diff --git a/bouquin/find_bar.py b/bouquin/find_bar.py index 5332beb..ae0206b 100644 --- a/bouquin/find_bar.py +++ b/bouquin/find_bar.py @@ -122,8 +122,6 @@ class FindBar(QWidget): return flags def find_next(self): - if not self.editor: - return txt = self.edit.text() if not txt: return @@ -149,8 +147,6 @@ class FindBar(QWidget): self._update_highlight() def find_prev(self): - if not self.editor: - return txt = self.edit.text() if not txt: return diff --git a/bouquin/history_dialog.py b/bouquin/history_dialog.py index 5e2e9ab..8429f5e 100644 --- a/bouquin/history_dialog.py +++ b/bouquin/history_dialog.py @@ -163,8 +163,6 @@ class HistoryDialog(QDialog): @Slot() def _revert(self): item = self.list.currentItem() - if not item: - return sel_id = item.data(Qt.UserRole) if sel_id == self._current_id: return diff --git a/bouquin/locales/fr.json b/bouquin/locales/fr.json index fe55464..49fde4f 100644 --- a/bouquin/locales/fr.json +++ b/bouquin/locales/fr.json @@ -111,4 +111,3 @@ "toolbar_heading": "Titre", "toolbar_toggle_checkboxes": "Cocher/Décocher les cases" } - diff --git a/bouquin/lock_overlay.py b/bouquin/lock_overlay.py index f40e7f5..61a52e5 100644 --- a/bouquin/lock_overlay.py +++ b/bouquin/lock_overlay.py @@ -18,8 +18,6 @@ class LockOverlay(QWidget): self.setFocusPolicy(Qt.StrongFocus) self.setGeometry(parent.rect()) - self._last_dark: bool | None = None - lay = QVBoxLayout(self) lay.addStretch(1) diff --git a/bouquin/main_window.py b/bouquin/main_window.py index d1987d7..abaf495 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -675,6 +675,9 @@ class MainWindow(QMainWindow): def _save_editor_content(self, editor: MarkdownEditor): """Save a specific editor's content to its associated date.""" + # Skip if DB is missing or not connected somehow. + if not getattr(self, "db", None) or getattr(self.db, "conn", None) is None: + return if not hasattr(editor, "current_date"): return date_iso = editor.current_date.toString("yyyy-MM-dd") @@ -773,9 +776,13 @@ class MainWindow(QMainWindow): Save editor contents into the given date. Shows status on success. explicit=True means user invoked Save: show feedback even if nothing changed. """ + # Bail out if there is no DB connection (can happen during construction/teardown) + if not getattr(self.db, "conn", None): + return + if not self._dirty and not explicit: return - text = self.editor.to_markdown() + text = self.editor.to_markdown() if hasattr(self, "editor") else "" self.db.save_new_version(date_iso, text, note) self._dirty = False self._refresh_calendar_marks() @@ -867,13 +874,14 @@ class MainWindow(QMainWindow): fmt.setFontWeight(QFont.Weight.Normal) # remove bold only self.calendar.setDateTextFormat(d, fmt) self._marked_dates = set() - for date_iso in self.db.dates_with_content(): - qd = QDate.fromString(date_iso, "yyyy-MM-dd") - if qd.isValid(): - fmt = self.calendar.dateTextFormat(qd) - fmt.setFontWeight(QFont.Weight.Bold) # add bold only - self.calendar.setDateTextFormat(qd, fmt) - self._marked_dates.add(qd) + if self.db.conn is not None: + for date_iso in self.db.dates_with_content(): + qd = QDate.fromString(date_iso, "yyyy-MM-dd") + if qd.isValid(): + fmt = self.calendar.dateTextFormat(qd) + fmt.setFontWeight(QFont.Weight.Bold) # add bold only + self.calendar.setDateTextFormat(qd, fmt) + self._marked_dates.add(qd) # -------------------- UI handlers ------------------- # @@ -1248,17 +1256,39 @@ class MainWindow(QMainWindow): # ----------------- Close handlers ----------------- # def closeEvent(self, event): - # Save window position - self.settings.setValue("main/geometry", self.saveGeometry()) - self.settings.setValue("main/windowState", self.saveState()) - self.settings.setValue("main/maximized", self.isMaximized()) + # Persist geometry if settings exist (window might be half-initialized). + if getattr(self, "settings", None) is not None: + try: + self.settings.setValue("main/geometry", self.saveGeometry()) + self.settings.setValue("main/windowState", self.saveState()) + self.settings.setValue("main/maximized", self.isMaximized()) + except Exception: + pass + + # Stop timers if present to avoid late autosaves firing during teardown. + for _t in ("_autosave_timer", "_idle_timer"): + t = getattr(self, _t, None) + if t: + t.stop() + + # Save content from tabs if the database is still connected + db = getattr(self, "db", None) + conn = getattr(db, "conn", None) + tw = getattr(self, "tab_widget", None) + if db is not None and conn is not None and tw is not None: + try: + for i in range(tw.count()): + editor = tw.widget(i) + if editor is not None: + self._save_editor_content(editor) + except Exception: + # Don't let teardown crash if one tab fails to save. + pass + try: + db.close() + except Exception: + pass - # Ensure we save all tabs before closing - for i in range(self.tab_widget.count()): - editor = self.tab_widget.widget(i) - if editor: - self._save_editor_content(editor) - self.db.close() super().closeEvent(event) # ----------------- Below logic helps focus the editor ----------------- # diff --git a/bouquin/markdown_highlighter.py b/bouquin/markdown_highlighter.py index 0c5e7c0..e68f03c 100644 --- a/bouquin/markdown_highlighter.py +++ b/bouquin/markdown_highlighter.py @@ -41,15 +41,15 @@ class MarkdownHighlighter(QSyntaxHighlighter): self.italic_format = QTextCharFormat() self.italic_format.setFontItalic(True) - # Strikethrough: ~~text~~ - self.strike_format = QTextCharFormat() - self.strike_format.setFontStrikeOut(True) - # Allow combination of bold/italic self.bold_italic_format = QTextCharFormat() self.bold_italic_format.setFontWeight(QFont.Weight.Bold) self.bold_italic_format.setFontItalic(True) + # Strikethrough: ~~text~~ + self.strike_format = QTextCharFormat() + self.strike_format.setFontStrikeOut(True) + # Inline code: `code` mono = QFontDatabase.systemFont(QFontDatabase.FixedFont) self.code_format = QTextCharFormat() @@ -163,25 +163,30 @@ class MarkdownHighlighter(QSyntaxHighlighter): self.setFormat(marker_len, len(text) - marker_len, heading_fmt) return - # Bold+Italic: ***text*** or ___text___ - # Do these first and remember their spans so later passes don't override them. - occupied = [] + # Bold+Italic (*** or ___): do these first and record occupied spans. + # --- Triple emphasis: detect first, hide markers now, but DEFER applying content style + triple_contents: list[tuple[int, int]] = [] # (start, length) for content only + occupied: list[tuple[int, int]] = ( + [] + ) # full spans including markers, for overlap checks + for m in re.finditer( r"(? 0 and text[start - 1 : start + 1] in ("**", "__"): continue if end < len(text) and text[end : end + 1] in ("*", "_"): @@ -213,6 +218,10 @@ class MarkdownHighlighter(QSyntaxHighlighter): content_start, content_end - content_start, self.italic_format ) + # --- NOW overlay bold+italic for triple contents LAST (so nothing clobbers it) + for cs, length in triple_contents: + self._overlay_range(cs, length, self.bold_italic_format) + # Strikethrough: ~~text~~ for m in re.finditer(r"~~(.+?)~~", text): start, end = m.span() diff --git a/bouquin/settings_dialog.py b/bouquin/settings_dialog.py index f3beccf..56a1ecd 100644 --- a/bouquin/settings_dialog.py +++ b/bouquin/settings_dialog.py @@ -303,7 +303,7 @@ class SettingsDialog(QDialog): self, strings._("key_changed"), strings._("key_changed_explanation") ) except Exception as e: - QMessageBox.critical(self, strings._("error"), e) + QMessageBox.critical(self, strings._("error"), str(e)) @Slot(bool) def _save_key_btn_clicked(self, checked: bool): @@ -330,7 +330,7 @@ class SettingsDialog(QDialog): self, strings._("success"), strings._("database_compacted_successfully") ) except Exception as e: - QMessageBox.critical(self, strings._("error"), e) + QMessageBox.critical(self, strings._("error"), str(e)) @property def config(self) -> DBConfig: diff --git a/tests/test_db.py b/tests/test_db.py index 42e50f3..fb3445f 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -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() diff --git a/tests/test_find_bar.py b/tests/test_find_bar.py index 47fab42..bccfd39 100644 --- a/tests/test_find_bar.py +++ b/tests/test_find_bar.py @@ -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 diff --git a/tests/test_history_dialog.py b/tests/test_history_dialog.py index 8b58f7a..b1cef62 100644 --- a/tests/test_history_dialog.py +++ b/tests/test_history_dialog.py @@ -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() diff --git a/tests/test_main_window.py b/tests/test_main_window.py index cad9139..b266ffc 100644 --- a/tests/test_main_window.py +++ b/tests/test_main_window.py @@ -1,11 +1,15 @@ import pytest +from pathlib import Path + import bouquin.main_window as mwmod from bouquin.main_window import MainWindow from bouquin.theme import Theme, ThemeConfig, ThemeManager from bouquin.settings import get_settings from bouquin.key_prompt import KeyPrompt -from PySide6.QtCore import QEvent, QDate, QTimer -from PySide6.QtWidgets import QTableView, QApplication +from bouquin.db import DBConfig, DBManager +from PySide6.QtCore import QEvent, QDate, QTimer, Qt, QPoint, QRect +from PySide6.QtWidgets import QTableView, QApplication, QWidget, QMessageBox +from PySide6.QtGui import QMouseEvent, QKeyEvent, QTextCursor, QCloseEvent @pytest.mark.gui @@ -127,25 +131,20 @@ def test_export_success_and_error(qtbot, app, fresh_db, tmp_path, monkeypatch): # Save as Markdown without extension -> should append .md and write file dest1 = tmp_path / "export_one" # no suffix + monkeypatch.setattr( + mwmod.QFileDialog, + "getSaveFileName", + staticmethod(lambda *a, **k: (str(dest1), "Markdown (*.md)")), + ) - def fake_save1(*a, **k): - return str(dest1), "Markdown (*.md)" - - monkeypatch.setattr(mwmod.QFileDialog, "getSaveFileName", staticmethod(fake_save1)) - - info_log = {"ok": False} - - # Auto-accept the warning dialog + # Use real QMessageBox class; just force decisions and silence popups monkeypatch.setattr( mwmod.QMessageBox, "exec", lambda self: mwmod.QMessageBox.Yes, raising=False ) - info_log = {"ok": False} monkeypatch.setattr( - mwmod.QMessageBox, - "information", - staticmethod(lambda *a, **k: info_log.__setitem__("ok", True) or 0), - raising=False, + mwmod.QMessageBox, "information", staticmethod(lambda *a, **k: 0), raising=False ) + # Critical should never trigger in the success path monkeypatch.setattr( mwmod.QMessageBox, "critical", @@ -154,9 +153,9 @@ def test_export_success_and_error(qtbot, app, fresh_db, tmp_path, monkeypatch): ), raising=False, ) + w._export() assert dest1.with_suffix(".md").exists() - assert info_log["ok"] # Now force an exception during export to hit error branch (patch the window's DB) def boom(): @@ -166,14 +165,13 @@ def test_export_success_and_error(qtbot, app, fresh_db, tmp_path, monkeypatch): # Different filename to avoid overwriting dest2 = tmp_path / "export_two" - - def fake_save2(*a, **k): - return str(dest2), "CSV (*.csv)" - - monkeypatch.setattr(mwmod.QFileDialog, "getSaveFileName", staticmethod(fake_save2)) + monkeypatch.setattr( + mwmod.QFileDialog, + "getSaveFileName", + staticmethod(lambda *a, **k: (str(dest2), "CSV (*.csv)")), + ) errs = {"hit": False} - # Auto-accept the warning dialog and capture the error message monkeypatch.setattr( mwmod.QMessageBox, "exec", lambda self: mwmod.QMessageBox.Yes, raising=False ) @@ -410,3 +408,1057 @@ def test_event_filter_keypress_starts_idle_timer(qtbot, app): w.show() ev = QEvent(QEvent.KeyPress) w.eventFilter(w, ev) + + +def _make_main_window(tmp_db_cfg, app, monkeypatch, fresh_db=None): + """Create a MainWindow wired to an existing db config so it doesn't prompt for key.""" + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + # Force MainWindow to pick up our provided cfg + monkeypatch.setattr(mwmod, "load_db_config", lambda: tmp_db_cfg, raising=True) + # Make sure DB connects fine + if fresh_db is None: + fresh_db = DBManager(tmp_db_cfg) + assert fresh_db.connect() + w = MainWindow(themes) + return w + + +def test_init_exits_when_prompt_rejected(app, monkeypatch, tmp_path): + themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + + # cfg with empty key and non-existent path => first_time True + cfg = DBConfig( + path=tmp_path / "does_not_exist.db", + key="", + idle_minutes=0, + theme="light", + move_todos=True, + ) + monkeypatch.setattr(mwmod, "load_db_config", lambda: cfg, raising=True) + + # Avoid accidentaly creating DB by short-circuiting the prompt loop + class MW(MainWindow): + def _prompt_for_key_until_valid(self, first_time: bool) -> bool: # noqa: N802 + assert first_time is True # hit line 73 path + return False + + with pytest.raises(SystemExit): + MW(themes) + + +@pytest.mark.parametrize( + "msg, expect_key_msg", + [ + ("file is not a database", True), + ("totally unrelated", False), + ], +) +def test_try_connect_maps_errors( + qtbot, tmp_db_cfg, app, monkeypatch, msg, expect_key_msg +): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + # Make connect() raise + def boom(self): + raise Exception(msg) + + monkeypatch.setattr(mwmod.DBManager, "connect", boom, raising=True) + + shown = {} + + def fake_critical(parent, title, text, *a, **k): + shown["title"] = title + shown["text"] = str(text) + return 0 + + monkeypatch.setattr( + mwmod.QMessageBox, "critical", staticmethod(fake_critical), raising=True + ) + + ok = w._try_connect() + assert ok is False + assert "database" in shown["title"].lower() + if expect_key_msg: + assert "key" in shown["text"].lower() + else: + assert "unrelated" in shown["text"].lower() + # If closeEvent later explodes here, that is an app bug (should guard partial init). + + +# ---- _prompt_for_key_until_valid (324-332) ---- + + +def test_prompt_for_key_cancel_returns_false(qtbot, tmp_db_cfg, app, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + class CancelPrompt: + def __init__(self, *a, **k): + pass + + def exec(self): + return mwmod.QDialog.Rejected + + def key(self): + return "" + + monkeypatch.setattr(mwmod, "KeyPrompt", CancelPrompt, raising=True) + assert w._prompt_for_key_until_valid(first_time=False) is False + + +def test_prompt_for_key_accept_then_connects(qtbot, tmp_db_cfg, app, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + class OKPrompt: + def __init__(self, *a, **k): + pass + + def exec(self): + return mwmod.QDialog.Accepted + + def key(self): + return "abc" + + monkeypatch.setattr(mwmod, "KeyPrompt", OKPrompt, raising=True) + monkeypatch.setattr(w, "_try_connect", lambda: True, raising=True) + assert w._prompt_for_key_until_valid(first_time=True) is True + assert w.cfg.key == "abc" + + +# ---- Tabs/date management (368, 383, 388-391, 427-428, …) ---- + + +def test_reorder_tabs_by_date_and_tab_lookup(qtbot, tmp_db_cfg, app, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + d1 = QDate.fromString("2024-01-10", "yyyy-MM-dd") + d2 = QDate.fromString("2024-01-01", "yyyy-MM-dd") + d3 = QDate.fromString("2024-02-01", "yyyy-MM-dd") + + e2 = w._create_new_tab(d2) + e3 = w._create_new_tab(d3) + e1 = w._create_new_tab(d1) # out of order on purpose + + # Force a reorder and verify ascending order for the three we added. + w._reorder_tabs_by_date() + order = [w.tab_widget.tabText(i) for i in range(w.tab_widget.count())] + today = QDate.currentDate().toString("yyyy-MM-dd") + order_wo_today = [d for d in order if d != today] + assert order_wo_today[:3] == ["2024-01-01", "2024-01-10", "2024-02-01"] + + # _tab_index_for_date finds existing and returns -1 for absent + assert w._tab_index_for_date(d2) != -1 + assert w._tab_index_for_date(QDate.fromString("1999-12-31", "yyyy-MM-dd")) == -1 + assert e1 is not None and e2 is not None and e3 is not None + + +def test_open_close_tabs_and_focus(qtbot, tmp_db_cfg, app, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + today = QDate.currentDate() + ed = w._open_date_in_tab(today) # creates new if needed + assert hasattr(ed, "current_date") + # make another tab to allow closing + w._create_new_tab(today.addDays(1)) + count = w.tab_widget.count() + w._close_tab(0) + assert w.tab_widget.count() == count - 1 + + +# ---- _set_editor_markdown_preserve_view & load with extra_data (631) ---- + + +def test_load_selected_date_appends_extra_data_with_newline( + qtbot, tmp_db_cfg, app, monkeypatch, fresh_db +): + w = _make_main_window(tmp_db_cfg, app, monkeypatch, fresh_db) + qtbot.addWidget(w) + w.db.save_new_version("2020-01-01", "first line", "seed") + w._load_selected_date("2020-01-01", extra_data="- [ ] carry") + assert "carry" in w.editor.toPlainText() + + +# ---- _save_editor_content early return (679) ---- + + +def test_save_editor_content_returns_without_current_date( + qtbot, tmp_db_cfg, app, monkeypatch +): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + from PySide6.QtWidgets import QTextEdit + + other = QTextEdit() + w._save_editor_content(other) # should early-return, not crash + + +# ---- _adjust_today creates a tab (695-696) ---- + + +def test_adjust_today_creates_tab(qtbot, tmp_db_cfg, app, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + w._adjust_today() + today = QDate.currentDate().toString("yyyy-MM-dd") + assert any(w.tab_widget.tabText(i) == today for i in range(w.tab_widget.count())) + + +# ---- _on_date_changed guard for context menus (745) ---- + + +def test_on_date_changed_early_return_when_context_menu( + qtbot, tmp_db_cfg, app, monkeypatch +): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + w._showing_context_menu = True + # Should not throw or load + w._on_date_changed() + assert w._showing_context_menu is True # not toggled here + + +# ---- _save_current explicit paths (793-801, 809-810) ---- + + +def test_save_current_explicit_cancel(qtbot, tmp_db_cfg, app, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + class CancelSave: + def __init__(self, *a, **k): + pass + + def exec(self): + return mwmod.QDialog.Rejected + + def note_text(self): + return "" + + monkeypatch.setattr(mwmod, "SaveDialog", CancelSave, raising=True) + # returns early, does not raise + w._save_current(explicit=True) + + +def test_save_current_explicit_accept(qtbot, tmp_db_cfg, app, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + class OKSave: + def __init__(self, *a, **k): + pass + + def exec(self): + return mwmod.QDialog.Accepted + + def note_text(self): + return "manual" + + monkeypatch.setattr(mwmod, "SaveDialog", OKSave, raising=True) + w._save_current(explicit=True) # should start/restart timers without error + + +# ---- Search highlighting (852-854) ---- + + +def test_apply_search_highlights_adds_and_clears(qtbot, tmp_db_cfg, app, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + d1 = QDate.currentDate() + d2 = d1.addDays(1) + w._apply_search_highlights({d1, d2}) + # now drop d2 so clear-path runs + w._apply_search_highlights({d1}) + fmt = w.calendar.dateTextFormat(d1) + assert fmt.background().style() != Qt.NoBrush + + +# ---- History dialog glue (956, 961-962) ---- + + +def test_open_history_accept_refreshes(qtbot, tmp_db_cfg, app, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + called = {"load": False, "refresh": False} + + def fake_load(date_iso=None): + called["load"] = True + + def fake_refresh(): + called["refresh"] = True + + monkeypatch.setattr(w, "_load_selected_date", fake_load, raising=True) + monkeypatch.setattr(w, "_refresh_calendar_marks", fake_refresh, raising=True) + + class Dlg: + def __init__(self, *a, **k): + pass + + def exec(self): + return mwmod.QDialog.Accepted + + monkeypatch.setattr(mwmod, "HistoryDialog", Dlg, raising=True) + w._open_history() + assert called["load"] and called["refresh"] + + +# ---- Insert image picker (974, 981-989) ---- + + +def test_on_insert_image_calls_editor_insert( + qtbot, tmp_db_cfg, app, monkeypatch, tmp_path +): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + # Make a tiny PNG file + from PySide6.QtGui import QImage + + img_path = tmp_path / "tiny.png" + img = QImage(1, 1, QImage.Format_ARGB32) + img.fill(0xFFFFFFFF) + img.save(str(img_path)) + + called = [] + + def fake_picker(*a, **k): + return ([str(img_path)], "Images (*.png)") + + monkeypatch.setattr( + mwmod.QFileDialog, "getOpenFileNames", staticmethod(fake_picker), raising=True + ) + + def recorder(path): + called.append(Path(path)) + + monkeypatch.setattr(w.editor, "insert_image_from_path", recorder, raising=True) + + w._on_insert_image() + assert called and called[0].name == "tiny.png" + + +# ---- Export flow branches (1062, 1078-1107) ---- + + +@pytest.mark.parametrize( + "filter_label, method", + [ + ("Text (*.txt)", "export_txt"), + ("JSON (*.json)", "export_json"), + ("CSV (*.csv)", "export_csv"), + ("HTML (*.html)", "export_html"), + ("Markdown (*.md)", "export_markdown"), + ("SQL (*.sql)", "export_sql"), + ], +) +def test_export_calls_correct_method( + qtbot, tmp_db_cfg, app, monkeypatch, tmp_path, filter_label, method +): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + # Confirm 'Yes' using the real QMessageBox; patch methods only + monkeypatch.setattr( + mwmod.QMessageBox, "exec", lambda self: mwmod.QMessageBox.Yes, raising=False + ) + monkeypatch.setattr( + mwmod.QMessageBox, "information", staticmethod(lambda *a, **k: 0), raising=False + ) + monkeypatch.setattr( + mwmod.QMessageBox, "critical", staticmethod(lambda *a, **k: 0), raising=False + ) + + # Return a path without extension to exercise default-ext logic too + out = tmp_path / "out" + monkeypatch.setattr( + mwmod.QFileDialog, + "getSaveFileName", + staticmethod(lambda *a, **k: (str(out), filter_label)), + raising=True, + ) + + # Provide some entries + monkeypatch.setattr( + w.db, "get_all_entries", lambda: [("2024-01-01", "x")], raising=True + ) + + called = {"name": None} + + def mark(*a, **k): + called["name"] = method + + monkeypatch.setattr(w.db, method, mark, raising=True) + w._export() + assert called["name"] == method + + +def test_export_unknown_filter_raises_and_is_caught( + qtbot, tmp_db_cfg, app, monkeypatch, tmp_path +): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + # Pre-export confirmation: Yes + monkeypatch.setattr( + mwmod.QMessageBox, "exec", lambda self: mwmod.QMessageBox.Yes, raising=False + ) + + out = tmp_path / "weird" + monkeypatch.setattr( + mwmod.QFileDialog, + "getSaveFileName", + staticmethod(lambda *a, **k: (str(out), "WUT (*.wut)")), + raising=True, + ) + + # Capture the critical call + seen = {} + + def critical(parent, title, text, *a, **k): + seen["title"] = title + seen["text"] = str(text) + return 0 + + monkeypatch.setattr( + mwmod.QMessageBox, "critical", staticmethod(critical), raising=False + ) + + # And stub entries + monkeypatch.setattr(w.db, "get_all_entries", lambda: [], raising=True) + + w._export() + assert "export" in seen["title"].lower() + + +# ---- Backup handler (1147-1148) ---- + + +def test_backup_success_and_error(qtbot, tmp_db_cfg, app, monkeypatch, tmp_path): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + # Always choose a filename and the SQL option + out = tmp_path / "backup" + monkeypatch.setattr( + mwmod.QFileDialog, + "getSaveFileName", + staticmethod(lambda *a, **k: (str(out), "SQLCipher (*.db)")), + raising=True, + ) + + called = {"ok": False, "err": False} + + def ok_export(path): + called["ok"] = True + + def crit(parent, title, text, *a, **k): + called["err"] = True + return 0 + + # First success + monkeypatch.setattr(w.db, "export_sqlcipher", ok_export, raising=True) + monkeypatch.setattr( + mwmod.QMessageBox, "information", staticmethod(lambda *a, **k: 0), raising=False + ) + monkeypatch.setattr( + mwmod.QMessageBox, "critical", staticmethod(crit), raising=False + ) + w._backup() + assert called["ok"] + + # Then failure + def boom(path): + raise RuntimeError("nope") + + monkeypatch.setattr(w.db, "export_sqlcipher", boom, raising=True) + w._backup() + assert called["err"] + + +# ---- Help openers (1152-1169) ---- + + +def test_open_docs_and_bugs_show_warning_on_failure( + qtbot, tmp_db_cfg, app, monkeypatch +): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + monkeypatch.setattr( + mwmod.QDesktopServices, "openUrl", staticmethod(lambda *a: False), raising=True + ) + seen = {"docs": False, "bugs": False} + + def warn(parent, title, text, *a, **k): + if "documentation" in title.lower(): + seen["docs"] = True + if "bug" in title.lower(): + seen["bugs"] = True + return 0 + + monkeypatch.setattr( + mwmod, "QMessageBox", type("MB", (), {"warning": staticmethod(warn)}) + ) + w._open_docs() + w._open_bugs() + assert seen["docs"] and seen["bugs"] + + +# ---- Idle/lock/event filter helpers (1176, 1181-1187, 1193-1202, 1231-1233) ---- + + +def test_apply_idle_minutes_paths_and_unlock(qtbot, tmp_db_cfg, app, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + # remove timer to hit early return + delattr(w, "_idle_timer") + w._apply_idle_minutes(5) # no crash => line 1176 branch + + # re-create a timer and simulate locking then disabling idle + w._idle_timer = QTimer(w) + w._idle_timer.setSingleShot(True) + w._locked = True + w._apply_idle_minutes(0) + assert not w._locked # unlocked and overlay hidden path covered + + +def test_event_filter_sets_context_flag_and_keystart( + qtbot, tmp_db_cfg, app, monkeypatch +): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + # Right-click on calendar with a real QMouseEvent + me = QMouseEvent( + QEvent.MouseButtonPress, + QPoint(0, 0), + Qt.RightButton, + Qt.RightButton, + Qt.NoModifier, + ) + w.eventFilter(w.calendar, me) + assert getattr(w, "_showing_context_menu", False) is True + + # KeyPress restarts idle timer when unlocked (real QKeyEvent) + w._locked = False + ke = QKeyEvent(QEvent.KeyPress, Qt.Key_A, Qt.NoModifier) + w.eventFilter(w, ke) + + +def test_unlock_exception_path(qtbot, tmp_db_cfg, app, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + def boom(*a, **k): + raise RuntimeError("nope") + + monkeypatch.setattr(w, "_prompt_for_key_until_valid", boom, raising=True) + + captured = {} + + def critical(parent, title, text, *a, **k): + captured["title"] = title + return 0 + + monkeypatch.setattr( + mwmod, "QMessageBox", type("MB", (), {"critical": staticmethod(critical)}) + ) + w._on_unlock_clicked() + assert "unlock" in captured["title"].lower() + + +# ---- Focus helpers (1273-1275, 1290) ---- + + +def test_focus_editor_now_and_app_state(qtbot, tmp_db_cfg, app, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + w.show() + # Locked => early return + w._locked = True + w._focus_editor_now() + # Active window path (force isActiveWindow) + w._locked = False + monkeypatch.setattr(w, "isActiveWindow", lambda: True, raising=True) + w._focus_editor_now() + # App state callback path + w._on_app_state_changed(Qt.ApplicationActive) + + +# ---- _rect_on_any_screen false path (1039) ---- + + +def test_rect_on_any_screen_false(qtbot, tmp_db_cfg, app, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + sc = QApplication.primaryScreen().availableGeometry() + far = QRect(sc.right() + 10_000, sc.bottom() + 10_000, 100, 100) + assert not w._rect_on_any_screen(far) + + +@pytest.mark.gui +def test_reorder_tabs_with_undated_page_stays_last(qtbot, app, tmp_db_cfg, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + # Create two dated tabs (out of order), plus an undated QWidget page. + d1 = QDate.fromString("2024-02-01", "yyyy-MM-dd") + d2 = QDate.fromString("2024-01-01", "yyyy-MM-dd") + w._create_new_tab(d1) + w._create_new_tab(d2) + + misc = QWidget() + w.tab_widget.addTab(misc, "misc") + + # Reorder and check: dated tabs sorted first, undated kept after them + w._reorder_tabs_by_date() + labels = [w.tab_widget.tabText(i) for i in range(w.tab_widget.count())] + assert labels[0] <= labels[1] + assert labels[-1] == "misc" + + # Also cover lookup for an existing date + assert w._tab_index_for_date(d2) != -1 + + +def test_index_for_date_insert_positions(qtbot, app, tmp_db_cfg, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + a = QDate.fromString("2024-01-01", "yyyy-MM-dd") + c = QDate.fromString("2024-03-01", "yyyy-MM-dd") + d = QDate.fromString("2024-04-01", "yyyy-MM-dd") + + def expected(): + key = (d.year(), d.month(), d.day()) + for i in range(w.tab_widget.count()): + ed = w.tab_widget.widget(i) + cur = getattr(ed, "current_date", None) + if isinstance(cur, QDate) and cur.isValid(): + if (cur.year(), cur.month(), cur.day()) > key: + return i + return w.tab_widget.count() + + w._create_new_tab(a) + w._create_new_tab(c) + + # B belongs between A and C + b = QDate.fromString("2024-02-01", "yyyy-MM-dd") + assert w._index_for_date_insert(b) == 1 + + # Date prior to first should insert at 0 + z = QDate.fromString("2023-12-31", "yyyy-MM-dd") + assert w._index_for_date_insert(z) == 0 + + # Date after last should append + d = QDate.fromString("2024-04-01", "yyyy-MM-dd") + assert w._index_for_date_insert(d) == expected() + + +def test_on_tab_changed_early_and_stop_guard(qtbot, app, tmp_db_cfg, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + # Early return path + w._on_tab_changed(-1) + + # Create two tabs then make stop() raise to hit try/except guard + w._create_new_tab(QDate.fromString("2024-01-01", "yyyy-MM-dd")) + w._create_new_tab(QDate.fromString("2024-01-02", "yyyy-MM-dd")) + + def boom(): + raise RuntimeError("stop failed") + + monkeypatch.setattr(w._save_timer, "stop", boom, raising=True) + w._on_tab_changed(1) # should not raise + + +@pytest.mark.gui +def test_show_calendar_context_menu_no_action(qtbot, app, tmp_db_cfg, monkeypatch): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + before = w.tab_widget.count() + + class DummyMenu: + def __init__(self, *a, **k): + pass + + def addAction(self, *a, **k): + return object() + + def exec_(self, *a, **k): + return None # return no action + + monkeypatch.setattr(mwmod, "QMenu", DummyMenu, raising=True) + w._show_calendar_context_menu(w.calendar.rect().center()) # nothing should happen + assert w.tab_widget.count() == before + + +@pytest.mark.gui +def test_export_cancel_then_empty_filename( + qtbot, app, tmp_db_cfg, monkeypatch, tmp_path +): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + # 1) cancel at the confirmation dialog + class FullMB: + Yes = QMessageBox.Yes + No = QMessageBox.No + Warning = QMessageBox.Warning + + def __init__(self, *a, **k): + pass + + def setWindowTitle(self, *a): + pass + + def setText(self, *a): + pass + + def setStandardButtons(self, *a): + pass + + def setIcon(self, *a): + pass + + def show(self): + pass + + def adjustSize(self): + pass + + def exec(self): + return self.No + + @staticmethod + def information(*a, **k): + return 0 + + @staticmethod + def critical(*a, **k): + return 0 + + monkeypatch.setattr(mwmod, "QMessageBox", FullMB, raising=True) + w._export() # returns early on No + + # 2) Yes in confirmation, but user cancels file dialog (empty filename) + class YesOnly(FullMB): + def exec(self): + return self.Yes + + monkeypatch.setattr(mwmod, "QMessageBox", YesOnly, raising=True) + monkeypatch.setattr( + mwmod.QFileDialog, + "getSaveFileName", + staticmethod(lambda *a, **k: ("", "Text (*.txt)")), + raising=False, + ) + w._export() # returns early at filename check + + +@pytest.mark.gui +def test_set_editor_markdown_preserve_view_preserves( + qtbot, app, tmp_db_cfg, monkeypatch +): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + long = "line\n" * 200 + w.editor.from_markdown(long) + w.editor.verticalScrollBar().setValue(50) + w.editor.moveCursor(QTextCursor.End) + pos_before = w.editor.textCursor().position() + v_before = w.editor.verticalScrollBar().value() + + # Same markdown → no rewrite, caret/scroll restored + w._set_editor_markdown_preserve_view(long) + assert w.editor.textCursor().position() == pos_before + assert w.editor.verticalScrollBar().value() == v_before + + # Different markdown → rewritten but caret restoration still executes + changed = long + "extra\n" + w._set_editor_markdown_preserve_view(changed) + assert w.editor.to_markdown().endswith("extra\n") + + +@pytest.mark.gui +def test_load_date_into_editor_with_extra_data_forces_save( + qtbot, app, tmp_db_cfg, monkeypatch +): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + called = {"iso": None, "explicit": None} + + def save_date(iso, explicit): + called.update(iso=iso, explicit=explicit) + + monkeypatch.setattr(w, "_save_date", save_date, raising=True) + d = QDate.fromString("2020-01-01", "yyyy-MM-dd") + w._load_date_into_editor(d, extra_data="- [ ] carry") + assert called["iso"] == "2020-01-01" and called["explicit"] is True + + +@pytest.mark.gui +def test_reorder_tabs_moves_and_undated(qtbot, app, tmp_db_cfg, monkeypatch): + """Covers moveTab for both dated and undated buckets.""" + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + # Create out-of-order dated tabs + d1 = QDate.fromString("2024-01-10", "yyyy-MM-dd") + d2 = QDate.fromString("2024-01-01", "yyyy-MM-dd") + d3 = QDate.fromString("2024-02-01", "yyyy-MM-dd") + w._create_new_tab(d1) + w._create_new_tab(d3) + w._create_new_tab(d2) + + # Also insert an "undated" plain QWidget at the front to force a move later + undated = QWidget() + w.tab_widget.insertTab(0, undated, "undated") + + moved = {"calls": []} + tb = w.tab_widget.tabBar() + + def spy_moveTab(frm, to): + moved["calls"].append((frm, to)) + # don't actually move; we just need the call for coverage + + monkeypatch.setattr(tb, "moveTab", spy_moveTab, raising=True) + + w._reorder_tabs_by_date() + assert moved["calls"] # both dated and undated moves should have occurred + + +def test_date_from_calendar_view_none(qtbot, app, tmp_db_cfg, monkeypatch): + """Covers early return when calendar view can't be found.""" + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + monkeypatch.setattr( + w.calendar, + "findChild", + lambda *a, **k: None, # pretend the internal view is missing + raising=False, + ) + assert w._date_from_calendar_pos(QPoint(1, 1)) is None + + +def test_date_from_calendar_no_first_or_last(qtbot, app, tmp_db_cfg, monkeypatch): + """ + Covers early returns when first_index or last_index cannot be found + """ + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + class FakeModel: + def rowCount(self): + return 1 + + def columnCount(self): + return 1 + + class Idx: + def data(self): + return None # never equals first/last-day + + def index(self, *_): + return self.Idx() + + class FakeView: + def model(self): + return FakeModel() + + class VP: + def mapToGlobal(self, p): + return p + + def mapFrom(self, *_): # calendar-local -> viewport + return QPoint(0, 0) + + def viewport(self): + return self.VP() + + def indexAt(self, *_): + # return an *instance* whose isValid() returns False + return type("I", (), {"isValid": lambda self: False})() + + monkeypatch.setattr( + w.calendar, + "findChild", + lambda *a, **k: FakeView(), + raising=False, + ) + # Early return when first day (1) can't be found + assert w._date_from_calendar_pos(QPoint(5, 5)) is None + + +@pytest.mark.gui +def test_save_editor_content_returns_if_no_conn(qtbot, app, tmp_db_cfg, monkeypatch): + """Covers DB not connected branch.""" + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + # window editor has a current_date; simulate a dropped connection + assert isinstance(w.db, DBManager) + w.db.conn = None + # no crash -> branch hit + w._save_editor_content(w.editor) + + +@pytest.mark.gui +def test_on_date_changed_stops_timer_and_saves_prev_when_dirty( + qtbot, app, tmp_db_cfg, monkeypatch +): + """ + Covers the exception-protected _save_timer.stop() and + the prev-date save path. + """ + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + # Make timer.stop() raise so the except path is covered + w._dirty = True + w.editor.current_date = QDate.fromString("2024-01-01", "yyyy-MM-dd") + # make timer.stop() raise so the except path is exercised + monkeypatch.setattr( + w._save_timer, + "stop", + lambda: (_ for _ in ()).throw(RuntimeError("boom")), + raising=True, + ) + + saved = {"iso": None} + + def stub_save_date(iso, explicit=False, note=None): + saved["iso"] = iso + + monkeypatch.setattr(w, "_save_date", stub_save_date, raising=False) + + w._on_date_changed() + assert saved["iso"] == "2024-01-01" + + +@pytest.mark.gui +def test_bind_toolbar_idempotent(qtbot, app, tmp_db_cfg, monkeypatch): + """Covers early return when toolbar is already bound.""" + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + w._bind_toolbar() + # Call again; line is covered if this no-ops + w._bind_toolbar() + + +@pytest.mark.gui +def test_on_insert_image_no_selection_returns(qtbot, app, tmp_db_cfg, monkeypatch): + """Covers the early return when user selects no files.""" + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + monkeypatch.setattr( + mwmod.QFileDialog, + "getOpenFileNames", + staticmethod(lambda *a, **k: ([], "")), + raising=True, + ) + + # Ensure we would notice if it tried to insert anything + monkeypatch.setattr( + w.editor, + "insert_image_from_path", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not insert")), + raising=True, + ) + w._on_insert_image() + + +def test_apply_idle_minutes_starts_when_unlocked(qtbot, app, tmp_db_cfg, monkeypatch): + """Covers set + start when minutes>0 and not locked.""" + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + w._locked = False + + hit = {"start": False} + monkeypatch.setattr( + w._idle_timer, + "start", + lambda *a, **k: hit.__setitem__("start", True), + raising=True, + ) + w._apply_idle_minutes(7) + assert hit["start"] + + +@pytest.mark.gui +def test_close_event_handles_settings_failures(qtbot, app, tmp_db_cfg, monkeypatch): + """ + Covers exception swallowing around settings writes & ensures close proceeds + """ + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + qtbot.addWidget(w) + + # A couple of tabs so _save_editor_content runs during close + w._create_new_tab(QDate.fromString("2024-01-01", "yyyy-MM-dd")) + + # Make settings.setValue raise for coverage + orig_set = w.settings.setValue + + def flaky_set(*a, **k): + if a[0] in ("main/geometry", "main/windowState"): + raise RuntimeError("boom") + return orig_set(*a, **k) + + monkeypatch.setattr(w.settings, "setValue", flaky_set, raising=True) + + # Should not crash + w.close() + + +def test_on_date_changed_ignored_when_context_menu_shown( + qtbot, app, tmp_db_cfg, monkeypatch +): + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + # Simulate flag set by context menu to ensure early return path + w._showing_context_menu = True + w._on_date_changed() # should simply return without raising + assert True # reached + + +def test_closeEvent_swallows_exceptions(qtbot, app, tmp_db_cfg, monkeypatch): + called = {"save": False, "close": False} + w = _make_main_window(tmp_db_cfg, app, monkeypatch) + + def bad_save(editor): + called["save"] = True + raise RuntimeError("boom") + + class DummyDB: + def __init__(self): + self.conn = object() # <-- make conn truthy so branch is taken + + def close(self): + called["close"] = True + raise RuntimeError("kaboom") + + class DummyTabs: + def count(self): + return 1 # <-- ensures the save loop runs + + def widget(self, i): + return object() # any non-None editor-like object + + # Patch the pieces the closeEvent checks + w.tab_widget = DummyTabs() + w.db = DummyDB() + monkeypatch.setattr(w, "_save_editor_content", bad_save, raising=True) + + # Fire the event + ev = QCloseEvent() + w.closeEvent(ev) + + assert called["save"] and called["close"] diff --git a/tests/test_markdown_editor.py b/tests/test_markdown_editor.py index 002ab63..7118dc6 100644 --- a/tests/test_markdown_editor.py +++ b/tests/test_markdown_editor.py @@ -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" diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index 33980df..28eead1 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -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() diff --git a/tests/test_strings.py b/tests/test_strings.py new file mode 100644 index 0000000..5aa79c9 --- /dev/null +++ b/tests/test_strings.py @@ -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 diff --git a/tests/test_theme.py b/tests/test_theme.py index 690f439..0370300 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -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)