Well, 95% test coverage is okay I guess

This commit is contained in:
Miguel Jacq 2025-11-13 11:52:21 +11:00
parent ab5ec2bfae
commit db0476f9ad
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
15 changed files with 1851 additions and 78 deletions

View file

@ -166,3 +166,42 @@ def test_compact_error_path(monkeypatch, tmp_db_cfg):
db.conn = BadConn()
# Should not raise; just print error
db.compact()
class _Cur:
def __init__(self, rows):
self._rows = rows
def execute(self, *a, **k):
return self
def fetchall(self):
return list(self._rows)
class _Conn:
def __init__(self, rows):
self._rows = rows
def cursor(self):
return _Cur(self._rows)
def test_integrity_check_raises_with_details(tmp_db_cfg):
db = DBManager(tmp_db_cfg)
assert db.connect()
# Force the integrity check to report problems with text details
db.conn = _Conn([("bad page checksum",), (None,)])
with pytest.raises(sqlite.IntegrityError) as ei:
db._integrity_ok()
# Message should contain the detail string
assert "bad page checksum" in str(ei.value)
def test_integrity_check_raises_without_details(tmp_db_cfg):
db = DBManager(tmp_db_cfg)
assert db.connect()
# Force the integrity check to report problems but without textual details
db.conn = _Conn([(None,), (None,)])
with pytest.raises(sqlite.IntegrityError):
db._integrity_ok()

View file

@ -1,6 +1,7 @@
import pytest
from PySide6.QtGui import QTextCursor
from PySide6.QtWidgets import QTextEdit, QWidget
from bouquin.markdown_editor import MarkdownEditor
from bouquin.theme import ThemeManager, ThemeConfig, Theme
from bouquin.find_bar import FindBar
@ -133,3 +134,40 @@ def test_maybe_hide_and_wrap_prev(qtbot, editor):
c.movePosition(QTextCursor.Start)
editor.setTextCursor(c)
fb.find_prev()
def _make_fb(editor, qtbot):
"""Create a FindBar with a live parent kept until teardown."""
parent = QWidget()
qtbot.addWidget(parent)
fb = FindBar(editor=editor, parent=parent)
qtbot.addWidget(fb)
parent.show()
fb.show()
return fb, parent
def test_find_next_early_returns_no_editor(qtbot):
# No editor: should early return and not crash
fb, _parent = _make_fb(editor=None, qtbot=qtbot)
fb.find_next()
def test_find_next_early_returns_empty_text(qtbot):
ed = QTextEdit()
fb, _parent = _make_fb(editor=ed, qtbot=qtbot)
fb.edit.setText("") # empty -> early return
fb.find_next()
def test_find_prev_early_returns_empty_text(qtbot):
ed = QTextEdit()
fb, _parent = _make_fb(editor=ed, qtbot=qtbot)
fb.edit.setText("") # empty -> early return
fb.find_prev()
def test_update_highlight_early_returns_no_editor(qtbot):
fb, _parent = _make_fb(editor=None, qtbot=qtbot)
fb.edit.setText("abc")
fb._update_highlight() # should return without error

View file

@ -1,4 +1,4 @@
from PySide6.QtWidgets import QWidget, QMessageBox
from PySide6.QtWidgets import QWidget, QMessageBox, QApplication
from PySide6.QtCore import Qt, QTimer
from bouquin.history_dialog import HistoryDialog
@ -83,3 +83,87 @@ def test_history_dialog_revert_error_shows_message(qtbot, fresh_db):
dlg._revert()
finally:
t.stop()
def test_revert_returns_when_no_item_selected(qtbot, fresh_db):
d = "2000-01-01"
fresh_db.save_new_version(d, "v1", "first")
w = QWidget()
dlg = HistoryDialog(fresh_db, d, parent=w)
qtbot.addWidget(dlg)
dlg.show()
# No selection at all -> early return
dlg.list.clearSelection()
dlg._revert() # should not raise
def test_revert_returns_when_current_selected(qtbot, fresh_db):
d = "2000-01-02"
fresh_db.save_new_version(d, "v1", "first")
# Create a second version so there is a 'current'
fresh_db.save_new_version(d, "v2", "second")
w = QWidget()
dlg = HistoryDialog(fresh_db, d, parent=w)
qtbot.addWidget(dlg)
dlg.show()
# Select the current item -> early return
for i in range(dlg.list.count()):
item = dlg.list.item(i)
if item.data(Qt.UserRole) == dlg._current_id:
dlg.list.setCurrentItem(item)
break
dlg._revert() # no-op
def test_revert_exception_shows_message(qtbot, fresh_db, monkeypatch):
"""
Trigger the exception path in _revert() and auto-accept the modal
QMessageBox that HistoryDialog pops so the test doesn't hang.
"""
d = "2000-01-03"
fresh_db.save_new_version(d, "v1", "first")
fresh_db.save_new_version(d, "v2", "second")
w = QWidget()
dlg = HistoryDialog(fresh_db, d, parent=w)
qtbot.addWidget(dlg)
dlg.show()
# Select a non-current item
for i in range(dlg.list.count()):
item = dlg.list.item(i)
if item.data(Qt.UserRole) != dlg._current_id:
dlg.list.setCurrentItem(item)
break
# Make revert raise to hit the except/critical message path.
def boom(*_a, **_k):
raise RuntimeError("nope")
monkeypatch.setattr(dlg._db, "revert_to_version", boom)
# Prepare a small helper that keeps trying to close an active modal box,
# but gives up after a bounded number of attempts.
def make_closer(max_tries=50, interval_ms=10):
tries = {"n": 0}
def closer():
tries["n"] += 1
w = QApplication.activeModalWidget()
if isinstance(w, QMessageBox):
# Prefer clicking the OK button if present; otherwise accept().
ok = w.button(QMessageBox.Ok)
if ok is not None:
ok.click()
else:
w.accept()
elif tries["n"] < max_tries:
QTimer.singleShot(interval_ms, closer)
return closer
# Schedule auto-close right before we trigger the modal dialog.
QTimer.singleShot(0, make_closer())
# Should show the critical box, which our timer will accept; _revert returns.
dlg._revert()

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,19 @@
import pytest
from PySide6.QtCore import Qt, QPoint
from PySide6.QtGui import QImage, QColor, QKeyEvent, QTextCursor
from PySide6.QtGui import (
QImage,
QColor,
QKeyEvent,
QTextCursor,
QTextDocument,
QFont,
QTextCharFormat,
)
from PySide6.QtWidgets import QTextEdit
from bouquin.markdown_editor import MarkdownEditor
from bouquin.markdown_highlighter import MarkdownHighlighter
from bouquin.theme import ThemeManager, ThemeConfig, Theme
@ -32,6 +43,15 @@ def editor(app, qtbot):
return ed
@pytest.fixture
def editor_hello(app):
tm = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
e = MarkdownEditor(tm)
e.setPlainText("hello")
e.moveCursor(QTextCursor.MoveOperation.End)
return e
def test_from_and_to_markdown_roundtrip(editor):
md = "# Title\n\nThis is **bold** and _italic_ and ~~strike~~.\n\n- [ ] task\n- [x] done\n\n```\ncode\n```"
editor.from_markdown(md)
@ -69,8 +89,8 @@ def test_insert_image_from_path(editor, tmp_path):
editor.insert_image_from_path(img)
md = editor.to_markdown()
# Images are saved as base64 data URIs in markdown
assert "data:image/image/png;base64" in md
# Accept either "image/png" or older "image/image/png" prefix
assert "data:image/png;base64" in md or "data:image/image/png;base64" in md
@pytest.mark.gui
@ -83,13 +103,10 @@ def test_checkbox_toggle_by_click(editor, qtbot):
# Click on the first character region to toggle
c = editor.textCursor()
from PySide6.QtGui import QTextCursor
c.movePosition(QTextCursor.StartOfBlock)
editor.setTextCursor(c)
r = editor.cursorRect()
center = r.center()
# Send click slightly right to land within checkbox icon region
pos = QPoint(r.left() + 2, center.y())
qtbot.mouseClick(editor.viewport(), Qt.LeftButton, pos=pos)
@ -164,7 +181,7 @@ def test_triple_backtick_autoexpands(editor, qtbot):
def test_toolbar_inserts_block_on_own_lines(editor, qtbot):
editor.from_markdown("hello")
editor.moveCursor(QTextCursor.End)
editor.apply_code() # </> action
editor.apply_code() # </> action inserts fenced code block
qtbot.wait(0)
t = text(editor)
@ -270,3 +287,271 @@ def test_no_orphan_two_backticks_lines_after_edits(editor, qtbot):
# ensure there are no stray "``" lines
assert not any(ln.strip() == "``" for ln in lines_keep(editor))
def _fmt_at(block, pos):
"""Return a *copy* of the char format at pos so it doesn't dangle."""
layout = block.layout()
for fr in list(layout.formats()):
if fr.start <= pos < fr.start + fr.length:
return QTextCharFormat(fr.format)
return None
@pytest.fixture
def highlighter(app):
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
doc = QTextDocument()
hl = MarkdownHighlighter(doc, themes)
return doc, hl
def test_headings_and_inline_styles(highlighter):
doc, hl = highlighter
doc.setPlainText("# H1\n## H2\n### H3\n***b+i*** **b** *i* __b__ _i_\n")
hl.rehighlight()
# H1: '#' markers hidden (very small size), text bold/larger
b0 = doc.findBlockByNumber(0)
fmt_marker = _fmt_at(b0, 0)
assert fmt_marker is not None
assert fmt_marker.fontPointSize() <= 0.2 # marker hidden
fmt_h1_text = _fmt_at(b0, 2)
assert fmt_h1_text is not None
assert fmt_h1_text.fontWeight() == QFont.Weight.Bold
# Bold-italic precedence
b3 = doc.findBlockByNumber(3)
line = b3.text()
triple = "***b+i***"
start = line.find(triple)
assert start != -1
pos_inside = start + 3 # skip the *** markers, land on 'b'
f_bi_inner = _fmt_at(b3, pos_inside)
assert f_bi_inner is not None
assert f_bi_inner.fontWeight() == QFont.Weight.Bold and f_bi_inner.fontItalic()
# Bold without triples
f_b = _fmt_at(b3, b3.text().find("**b**") + 2)
assert f_b.fontWeight() == QFont.Weight.Bold
# Italic without bold
f_i = _fmt_at(b3, b3.text().rfind("_i_") + 1)
assert f_i.fontItalic()
def test_code_blocks_inline_code_and_strike_overlay(highlighter):
doc, hl = highlighter
doc.setPlainText("```\n**B**\n```\nX ~~**boom**~~ Y `code`\n")
hl.rehighlight()
# Fence and inner lines use code block format
fence = doc.findBlockByNumber(0)
inner = doc.findBlockByNumber(1)
fmt_fence = _fmt_at(fence, 0)
fmt_inner = _fmt_at(inner, 0)
assert fmt_fence is not None and fmt_inner is not None
# check key properties
assert fmt_inner.fontFixedPitch() or fmt_inner.font().styleHint() == QFont.Monospace
assert fmt_inner.background() == hl.code_block_format.background()
# Inline code uses fixed pitch and hides the backticks
inline = doc.findBlockByNumber(3)
start = inline.text().find("`code`")
fmt_inline_char = _fmt_at(inline, start + 1)
fmt_inline_tick = _fmt_at(inline, start)
assert fmt_inline_char is not None and fmt_inline_tick is not None
assert fmt_inline_char.fontFixedPitch()
assert fmt_inline_tick.fontPointSize() <= 0.2 # backtick hidden
boom_pos = inline.text().find("boom")
fmt_boom = _fmt_at(inline, boom_pos)
assert fmt_boom is not None
assert fmt_boom.fontStrikeOut() and fmt_boom.fontWeight() == QFont.Weight.Bold
def test_theme_change_rehighlight(highlighter):
doc, hl = highlighter
hl._on_theme_changed()
doc.setPlainText("`x`")
hl.rehighlight()
b = doc.firstBlock()
fmt = _fmt_at(b, 1)
assert fmt is not None and fmt.fontFixedPitch()
@pytest.fixture
def hl_light(app):
# Light theme path (covers lines ~74-75 in _on_theme_changed)
tm = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
doc = QTextDocument()
hl = MarkdownHighlighter(doc, tm)
return doc, hl
@pytest.fixture
def hl_light_edit(app, qtbot):
tm = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
doc = QTextDocument()
edit = QTextEdit() # <-- give the doc a layout
edit.setDocument(doc)
qtbot.addWidget(edit)
edit.show()
qtbot.wait(10) # let Qt build the layouts
hl = MarkdownHighlighter(doc, tm)
return doc, hl, edit
def fmt(doc, block_no, pos):
"""Return the QTextCharFormat at character position `pos` in the given block."""
b = doc.findBlockByNumber(block_no)
it = b.begin()
off = 0
while not it.atEnd():
frag = it.fragment()
length = frag.length() # includes chars in this fragment
if off + length > pos:
return frag.charFormat()
off += length
it = it.next()
# Fallback (shouldn't happen in our tests)
cf = QTextCharFormat()
return cf
def test_light_palette_specific_colors(hl_light_edit, qtbot):
doc, hl, edit = hl_light_edit
doc.setPlainText("```\ncode\n```")
hl.rehighlight()
# the second block ("code") is the one inside the fenced block
b_code = doc.firstBlock().next()
fmt = _fmt_at(b_code, 0)
assert fmt is not None and fmt.background().style() != 0
def test_code_block_light_colors(hl_light):
"""Ensure code block colors use the light palette (covers 74-75)."""
doc, hl = hl_light
doc.setPlainText("```\ncode\n```")
hl.rehighlight()
# Background is a light gray and text is dark/black-ish in light theme
bg = hl.code_block_format.background().color()
fg = hl.code_block_format.foreground().color()
assert bg.red() >= 240 and bg.green() >= 240 and bg.blue() >= 240
assert fg.red() < 40 and fg.green() < 40 and fg.blue() < 40
def test_end_guard_skips_italic_followed_by_marker(hl_light):
"""
Triggers the end-following guard for italic (line ~208), e.g. '*i**'.
"""
doc, hl = hl_light
doc.setPlainText("*i**")
hl.rehighlight()
# The 'i' should not get italic due to the guard (closing '*' followed by '*')
f = fmt(doc, 0, 1)
assert not f.fontItalic()
@pytest.mark.gui
def test_char_rect_at_edges_and_click_checkbox(editor, qtbot):
"""
Exercises char_rect_at()-style logic and checkbox toggle via click
to push coverage on geometry-dependent paths.
"""
editor.from_markdown("- [ ] task")
c = editor.textCursor()
c.movePosition(QTextCursor.StartOfBlock)
editor.setTextCursor(c)
r = editor.cursorRect()
qtbot.mouseClick(editor.viewport(), Qt.LeftButton, pos=r.center())
assert "" in editor.toPlainText()
@pytest.mark.gui
def test_heading_apply_levels_and_inline_styles(editor):
editor.setPlainText("hello")
editor.selectAll()
editor.apply_heading(18) # H2
assert editor.toPlainText().startswith("## ")
editor.selectAll()
editor.apply_heading(12) # normal
assert not editor.toPlainText().startswith("#")
# Bold/italic/strike together to nudge style branches
editor.setPlainText("hi")
editor.selectAll()
editor.apply_weight()
editor.apply_italic()
editor.apply_strikethrough()
md = editor.to_markdown()
assert "**" in md and "*" in md and "~~" in md
@pytest.mark.gui
def test_insert_image_and_markdown_roundtrip(editor, tmp_path):
img = tmp_path / "p.png"
qimg = QImage(2, 2, QImage.Format_RGBA8888)
qimg.fill(QColor(255, 0, 0))
assert qimg.save(str(img))
editor.insert_image_from_path(img)
# At least a replacement char shows in the plain-text view
assert "\ufffc" in editor.toPlainText()
# And markdown contains a data: URI
assert "data:image" in editor.to_markdown()
def test_apply_italic_and_strike(editor):
# Italic: insert markers with no selection and place caret in between
editor.setPlainText("x")
editor.moveCursor(QTextCursor.MoveOperation.End)
editor.apply_italic()
assert editor.toPlainText().endswith("x**")
assert editor.textCursor().position() == len(editor.toPlainText()) - 1
# With selection toggling
editor.setPlainText("*y*")
c = editor.textCursor()
c.movePosition(QTextCursor.MoveOperation.End, QTextCursor.MoveMode.MoveAnchor)
c.movePosition(QTextCursor.MoveOperation.Start, QTextCursor.MoveMode.KeepAnchor)
editor.setTextCursor(c)
editor.apply_italic()
assert editor.toPlainText() == "y"
# Strike: no selection case inserts placeholder and moves caret
editor.setPlainText("z")
editor.moveCursor(QTextCursor.MoveOperation.End)
editor.apply_strikethrough()
assert editor.toPlainText().endswith("z~~~~")
assert editor.textCursor().position() == len(editor.toPlainText()) - 2
def test_apply_code_inline_block_navigation(editor):
# Selection case -> fenced block around selection
editor.setPlainText("code")
c = editor.textCursor()
c.select(QTextCursor.SelectionType.Document)
editor.setTextCursor(c)
editor.apply_code()
assert "```\ncode\n```\n" in editor.toPlainText()
# No selection, at EOF with no following block -> creates block and extra newline path
editor.setPlainText("before")
editor.moveCursor(QTextCursor.MoveOperation.End)
editor.apply_code()
t = editor.toPlainText()
assert t.endswith("before\n```\n\n```\n")
# Caret should be inside the code block blank line
assert editor.textCursor().position() == len("before\n") + 4
def test_insert_image_from_path_invalid_returns(editor_hello, tmp_path):
# Non-existent path should just return (early exit)
bad = tmp_path / "missing.png"
editor_hello.insert_image_from_path(bad)
# Nothing new added
assert editor_hello.toPlainText() == "hello"

View file

@ -1,12 +1,13 @@
import pytest
import bouquin.settings_dialog as sd
from bouquin.db import DBManager, DBConfig
from bouquin.key_prompt import KeyPrompt
import bouquin.settings_dialog as sd
from bouquin.settings_dialog import SettingsDialog
from bouquin.theme import ThemeManager, ThemeConfig, Theme
from bouquin.settings import get_settings
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication, QMessageBox, QWidget
from PySide6.QtWidgets import QApplication, QMessageBox, QWidget, QDialog
@pytest.mark.gui
@ -225,3 +226,207 @@ def test_settings_browse_sets_path(qtbot, app, tmp_path, fresh_db, monkeypatch):
)
dlg._browse()
assert dlg.path_edit.text().endswith("new_file.db")
class _Host(QWidget):
def __init__(self, themes):
super().__init__()
self.themes = themes
def _make_host_and_dialog(tmp_db_cfg, fresh_db):
# Create a real ThemeManager so we don't have to fake anything here
from PySide6.QtWidgets import QApplication
themes = ThemeManager(QApplication.instance(), ThemeConfig(theme=Theme.SYSTEM))
host = _Host(themes)
dlg = SettingsDialog(tmp_db_cfg, fresh_db, parent=host)
return host, dlg
def _clear_qsettings_theme_to_system():
"""Make the radio-button default deterministic across the full suite."""
s = get_settings()
s.clear()
s.setValue("ui/theme", "system")
def test_default_theme_radio_is_system_checked(qtbot, tmp_db_cfg, fresh_db):
# Ensure no stray theme value from previous tests
_clear_qsettings_theme_to_system()
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
qtbot.addWidget(host)
qtbot.addWidget(dlg)
# With fresh settings (system), the 'system' radio should be selected
assert dlg.theme_system.isChecked()
def test_save_selects_system_when_no_explicit_choice(
qtbot, tmp_db_cfg, fresh_db, monkeypatch
):
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
qtbot.addWidget(dlg)
# Ensure neither dark nor light is checked so SYSTEM path is taken
dlg.theme_dark.setChecked(False)
dlg.theme_light.setChecked(False)
# This should not raise
dlg._save()
def test_save_with_dark_selected(qtbot, tmp_db_cfg, fresh_db):
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
qtbot.addWidget(dlg)
dlg.theme_dark.setChecked(True)
dlg._save()
def test_change_key_cancel_first_prompt(qtbot, tmp_db_cfg, fresh_db, monkeypatch):
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
qtbot.addWidget(dlg)
class P1:
def __init__(self, *a, **k):
pass
def exec(self):
return QDialog.Rejected
def key(self):
return ""
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", P1)
dlg._change_key() # returns early
def test_change_key_cancel_second_prompt(qtbot, tmp_db_cfg, fresh_db, monkeypatch):
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
qtbot.addWidget(dlg)
class P1:
def __init__(self, *a, **k):
pass
def exec(self):
return QDialog.Accepted
def key(self):
return "abc"
class P2:
def __init__(self, *a, **k):
pass
def exec(self):
return QDialog.Rejected
def key(self):
return "abc"
# First call yields P1, second yields P2
seq = [P1, P2]
def _factory(*a, **k):
cls = seq.pop(0)
return cls(*a, **k)
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", _factory)
dlg._change_key() # returns early
def test_change_key_empty_and_exception_paths(qtbot, tmp_db_cfg, fresh_db, monkeypatch):
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
qtbot.addWidget(dlg)
# Timer that auto-accepts any modal QMessageBox so we don't hang.
def _pump_boxes():
# Try both the active modal and the general top-level enumeration
m = QApplication.activeModalWidget()
if isinstance(m, QMessageBox):
m.accept()
for w in QApplication.topLevelWidgets():
if isinstance(w, QMessageBox):
w.accept()
timer = QTimer()
timer.setInterval(10)
timer.timeout.connect(_pump_boxes)
timer.start()
try:
class P1:
def __init__(self, *a, **k):
pass
def exec(self):
return QDialog.Accepted
def key(self):
return ""
class P2:
def __init__(self, *a, **k):
pass
def exec(self):
return QDialog.Accepted
def key(self):
return ""
seq = [P1, P2, P1, P2]
def _factory(*a, **k):
cls = seq.pop(0)
return cls(*a, **k)
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", _factory)
# First run triggers empty-key warning path and return (auto-closed)
dlg._change_key()
# Now make rekey() raise to hit the except block (critical dialog)
def boom(*a, **k):
raise RuntimeError("nope")
dlg._db.rekey = boom
# Return a non-empty matching key twice
class P3:
def __init__(self, *a, **k):
pass
def exec(self):
return QDialog.Accepted
def key(self):
return "secret"
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: P3())
dlg._change_key()
finally:
timer.stop()
def test_save_key_btn_clicked_cancel_flow(qtbot, tmp_db_cfg, fresh_db, monkeypatch):
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
qtbot.addWidget(dlg)
# Make sure we start with no key saved so it will prompt
dlg.key = ""
class P1:
def __init__(self, *a, **k):
pass
def exec(self):
return QDialog.Rejected
def key(self):
return ""
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", P1)
dlg.save_key_btn.setChecked(True) # toggles and calls handler
# Handler should have undone the checkbox back to False
assert not dlg.save_key_btn.isChecked()

7
tests/test_strings.py Normal file
View file

@ -0,0 +1,7 @@
from bouquin import strings
def test_load_strings_uses_system_locale_and_fallback():
# pass a bogus locale to trigger fallback-to-default
strings.load_strings("zz")
assert strings._("next") # key exists in base translations

View file

@ -1,5 +1,6 @@
import pytest
from PySide6.QtGui import QPalette
from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget
from bouquin.theme import Theme, ThemeConfig, ThemeManager
@ -19,3 +20,35 @@ def test_theme_manager_system_roundtrip(app, qtbot):
cfg = ThemeConfig(theme=Theme.SYSTEM)
mgr = ThemeManager(app, cfg)
mgr.apply(cfg.theme)
def _make_themes(theme):
app = QApplication.instance()
return ThemeManager(app, ThemeConfig(theme=theme))
def test_register_and_restyle_calendar_and_overlay(qtbot):
themes = _make_themes(Theme.DARK)
cal = QCalendarWidget()
ov = QWidget()
ov.setObjectName("LockOverlay")
qtbot.addWidget(cal)
qtbot.addWidget(ov)
themes.register_calendar(cal)
themes.register_lock_overlay(ov)
# Force a restyle pass (covers the "is not None" branches)
themes._restyle_registered()
def test_apply_dark_styles_cover_css_paths(qtbot):
themes = _make_themes(Theme.DARK)
cal = QCalendarWidget()
ov = QWidget()
ov.setObjectName("LockOverlay")
qtbot.addWidget(cal)
qtbot.addWidget(ov)
themes.register_calendar(cal) # drives _apply_calendar_theme (dark path)
themes.register_lock_overlay(ov) # drives _apply_lock_overlay_theme (dark path)