diff --git a/bouquin/find_bar.py b/bouquin/find_bar.py index ae0206b..5332beb 100644 --- a/bouquin/find_bar.py +++ b/bouquin/find_bar.py @@ -122,6 +122,8 @@ class FindBar(QWidget): return flags def find_next(self): + if not self.editor: + return txt = self.edit.text() if not txt: return @@ -147,6 +149,8 @@ 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 8429f5e..5e2e9ab 100644 --- a/bouquin/history_dialog.py +++ b/bouquin/history_dialog.py @@ -163,6 +163,8 @@ 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 49fde4f..fe55464 100644 --- a/bouquin/locales/fr.json +++ b/bouquin/locales/fr.json @@ -111,3 +111,4 @@ "toolbar_heading": "Titre", "toolbar_toggle_checkboxes": "Cocher/Décocher les cases" } + diff --git a/bouquin/lock_overlay.py b/bouquin/lock_overlay.py index 61a52e5..f40e7f5 100644 --- a/bouquin/lock_overlay.py +++ b/bouquin/lock_overlay.py @@ -18,6 +18,8 @@ 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 abaf495..d1987d7 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -675,9 +675,6 @@ 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") @@ -776,13 +773,9 @@ 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() if hasattr(self, "editor") else "" + text = self.editor.to_markdown() self.db.save_new_version(date_iso, text, note) self._dirty = False self._refresh_calendar_marks() @@ -874,14 +867,13 @@ class MainWindow(QMainWindow): fmt.setFontWeight(QFont.Weight.Normal) # remove bold only self.calendar.setDateTextFormat(d, fmt) self._marked_dates = set() - 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) + 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 ------------------- # @@ -1256,39 +1248,17 @@ class MainWindow(QMainWindow): # ----------------- Close handlers ----------------- # def closeEvent(self, event): - # 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 + # Save window position + self.settings.setValue("main/geometry", self.saveGeometry()) + self.settings.setValue("main/windowState", self.saveState()) + self.settings.setValue("main/maximized", self.isMaximized()) + # 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 e68f03c..0c5e7c0 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,30 +163,25 @@ class MarkdownHighlighter(QSyntaxHighlighter): self.setFormat(marker_len, len(text) - marker_len, heading_fmt) return - # 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 - + # Bold+Italic: ***text*** or ___text___ + # Do these first and remember their spans so later passes don't override them. + occupied = [] 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 ("*", "_"): @@ -218,10 +213,6 @@ 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 56a1ecd..f3beccf 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"), str(e)) + QMessageBox.critical(self, strings._("error"), 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"), str(e)) + QMessageBox.critical(self, strings._("error"), e) @property def config(self) -> DBConfig: diff --git a/bouquin/strings.py b/bouquin/strings.py index 6a321b8..306183a 100644 --- a/bouquin/strings.py +++ b/bouquin/strings.py @@ -8,7 +8,7 @@ strings = {} translations = {} -def load_strings(current_locale: str) -> None: +def load_strings(current_locale: str | None = None) -> None: global strings, translations translations = {} @@ -18,6 +18,15 @@ def load_strings(current_locale: str) -> None: data = (root / f"{loc}.json").read_text(encoding="utf-8") translations[loc] = json.loads(data) + # Load in the system's locale if not passed in somehow from settings + if not current_locale: + try: + from PySide6.QtCore import QLocale + + current_locale = QLocale.system().name().split("_")[0] + except Exception: + current_locale = _DEFAULT + if current_locale not in translations: current_locale = _DEFAULT diff --git a/tests/test_db.py b/tests/test_db.py index fb3445f..42e50f3 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -166,42 +166,3 @@ 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 bccfd39..47fab42 100644 --- a/tests/test_find_bar.py +++ b/tests/test_find_bar.py @@ -1,7 +1,6 @@ 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 @@ -134,40 +133,3 @@ 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 b1cef62..8b58f7a 100644 --- a/tests/test_history_dialog.py +++ b/tests/test_history_dialog.py @@ -1,4 +1,4 @@ -from PySide6.QtWidgets import QWidget, QMessageBox, QApplication +from PySide6.QtWidgets import QWidget, QMessageBox from PySide6.QtCore import Qt, QTimer from bouquin.history_dialog import HistoryDialog @@ -83,87 +83,3 @@ 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 b266ffc..cad9139 100644 --- a/tests/test_main_window.py +++ b/tests/test_main_window.py @@ -1,15 +1,11 @@ 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 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 +from PySide6.QtCore import QEvent, QDate, QTimer +from PySide6.QtWidgets import QTableView, QApplication @pytest.mark.gui @@ -131,20 +127,25 @@ 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)")), - ) - # Use real QMessageBox class; just force decisions and silence popups + 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 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: 0), raising=False + mwmod.QMessageBox, + "information", + staticmethod(lambda *a, **k: info_log.__setitem__("ok", True) or 0), + raising=False, ) - # Critical should never trigger in the success path monkeypatch.setattr( mwmod.QMessageBox, "critical", @@ -153,9 +154,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(): @@ -165,13 +166,14 @@ def test_export_success_and_error(qtbot, app, fresh_db, tmp_path, monkeypatch): # Different filename to avoid overwriting dest2 = tmp_path / "export_two" - monkeypatch.setattr( - mwmod.QFileDialog, - "getSaveFileName", - staticmethod(lambda *a, **k: (str(dest2), "CSV (*.csv)")), - ) + + def fake_save2(*a, **k): + return str(dest2), "CSV (*.csv)" + + monkeypatch.setattr(mwmod.QFileDialog, "getSaveFileName", staticmethod(fake_save2)) 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 ) @@ -408,1057 +410,3 @@ 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 7118dc6..002ab63 100644 --- a/tests/test_markdown_editor.py +++ b/tests/test_markdown_editor.py @@ -1,19 +1,8 @@ import pytest from PySide6.QtCore import Qt, QPoint -from PySide6.QtGui import ( - QImage, - QColor, - QKeyEvent, - QTextCursor, - QTextDocument, - QFont, - QTextCharFormat, -) -from PySide6.QtWidgets import QTextEdit - +from PySide6.QtGui import QImage, QColor, QKeyEvent, QTextCursor from bouquin.markdown_editor import MarkdownEditor -from bouquin.markdown_highlighter import MarkdownHighlighter from bouquin.theme import ThemeManager, ThemeConfig, Theme @@ -43,15 +32,6 @@ 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) @@ -89,8 +69,8 @@ def test_insert_image_from_path(editor, tmp_path): editor.insert_image_from_path(img) md = editor.to_markdown() - # 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 + # Images are saved as base64 data URIs in markdown + assert "data:image/image/png;base64" in md @pytest.mark.gui @@ -103,10 +83,13 @@ 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) @@ -181,7 +164,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 inserts fenced code block + editor.apply_code() # action qtbot.wait(0) t = text(editor) @@ -287,271 +270,3 @@ 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 28eead1..33980df 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -1,13 +1,12 @@ 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, QDialog +from PySide6.QtWidgets import QApplication, QMessageBox, QWidget @pytest.mark.gui @@ -226,207 +225,3 @@ 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 deleted file mode 100644 index 5aa79c9..0000000 --- a/tests/test_strings.py +++ /dev/null @@ -1,7 +0,0 @@ -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 0370300..690f439 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -1,6 +1,5 @@ import pytest from PySide6.QtGui import QPalette -from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget from bouquin.theme import Theme, ThemeConfig, ThemeManager @@ -20,35 +19,3 @@ 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)