import pytest import importlib.metadata from datetime import date, timedelta 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, QDialog from PySide6.QtGui import QMouseEvent, QKeyEvent, QTextCursor, QCloseEvent from unittest.mock import Mock, patch import bouquin.version_check as version_check def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db): s = get_settings() s.setValue("db/default_db", 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) s.setValue("ui/tags", True) s.setValue("ui/time_log", True) s.setValue("ui/reminders", True) s.setValue("ui/locale", "en") s.setValue("ui/font_size", 11) 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.key_entry.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/default_db", 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_unchecked_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 def test_open_docs_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 return 0 monkeypatch.setattr(mwmod, "QMessageBox", DummyMB, raising=True) # capture warnings w._open_docs() assert called["docs"] 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/default_db", 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"] 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/default_db", 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"] 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/default_db", 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) 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"] 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) 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 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 ) w._try_connect() # And we still showed the right error message 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 "" def db_path(self) -> Path | None: return "foo.db" 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" def db_path(self) -> Path | None: return "foo.db" 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", [ ("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_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 return 0 monkeypatch.setattr( mwmod, "QMessageBox", type("MB", (), {"warning": staticmethod(warn)}) ) w._open_docs() assert seen["docs"] def test_open_version(qtbot, tmp_db_cfg, app, monkeypatch): w = _make_main_window(tmp_db_cfg, app, monkeypatch) qtbot.addWidget(w) called = {"title": None, "text": None, "check_called": False} # Fake QMessageBox that mimics the bits VersionChecker.show_version_dialog uses class FakeMessageBox: # provide the enum attributes the code references Information = 0 ActionRole = 1 Close = 2 def __init__(self, parent=None): self._parent = parent self._icon = None self._title = "" self._text = "" self._buttons = [] self._clicked = None def setIcon(self, icon): self._icon = icon def setIconPixmap(self, icon): self._icon = icon def setWindowTitle(self, title): self._title = title called["title"] = title def setText(self, text): self._text = text called["text"] = text def addButton(self, *args, **kwargs): # We don't care about the label/role, we just need a distinct object btn = object() self._buttons.append(btn) return btn def exec(self): # Simulate user clicking the *Close* button, i.e. the second button if self._buttons: # show_version_dialog adds buttons in order: # 0 -> "Check for updates" # 1 -> Close self._clicked = self._buttons[-1] def clickedButton(self): return self._clicked # Patch the QMessageBox used *inside* version_check.py monkeypatch.setattr(version_check, "QMessageBox", FakeMessageBox) # Optional: track if check_for_updates would be called def fake_check_for_updates(self): called["check_called"] = True monkeypatch.setattr( version_check.VersionChecker, "check_for_updates", fake_check_for_updates ) # Call the entrypoint w._open_version() # Assertions: title and text got set correctly assert called["title"] is not None assert "version" in called["title"].lower() version = importlib.metadata.version("bouquin") assert version in called["text"] # And we simulated closing, so "Check for updates" should not have fired assert called["check_called"] is False # ---- 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 # 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) 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 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 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: ("", "Markdown (*.md)")), raising=False, ) w._export() # returns early at filename check 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") 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 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 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) 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" 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() 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"] 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"] # ============================================================================ # Tag Save Handler Tests # ============================================================================ def test_main_window_do_tag_save_with_editor(app, fresh_db, tmp_db_cfg, monkeypatch): """Test _do_tag_save when editor has current_date""" # Skip the key prompt monkeypatch.setattr( "bouquin.main_window.KeyPrompt", lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), ) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) window = MainWindow(themes) # Set a date on the editor date = QDate(2024, 1, 15) window.editor.current_date = date window.editor.from_markdown("Test content") # Call _do_tag_save window._do_tag_save() # Should have saved fresh_db.get_entry("2024-01-15") # May or may not have content depending on timing, but should not crash assert True def test_main_window_do_tag_save_no_editor(app, fresh_db, tmp_db_cfg, monkeypatch): """Test _do_tag_save when editor doesn't have current_date""" monkeypatch.setattr( "bouquin.main_window.KeyPrompt", lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), ) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) window = MainWindow(themes) # Remove current_date attribute if hasattr(window.editor, "current_date"): delattr(window.editor, "current_date") # Call _do_tag_save - should handle gracefully window._do_tag_save() assert True def test_main_window_on_tag_added_triggers_deferred_save( app, fresh_db, tmp_db_cfg, monkeypatch ): """Test that _on_tag_added defers the save""" monkeypatch.setattr( "bouquin.main_window.KeyPrompt", lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), ) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) window = MainWindow(themes) # Mock QTimer.singleShot with patch("PySide6.QtCore.QTimer.singleShot") as mock_timer: window._on_tag_added() # Should have called singleShot mock_timer.assert_called_once() args = mock_timer.call_args[0] assert args[0] == 0 # Delay of 0 assert callable(args[1]) # Callback function # ============================================================================ # Tag Activation Tests # ============================================================================ def test_main_window_on_tag_activated_with_date(app, fresh_db, tmp_db_cfg, monkeypatch): """Test _on_tag_activated when passed a date string""" monkeypatch.setattr( "bouquin.main_window.KeyPrompt", lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), ) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) window = MainWindow(themes) # Mock _load_selected_date window._load_selected_date = Mock() # Call with date format window._on_tag_activated("2024-01-15") # Should have called _load_selected_date window._load_selected_date.assert_called_once_with("2024-01-15") def test_main_window_on_tag_activated_with_tag_name( app, fresh_db, tmp_db_cfg, monkeypatch ): """Test _on_tag_activated when passed a tag name""" monkeypatch.setattr( "bouquin.main_window.KeyPrompt", lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), ) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) window = MainWindow(themes) # Mock the tag browser dialog (it's imported locally in the method) with patch("bouquin.tag_browser.TagBrowserDialog") as mock_dialog: mock_instance = Mock() mock_instance.openDateRequested = Mock() mock_instance.exec.return_value = QDialog.Accepted mock_dialog.return_value = mock_instance # Call with tag name window._on_tag_activated("worktag") # Should have opened dialog mock_dialog.assert_called_once() # Check focus_tag was passed call_kwargs = mock_dialog.call_args[1] assert call_kwargs.get("focus_tag") == "worktag" # ============================================================================ # Settings Path Change Tests # ============================================================================ def test_main_window_settings_path_change_success( app, fresh_db, tmp_db_cfg, tmp_path, monkeypatch ): """Test changing database path in settings""" monkeypatch.setattr( "bouquin.main_window.KeyPrompt", lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), ) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) window = MainWindow(themes) new_path = tmp_path / "new.db" # Mock the settings dialog with patch("bouquin.main_window.SettingsDialog") as mock_dialog: mock_instance = Mock() mock_instance.exec.return_value = QDialog.Accepted # Create a new config with different path new_cfg = Mock() new_cfg.path = str(new_path) new_cfg.key = tmp_db_cfg.key new_cfg.idle_minutes = 15 new_cfg.theme = "light" new_cfg.move_todos = True new_cfg.locale = "en" new_cfg.font_size = 11 mock_instance.config = new_cfg mock_dialog.return_value = mock_instance # Mock _prompt_for_key_until_valid to return True window._prompt_for_key_until_valid = Mock(return_value=True) # Also mock _load_selected_date and _refresh_calendar_marks since we don't have a real DB connection window._load_selected_date = Mock() window._refresh_calendar_marks = Mock() # Open settings window._open_settings() # Path should have changed assert window.cfg.path == str(new_path) def test_main_window_settings_path_change_failure( app, fresh_db, tmp_db_cfg, tmp_path, monkeypatch ): """Test failed database path change shows warning""" monkeypatch.setattr( "bouquin.main_window.KeyPrompt", lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), ) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) window = MainWindow(themes) new_path = tmp_path / "new.db" # Mock the settings dialog with patch("bouquin.main_window.SettingsDialog") as mock_dialog: mock_instance = Mock() mock_instance.exec.return_value = QDialog.Accepted new_cfg = Mock() new_cfg.path = str(new_path) new_cfg.key = tmp_db_cfg.key new_cfg.idle_minutes = 15 new_cfg.theme = "light" new_cfg.move_todos = True new_cfg.locale = "en" new_cfg.font_size = 11 mock_instance.config = new_cfg mock_dialog.return_value = mock_instance # Mock _prompt_for_key_until_valid to return False (failure) window._prompt_for_key_until_valid = Mock(return_value=False) # Mock QMessageBox.warning with patch.object(QMessageBox, "warning") as mock_warning: # Open settings window._open_settings() # Warning should have been shown mock_warning.assert_called_once() def test_main_window_settings_no_path_change(app, fresh_db, tmp_db_cfg, monkeypatch): """Test settings change without path change""" monkeypatch.setattr( "bouquin.main_window.KeyPrompt", lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), ) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) window = MainWindow(themes) old_path = window.cfg.path # Mock the settings dialog with patch("bouquin.main_window.SettingsDialog") as mock_dialog: mock_instance = Mock() mock_instance.exec.return_value = QDialog.Accepted # Create config with SAME path new_cfg = Mock() new_cfg.path = old_path new_cfg.key = tmp_db_cfg.key new_cfg.idle_minutes = 20 # Changed new_cfg.theme = "dark" # Changed new_cfg.move_todos = False # Changed new_cfg.locale = "fr" # Changed new_cfg.font_size = 12 # Changed mock_instance.config = new_cfg mock_dialog.return_value = mock_instance # Open settings window._open_settings() # Settings should be updated but path didn't change assert window.cfg.idle_minutes == 20 assert window.cfg.theme == "dark" assert window.cfg.path == old_path assert window.cfg.font_size == 12 def test_main_window_settings_cancelled(app, fresh_db, tmp_db_cfg, monkeypatch): """Test cancelling settings dialog""" monkeypatch.setattr( "bouquin.main_window.KeyPrompt", lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), ) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) window = MainWindow(themes) old_theme = window.cfg.theme # Mock the settings dialog to be rejected with patch("bouquin.main_window.SettingsDialog") as mock_dialog: mock_instance = Mock() mock_instance.exec.return_value = QDialog.Rejected mock_dialog.return_value = mock_instance # Open settings window._open_settings() # Settings should NOT change assert window.cfg.theme == old_theme # ============================================================================ # Update Tag Views Tests # ============================================================================ def test_main_window_update_tag_views_for_date(app, fresh_db, tmp_db_cfg, monkeypatch): """Test _update_tag_views_for_date""" monkeypatch.setattr( "bouquin.main_window.KeyPrompt", lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), ) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) window = MainWindow(themes) # Set tags for a date fresh_db.set_tags_for_page("2024-01-15", ["test"]) # Update tag views window._update_tag_views_for_date("2024-01-15") # Tags widget should have been updated assert window.tags._current_date == "2024-01-15" def test_main_window_update_tag_views_no_tags_widget( app, fresh_db, tmp_db_cfg, monkeypatch ): """Test _update_tag_views_for_date when tags widget doesn't exist""" monkeypatch.setattr( "bouquin.main_window.KeyPrompt", lambda *args, **kwargs: Mock(exec=lambda: True, key=lambda: tmp_db_cfg.key), ) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) window = MainWindow(themes) # Remove tags widget delattr(window, "tags") # Should handle gracefully window._update_tag_views_for_date("2024-01-15") assert True def test_main_window_with_tags_disabled(qtbot, app, tmp_path): """Test MainWindow with tags disabled in config - covers line 319""" db_path = tmp_path / "notebook.db" s = get_settings() s.setValue("db/default_db", str(db_path)) s.setValue("db/key", "test-key") s.setValue("ui/idle_minutes", 0) s.setValue("ui/theme", "light") s.setValue("ui/tags", False) # Disable tags s.setValue("ui/time_log", True) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) w = MainWindow(themes=themes) qtbot.addWidget(w) w.show() # Tags widget should be hidden assert w.tags.isHidden() def test_main_window_with_time_log_disabled(qtbot, app, tmp_path): """Test MainWindow with time_log disabled in config - covers line 321""" db_path = tmp_path / "notebook.db" s = get_settings() s.setValue("db/default_db", str(db_path)) s.setValue("db/key", "test-key") s.setValue("ui/idle_minutes", 0) s.setValue("ui/theme", "light") s.setValue("ui/tags", True) s.setValue("ui/time_log", False) # Disable time log themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) w = MainWindow(themes=themes) qtbot.addWidget(w) w.show() # Time log widget should be hidden assert w.time_log.isHidden() def test_export_csv_format(qtbot, app, tmp_path, monkeypatch): """Test exporting to CSV format - covers export path lines""" db_path = tmp_path / "notebook.db" s = get_settings() s.setValue("db/default_db", str(db_path)) s.setValue("db/key", "test-key") s.setValue("ui/idle_minutes", 0) s.setValue("ui/theme", "light") themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) w = MainWindow(themes=themes) qtbot.addWidget(w) w.show() # Add some data w.db.save_new_version("2024-01-01", "Test content", "test") # Mock file dialog to return CSV dest = tmp_path / "export_test.csv" monkeypatch.setattr( mwmod.QFileDialog, "getSaveFileName", staticmethod(lambda *a, **k: (str(dest), "CSV (*.csv)")), ) # Mock QMessageBox to auto-accept monkeypatch.setattr( mwmod.QMessageBox, "exec", lambda self: mwmod.QMessageBox.Yes, raising=False ) monkeypatch.setattr( mwmod.QMessageBox, "information", staticmethod(lambda *a, **k: 0), raising=False ) w._export() assert dest.exists() def test_settings_dialog_with_locale_change(qtbot, app, tmp_path, monkeypatch): """Test opening settings dialog and changing locale - covers settings dialog paths""" db_path = tmp_path / "notebook.db" s = get_settings() s.setValue("db/default_db", str(db_path)) s.setValue("db/key", "test-key") s.setValue("ui/idle_minutes", 0) s.setValue("ui/theme", "light") themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) w = MainWindow(themes=themes) qtbot.addWidget(w) w.show() # Mock the settings dialog to auto-accept from bouquin.settings_dialog import SettingsDialog SettingsDialog.exec def fake_exec(self): # Change locale before accepting idx = self.locale_combobox.findData("fr") if idx >= 0: self.locale_combobox.setCurrentIndex(idx) return mwmod.QDialog.Accepted monkeypatch.setattr(SettingsDialog, "exec", fake_exec) w._open_settings() qtbot.wait(50) def test_statistics_dialog_open(qtbot, app, tmp_path, monkeypatch): """Test opening statistics dialog - covers statistics dialog paths""" db_path = tmp_path / "notebook.db" s = get_settings() s.setValue("db/default_db", str(db_path)) s.setValue("db/key", "test-key") s.setValue("ui/idle_minutes", 0) s.setValue("ui/theme", "light") themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) w = MainWindow(themes=themes) qtbot.addWidget(w) w.show() # Add some data w.db.save_new_version("2024-01-01", "Test content", "test") from bouquin.statistics_dialog import StatisticsDialog StatisticsDialog.exec def fake_exec(self): # Just accept immediately return mwmod.QDialog.Accepted monkeypatch.setattr(StatisticsDialog, "exec", fake_exec) w._open_statistics() qtbot.wait(50) def test_bug_report_dialog_open(qtbot, app, tmp_path, monkeypatch): """Test opening bug report dialog""" db_path = tmp_path / "notebook.db" s = get_settings() s.setValue("db/default_db", str(db_path)) s.setValue("db/key", "test-key") s.setValue("ui/idle_minutes", 0) s.setValue("ui/theme", "light") themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) w = MainWindow(themes=themes) qtbot.addWidget(w) w.show() from bouquin.bug_report_dialog import BugReportDialog BugReportDialog.exec def fake_exec(self): return mwmod.QDialog.Rejected monkeypatch.setattr(BugReportDialog, "exec", fake_exec) w._open_bugs() qtbot.wait(50) def test_history_dialog_open_and_restore(qtbot, app, tmp_path, monkeypatch): """Test opening history dialog and restoring a version""" db_path = tmp_path / "notebook.db" s = get_settings() s.setValue("db/default_db", str(db_path)) s.setValue("db/key", "test-key") s.setValue("ui/idle_minutes", 0) s.setValue("ui/theme", "light") themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) w = MainWindow(themes=themes) qtbot.addWidget(w) w.show() # Add some data date_str = QDate.currentDate().toString("yyyy-MM-dd") w.db.save_new_version(date_str, "Version 1", "v1") w.db.save_new_version(date_str, "Version 2", "v2") from bouquin.history_dialog import HistoryDialog def fake_exec(self): # Simulate selecting first version and accepting if self.list.count() > 0: self.list.setCurrentRow(0) self._revert() return mwmod.QDialog.Accepted monkeypatch.setattr(HistoryDialog, "exec", fake_exec) w._open_history() qtbot.wait(50) def test_goto_today_button(qtbot, app, tmp_path): """Test going to today's date""" db_path = tmp_path / "notebook.db" s = get_settings() s.setValue("db/default_db", str(db_path)) s.setValue("db/key", "test-key") s.setValue("ui/idle_minutes", 0) s.setValue("ui/theme", "light") themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) w = MainWindow(themes=themes) qtbot.addWidget(w) w.show() # Move to a different date past_date = QDate.currentDate().addDays(-30) w.calendar.setSelectedDate(past_date) # Go back to today w._adjust_today() qtbot.wait(50) assert w.calendar.selectedDate() == QDate.currentDate() def test_adjust_font_size(qtbot, app, tmp_path): """Test adjusting font size""" db_path = tmp_path / "notebook.db" s = get_settings() s.setValue("db/default_db", str(db_path)) s.setValue("db/key", "test-key") s.setValue("ui/idle_minutes", 0) s.setValue("ui/theme", "light") s.setValue("ui/font_size", 12) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) w = MainWindow(themes=themes) qtbot.addWidget(w) w.show() initial_size = w.editor.font().pointSize() # Increase font size w._on_font_larger_requested() qtbot.wait(50) assert w.editor.font().pointSize() > initial_size # Decrease font size w._on_font_smaller_requested() qtbot.wait(50) def test_calendar_date_selection(qtbot, app, tmp_path): """Test selecting a date from calendar""" db_path = tmp_path / "notebook.db" s = get_settings() s.setValue("db/default_db", str(db_path)) s.setValue("db/key", "test-key") s.setValue("ui/idle_minutes", 0) s.setValue("ui/theme", "light") themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) w = MainWindow(themes=themes) qtbot.addWidget(w) w.show() # Select a specific date test_date = QDate(2024, 6, 15) w.calendar.setSelectedDate(test_date) qtbot.wait(50) # The window should load that date assert test_date.toString("yyyy-MM-dd") in str(w._current_date_iso()) def test_main_window_without_reminders(qtbot, app, tmp_db_cfg): """Test main window when reminders feature is disabled.""" s = get_settings() s.setValue("db/default_db", 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) s.setValue("ui/tags", True) s.setValue("ui/time_log", True) s.setValue("ui/reminders", False) # Disabled s.setValue("ui/locale", "en") s.setValue("ui/font_size", 11) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) window = MainWindow(themes=themes) qtbot.addWidget(window) window.show() # Verify reminders widget is hidden assert window.upcoming_reminders.isHidden() assert not window.toolBar.actAlarm.isVisible() def test_main_window_without_time_log(qtbot, app, tmp_db_cfg): """Test main window when time_log feature is disabled.""" s = get_settings() s.setValue("db/default_db", 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) s.setValue("ui/tags", True) s.setValue("ui/time_log", False) # Disabled s.setValue("ui/reminders", True) s.setValue("ui/locale", "en") s.setValue("ui/font_size", 11) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) window = MainWindow(themes=themes) qtbot.addWidget(window) window.show() # Verify time_log widget is hidden assert window.time_log.isHidden() assert not window.toolBar.actTimer.isVisible() def test_main_window_without_tags(qtbot, app, tmp_db_cfg): """Test main window when tags feature is disabled.""" s = get_settings() s.setValue("db/default_db", 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) s.setValue("ui/tags", False) # Disabled s.setValue("ui/time_log", True) s.setValue("ui/reminders", True) s.setValue("ui/locale", "en") s.setValue("ui/font_size", 11) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) window = MainWindow(themes=themes) qtbot.addWidget(window) window.show() # Verify tags widget is hidden assert window.tags.isHidden() def test_close_current_tab(qtbot, app, tmp_db_cfg, fresh_db): """Test closing the current tab via _close_current_tab.""" s = get_settings() s.setValue("db/default_db", 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) s.setValue("ui/tags", True) s.setValue("ui/time_log", True) s.setValue("ui/reminders", True) s.setValue("ui/locale", "en") s.setValue("ui/font_size", 11) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) window = MainWindow(themes=themes) qtbot.addWidget(window) window.show() # Open multiple tabs today = date.today().isoformat() tomorrow = (date.today() + timedelta(days=1)).isoformat() window._open_date_in_tab(QDate.fromString(today, "yyyy-MM-dd")) window._open_date_in_tab(QDate.fromString(tomorrow, "yyyy-MM-dd")) initial_count = window.tab_widget.count() assert initial_count >= 2 # Close current tab window._close_current_tab() # Verify tab was closed assert window.tab_widget.count() == initial_count - 1 def test_parse_today_alarms(qtbot, app, tmp_db_cfg, fresh_db): """Test parsing inline alarms from markdown (⏰ HH:MM format).""" from PySide6.QtCore import QTime s = get_settings() s.setValue("db/default_db", 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) s.setValue("ui/tags", True) s.setValue("ui/time_log", True) s.setValue("ui/reminders", True) s.setValue("ui/locale", "en") s.setValue("ui/font_size", 11) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) window = MainWindow(themes=themes) qtbot.addWidget(window) window.show() # Open today's date today_qdate = QDate.currentDate() window._open_date_in_tab(today_qdate) # Set content with a future alarm future_time = QTime.currentTime().addSecs(3600) # 1 hour from now alarm_text = f"Do something ⏰ {future_time.hour():02d}:{future_time.minute():02d}" # Set the editor's current_date attribute window.editor.current_date = today_qdate window.editor.setPlainText(alarm_text) # Clear any existing timers window._reminder_timers = [] # Trigger alarm parsing window._rebuild_reminders_for_today() # Verify timer was created (not DB reminder) assert len(window._reminder_timers) > 0 def test_parse_today_alarms_invalid_time(qtbot, app, tmp_db_cfg, fresh_db): """Test that invalid time formats are skipped.""" s = get_settings() s.setValue("db/default_db", 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) s.setValue("ui/tags", True) s.setValue("ui/time_log", True) s.setValue("ui/reminders", True) s.setValue("ui/locale", "en") s.setValue("ui/font_size", 11) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) window = MainWindow(themes=themes) qtbot.addWidget(window) window.show() # Open today's date today_qdate = QDate.currentDate() window._open_date_in_tab(today_qdate) # Set content with invalid time alarm_text = "Do something ⏰ 25:99" # Invalid time window.editor.current_date = today_qdate window.editor.setPlainText(alarm_text) # Clear any existing timers window._reminder_timers = [] # Trigger alarm parsing - should not crash window._rebuild_reminders_for_today() # No timer should be created for invalid time assert len(window._reminder_timers) == 0 def test_parse_today_alarms_past_time(qtbot, app, tmp_db_cfg, fresh_db): """Test that past alarms are skipped.""" from PySide6.QtCore import QTime s = get_settings() s.setValue("db/default_db", 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) s.setValue("ui/tags", True) s.setValue("ui/time_log", True) s.setValue("ui/reminders", True) s.setValue("ui/locale", "en") s.setValue("ui/font_size", 11) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) window = MainWindow(themes=themes) qtbot.addWidget(window) window.show() # Open today's date today_qdate = QDate.currentDate() window._open_date_in_tab(today_qdate) # Set content with past time past_time = QTime.currentTime().addSecs(-3600) # 1 hour ago alarm_text = f"Do something ⏰ {past_time.hour():02d}:{past_time.minute():02d}" window.editor.current_date = today_qdate window.editor.setPlainText(alarm_text) # Clear any existing timers window._reminder_timers = [] # Trigger alarm parsing window._rebuild_reminders_for_today() # Past alarms should not create timers assert len(window._reminder_timers) == 0 def test_parse_today_alarms_no_text(qtbot, app, tmp_db_cfg, fresh_db): """Test alarm with no text before emoji uses fallback.""" from PySide6.QtCore import QTime s = get_settings() s.setValue("db/default_db", 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) s.setValue("ui/tags", True) s.setValue("ui/time_log", True) s.setValue("ui/reminders", True) s.setValue("ui/locale", "en") s.setValue("ui/font_size", 11) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) window = MainWindow(themes=themes) qtbot.addWidget(window) window.show() # Open today's date today_qdate = QDate.currentDate() window._open_date_in_tab(today_qdate) # Set content with alarm but no text future_time = QTime.currentTime().addSecs(3600) alarm_text = f"⏰ {future_time.hour():02d}:{future_time.minute():02d}" window.editor.current_date = today_qdate window.editor.setPlainText(alarm_text) # Clear any existing timers window._reminder_timers = [] # Trigger alarm parsing window._rebuild_reminders_for_today() # Timer should be created even without text assert len(window._reminder_timers) > 0 def test_open_history_with_editor(qtbot, app, tmp_db_cfg, fresh_db): """Test opening history when editor has content.""" from unittest.mock import patch s = get_settings() s.setValue("db/default_db", 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) s.setValue("ui/tags", True) s.setValue("ui/time_log", True) s.setValue("ui/reminders", True) s.setValue("ui/locale", "en") s.setValue("ui/font_size", 11) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) window = MainWindow(themes=themes) qtbot.addWidget(window) window.show() # Create some history today = date.today().isoformat() fresh_db.save_new_version(today, "v1", "note1") fresh_db.save_new_version(today, "v2", "note2") # Open today's date window._open_date_in_tab(QDate.fromString(today, "yyyy-MM-dd")) # Open history with patch("bouquin.history_dialog.HistoryDialog.exec") as mock_exec: mock_exec.return_value = False # User cancels window._open_history() # HistoryDialog should have been created and shown mock_exec.assert_called_once()