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 @pytest.mark.gui def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db): s = get_settings() s.setValue("db/path", str(tmp_db_cfg.path)) s.setValue("db/key", tmp_db_cfg.key) s.setValue("ui/idle_minutes", 0) s.setValue("ui/theme", "light") s.setValue("ui/move_todos", True) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) w = MainWindow(themes=themes) qtbot.addWidget(w) w.show() date = QDate.currentDate().toString("yyyy-MM-dd") w._load_selected_date(date) w.editor.from_markdown("hello **world**") w._on_text_changed() qtbot.wait(5500) # let the 5s autosave QTimer fire assert "world" in fresh_db.get_entry(date) w.search.search.setText("world") qtbot.wait(50) assert not w.search.results.isHidden() w._sync_toolbar() w._adjust_day(-1) # previous day w._adjust_day(+1) # next day # Auto-accept the unlock KeyPrompt with the correct key def _auto_accept_keyprompt(): for wdg in QApplication.topLevelWidgets(): if isinstance(wdg, KeyPrompt): wdg.edit.setText(tmp_db_cfg.key) wdg.accept() w._enter_lock() QTimer.singleShot(0, _auto_accept_keyprompt) w._on_unlock_clicked() qtbot.wait(50) # let the nested event loop process the acceptance def test_load_yesterday_todos_moves_items(qtbot, app, tmp_db_cfg, fresh_db): s = get_settings() s.setValue("db/path", str(tmp_db_cfg.path)) s.setValue("db/key", tmp_db_cfg.key) s.setValue("ui/move_todos", True) s.setValue("ui/theme", "light") y = QDate.currentDate().addDays(-1).toString("yyyy-MM-dd") fresh_db.save_new_version(y, "- [ ] carry me\n- [x] done", "seed") themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) w = MainWindow(themes=themes) qtbot.addWidget(w) w.show() w._load_yesterday_todos() assert "carry me" in w.editor.to_markdown() y_txt = fresh_db.get_entry(y) assert "carry me" not in y_txt or "- [ ]" not in y_txt @pytest.mark.gui def test_open_docs_and_bugs_warning(qtbot, app, fresh_db, tmp_path, monkeypatch): themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) w = MainWindow(themes=themes) qtbot.addWidget(w) w.show() # Force QDesktopServices.openUrl to fail so the warning path executes called = {"docs": False, "bugs": False} def fake_open(url): # return False to force warning path return False mwmod.QDesktopServices.openUrl = fake_open # minimal monkeypatch class DummyMB: @staticmethod def warning(parent, title, text, *rest): t = str(text) if "wiki" in t: called["docs"] = True if "forms/mig5/contact" in t or "contact" in t: called["bugs"] = True return 0 monkeypatch.setattr(mwmod, "QMessageBox", DummyMB, raising=True) # capture warnings # Trigger both actions w._open_docs() w._open_bugs() assert called["docs"] and called["bugs"] @pytest.mark.gui def test_export_success_and_error(qtbot, app, fresh_db, tmp_path, monkeypatch): # Seed some content fresh_db.save_new_version("2001-01-01", "alpha", "n1") fresh_db.save_new_version("2001-01-02", "beta", "n2") themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) from bouquin.settings import get_settings s = get_settings() s.setValue("db/path", str(fresh_db.cfg.path)) s.setValue("db/key", fresh_db.cfg.key) s.setValue("ui/idle_minutes", 0) s.setValue("ui/theme", "light") w = MainWindow(themes=themes) qtbot.addWidget(w) w.show() # 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 monkeypatch.setattr( mwmod.QMessageBox, "exec", lambda self: mwmod.QMessageBox.Yes, raising=False ) monkeypatch.setattr( mwmod.QMessageBox, "information", staticmethod(lambda *a, **k: 0), raising=False ) # Critical should never trigger in the success path monkeypatch.setattr( mwmod.QMessageBox, "critical", staticmethod( lambda *a, **k: (_ for _ in ()).throw(AssertionError("Unexpected critical")) ), raising=False, ) w._export() assert dest1.with_suffix(".md").exists() # Now force an exception during export to hit error branch (patch the window's DB) def boom(): raise RuntimeError("explode") monkeypatch.setattr(w.db, "get_all_entries", boom, raising=False) # Different filename to avoid overwriting dest2 = tmp_path / "export_two" monkeypatch.setattr( mwmod.QFileDialog, "getSaveFileName", staticmethod(lambda *a, **k: (str(dest2), "CSV (*.csv)")), ) errs = {"hit": False} 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: errs.__setitem__("hit", True) or 0), raising=False, ) w._export() assert errs["hit"] @pytest.mark.gui def test_backup_path(qtbot, app, fresh_db, tmp_path, monkeypatch): themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) # wire DB settings the window reads from bouquin.settings import get_settings s = get_settings() s.setValue("db/path", str(fresh_db.cfg.path)) s.setValue("db/key", fresh_db.cfg.key) s.setValue("ui/idle_minutes", 0) s.setValue("ui/theme", "light") w = MainWindow(themes=themes) qtbot.addWidget(w) w.show() # Pretend user picked a filename with no suffix -> .db should be appended dest = tmp_path / "backupfile" monkeypatch.setattr( mwmod.QFileDialog, "getSaveFileName", staticmethod(lambda *a, **k: (str(dest), "SQLCipher (*.db)")), raising=False, ) # Avoid any modal dialogs and record the success message hit = {"info": False, "text": None} monkeypatch.setattr( mwmod.QMessageBox, "information", staticmethod( lambda parent, title, text, *a, **k: ( hit.__setitem__("info", True), hit.__setitem__("text", str(text)), 0, )[-1] ), raising=False, ) monkeypatch.setattr( mwmod.QMessageBox, "critical", staticmethod(lambda *a, **k: 0), raising=False ) # Stub the *export* itself to be instant and non-blocking called = {"path": None} monkeypatch.setattr( w.db, "export_sqlcipher", lambda p: called.__setitem__("path", p), raising=False ) w._backup() # Assertions: suffix added, export invoked, success toast shown assert called["path"] == str(dest.with_suffix(".db")) assert hit["info"] assert str(dest.with_suffix(".db")) in hit["text"] @pytest.mark.gui def test_close_tab_edges_and_autosave(qtbot, app, fresh_db, monkeypatch): themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) from bouquin.settings import get_settings s = get_settings() s.setValue("db/path", str(fresh_db.cfg.path)) s.setValue("db/key", fresh_db.cfg.key) s.setValue("ui/idle_minutes", 0) s.setValue("ui/theme", "light") w = MainWindow(themes=themes) qtbot.addWidget(w) w.show() # Create exactly one extra tab (there is already one from __init__) d1 = QDate(2020, 1, 1) w._open_date_in_tab(d1) assert w.tab_widget.count() == 2 # Close one tab: should call _save_editor_content on its editor saved = {"called": False} def fake_save_editor(editor): saved["called"] = True monkeypatch.setattr(w, "_save_editor_content", fake_save_editor, raising=True) w._close_tab(0) assert saved["called"] # Now only one tab remains; closing should no-op count_before = w.tab_widget.count() w._close_tab(0) assert w.tab_widget.count() == count_before monkeypatch.delattr(w, "_save_editor_content", raising=False) @pytest.mark.gui def test_restore_window_position_variants(qtbot, app, fresh_db, monkeypatch): themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) w = MainWindow(themes=themes) qtbot.addWidget(w) w.show() # Case A: no geometry stored -> should call _move_to_cursor_screen_center moved = {"hit": False} monkeypatch.setattr( w, "_move_to_cursor_screen_center", lambda: moved.__setitem__("hit", True), raising=True, ) # clear any stored geometry w.settings.remove("main/geometry") w.settings.remove("main/windowState") w.settings.remove("main/maximized") w._restore_window_position() assert moved["hit"] # Case B: geometry present but off-screen -> fallback to move_to_cursor moved["hit"] = False # Save a valid geometry then lie that it's offscreen geom = w.saveGeometry() w.settings.setValue("main/geometry", geom) w.settings.setValue("main/windowState", w.saveState()) w.settings.setValue("main/maximized", False) monkeypatch.setattr(w, "_rect_on_any_screen", lambda r: False, raising=True) w._restore_window_position() assert moved["hit"] # Case C: was_max True triggers showMaximized via QTimer.singleShot called = {"max": False} monkeypatch.setattr( w, "showMaximized", lambda: called.__setitem__("max", True), raising=True ) monkeypatch.setattr( mwmod.QTimer, "singleShot", staticmethod(lambda _ms, f: f()), raising=False ) w.settings.setValue("main/maximized", True) w._restore_window_position() assert called["max"] @pytest.mark.gui def test_calendar_pos_mapping_and_context_menu(qtbot, app, fresh_db, monkeypatch): # Seed DB so refresh marks does something fresh_db.save_new_version("2021-08-15", "note", "") themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) w = MainWindow(themes=themes) qtbot.addWidget(w) w.show() # Show a month with leading days qd = QDate(2021, 8, 15) w.calendar.setSelectedDate(qd) # Grab the internal table view and pick a couple of indices view = w.calendar.findChild(QTableView, "qt_calendar_calendarview") model = view.model() # Find the first index belonging to current month (day == 1) first_idx = None for r in range(model.rowCount()): for c in range(model.columnCount()): if model.index(r, c).data() == 1: first_idx = model.index(r, c) break if first_idx: break assert first_idx is not None # A cell before 'first_idx' should map to previous month col0 = 0 if first_idx.column() > 0 else 1 idx_prev = model.index(first_idx.row(), col0) vp_pos = view.visualRect(idx_prev).center() global_pos = view.viewport().mapToGlobal(vp_pos) cal_pos = w.calendar.mapFromGlobal(global_pos) date_prev = w._date_from_calendar_pos(cal_pos) assert isinstance(date_prev, QDate) and date_prev.isValid() # A cell after the last day should map to next month last_day = QDate(qd.year(), qd.month(), 1).addMonths(1).addDays(-1).day() last_idx = None for r in range(model.rowCount() - 1, -1, -1): for c in range(model.columnCount() - 1, -1, -1): if model.index(r, c).data() == last_day: last_idx = model.index(r, c) break if last_idx: break assert last_idx is not None c_next = min(model.columnCount() - 1, last_idx.column() + 1) idx_next = model.index(last_idx.row(), c_next) vp_pos2 = view.visualRect(idx_next).center() global_pos2 = view.viewport().mapToGlobal(vp_pos2) cal_pos2 = w.calendar.mapFromGlobal(global_pos2) date_next = w._date_from_calendar_pos(cal_pos2) assert isinstance(date_next, QDate) and date_next.isValid() # Context menu path: return the "Open in New Tab" action class DummyMenu: def __init__(self, parent=None): self._action = object() def addAction(self, text): return self._action def exec_(self, *args, **kwargs): return self._action monkeypatch.setattr(mwmod, "QMenu", DummyMenu, raising=True) w._show_calendar_context_menu(cal_pos) @pytest.mark.gui def test_event_filter_keypress_starts_idle_timer(qtbot, app): themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) w = MainWindow(themes=themes) qtbot.addWidget(w) 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"]