diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py index 7f51d8c..1900f40 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,56 +1,77 @@ import os +import sys +from pathlib import Path import pytest +from PySide6.QtCore import QStandardPaths +from tests.qt_helpers import AutoResponder -# Run Qt without a visible display (CI-safe) -os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +# Force Qt *non-native* file dialog so we can type a filename programmatically. +os.environ.setdefault("QT_FILE_DIALOG_ALWAYS_USE_NATIVE", "0") +# For CI headless runs, set QT_QPA_PLATFORM=offscreen in the CI env +# os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") -@pytest.fixture -def fake_key_prompt_cls(): - """A KeyPrompt stand-in that immediately returns Accepted with a fixed key.""" - from PySide6.QtWidgets import QDialog - - class FakeKeyPrompt: - accepted_count = 0 - - def __init__(self, *a, **k): - self._key = "sekret" - - def exec(self): - FakeKeyPrompt.accepted_count += 1 - return QDialog.Accepted - - def key(self): - return self._key - - return FakeKeyPrompt +# Make project importable +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) -@pytest.fixture -def fake_db_cls(): - """In-memory DB fake that mimics the subset of DBManager used by the UI.""" - class FakeDB: - def __init__(self, cfg): - self.cfg = cfg - self.data = {} - self.connected_key = None - self.closed = False +@pytest.fixture(scope="session", autouse=True) +def enable_qstandardpaths_test_mode(): + QStandardPaths.setTestModeEnabled(True) - def connect(self): - # record the key that UI supplied - self.connected_key = self.cfg.key - return True - def get_entry(self, date_iso: str) -> str: - return self.data.get(date_iso, "") +@pytest.fixture() +def temp_home(tmp_path, monkeypatch): + home = tmp_path / "home" + (home / "Documents").mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("HOME", str(home)) + return home - def upsert_entry(self, date_iso: str, content: str) -> None: - self.data[date_iso] = content - def dates_with_content(self) -> list[str]: - return [d for d, t in self.data.items() if t.strip()] +@pytest.fixture() +def clean_settings(): + try: + from bouquin.settings import APP_NAME, APP_ORG + from PySide6.QtCore import QSettings + except Exception: + yield + return + s = QSettings(APP_ORG, APP_NAME) + s.clear() + yield + s.clear() - def close(self) -> None: - self.closed = True - return FakeDB +@pytest.fixture(autouse=True) +def auto_accept_common_dialogs(qtbot): + ar = AutoResponder() + ar.start() + try: + yield + finally: + ar.stop() + + +@pytest.fixture() +def open_window(qtbot, temp_home, clean_settings): + """Launch the app and immediately satisfy first-run/unlock key prompts.""" + from bouquin.main_window import MainWindow + + win = MainWindow() + qtbot.addWidget(win) + win.show() + qtbot.waitExposed(win) + + # Immediately satisfy first-run 'Set key' or 'Unlock' prompts if already visible + AutoResponder().prehandle_key_prompts_if_present() + return win + + +@pytest.fixture() +def today_iso(): + from datetime import date + + d = date.today() + return f"{d.year:04d}-{d.month:02d}-{d.day:02d}" diff --git a/tests/qt_helpers.py b/tests/qt_helpers.py new file mode 100644 index 0000000..1b9b9a3 --- /dev/null +++ b/tests/qt_helpers.py @@ -0,0 +1,287 @@ +import time +from pathlib import Path + +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QAction +from PySide6.QtTest import QTest +from PySide6.QtWidgets import ( + QApplication, + QWidget, + QDialog, + QFileDialog, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QAbstractButton, + QListWidget, +) + +# ---------- robust widget finders ---------- + + +def _visible_widgets(): + for w in QApplication.topLevelWidgets(): + if w.isVisible(): + yield w + for c in w.findChildren(QWidget): + if c.isWindow() and c.isVisible(): + yield c + + +def wait_for_widget(cls=None, predicate=lambda w: True, timeout_ms: int = 15000): + deadline = time.time() + timeout_ms / 1000.0 + while time.time() < deadline: + for w in _visible_widgets(): + if (cls is None or isinstance(w, cls)) and predicate(w): + return w + QTest.qWait(25) + raise TimeoutError(f"Timed out waiting for {cls} matching predicate") + + +# ---------- generic ui helpers ---------- + + +def click_button_by_text(container: QWidget, contains: str) -> bool: + """Click any QAbstractButton whose label contains the substring.""" + target = contains.lower() + for btn in container.findChildren(QAbstractButton): + text = (btn.text() or "").lower() + if target in text: + from PySide6.QtTest import QTest + + if not btn.isEnabled(): + QTest.qWait(50) # give UI a tick to enable + QTest.mouseClick(btn, Qt.LeftButton) + return True + return False + + +def _first_line_edit(dlg: QDialog) -> QLineEdit | None: + edits = dlg.findChildren(QLineEdit) + return edits[0] if edits else None + + +def fill_first_line_edit_and_accept(dlg: QDialog, text: str | None): + le = _first_line_edit(dlg) + assert le is not None, "Expected a QLineEdit in the dialog" + if text is not None: + le.clear() + QTest.keyClicks(le, text) + # Prefer 'OK'; fallback to Return + ok = None + for btn in dlg.findChildren(QPushButton): + t = btn.text().lower().lstrip("&") + if t == "ok" or btn.isDefault(): + ok = btn + break + if ok: + QTest.mouseClick(ok, Qt.LeftButton) + else: + QTest.keyClick(le, Qt.Key_Return) + + +def accept_all_message_boxes(limit: int = 5) -> bool: + """ + Accept every visible QMessageBox, preferring Yes/Accept/Ok. + Returns True if at least one box was accepted. + """ + accepted_any = False + for _ in range(limit): + accepted_this_round = False + for w in _visible_widgets(): + if isinstance(w, QMessageBox) and w.isVisible(): + # Prefer "Yes", then any Accept/Apply role, then Ok, then default/first. + btn = ( + w.button(QMessageBox.Yes) + or next( + ( + b + for b in w.buttons() + if w.buttonRole(b) + in ( + QMessageBox.YesRole, + QMessageBox.AcceptRole, + QMessageBox.ApplyRole, + ) + ), + None, + ) + or w.button(QMessageBox.Ok) + or w.defaultButton() + or (w.buttons()[0] if w.buttons() else None) + ) + if btn: + QTest.mouseClick(btn, Qt.LeftButton) + accepted_this_round = True + accepted_any = True + if not accepted_this_round: + break + QTest.qWait(30) # give the next box a tick to appear + return accepted_any + + +def trigger_menu_action(win, text_contains: str) -> QAction: + for act in win.findChildren(QAction): + if text_contains in act.text(): + act.trigger() + return act + raise AssertionError(f"Action containing '{text_contains}' not found") + + +def find_line_edit_by_placeholder(container: QWidget, needle: str) -> QLineEdit | None: + n = needle.lower() + for le in container.findChildren(QLineEdit): + if n in (le.placeholderText() or "").lower(): + return le + return None + + +class AutoResponder: + def __init__(self): + self._seen: set[int] = set() + self._timer = QTimer() + self._timer.setInterval(50) + self._timer.timeout.connect(self._tick) + + def start(self): + self._timer.start() + + def stop(self): + self._timer.stop() + + def prehandle_key_prompts_if_present(self): + for w in _visible_widgets(): + if isinstance(w, QDialog) and ( + _looks_like_set_key_dialog(w) or _looks_like_unlock_dialog(w) + ): + fill_first_line_edit_and_accept(w, "ci-secret-key") + + def _tick(self): + if accept_all_message_boxes(limit=3): + return + + for w in _visible_widgets(): + if not isinstance(w, QDialog) or not w.isVisible(): + continue + + wid = id(w) + # Handle first-run / unlock / save-name prompts (your existing branches) + if _looks_like_set_key_dialog(w) or _looks_like_unlock_dialog(w): + fill_first_line_edit_and_accept(w, "ci-secret-key") + self._seen.add(wid) + continue + + if _looks_like_save_version_dialog(w): + fill_first_line_edit_and_accept(w, None) + self._seen.add(wid) + continue + + if _is_history_dialog(w): + # Don't mark as seen until we've actually clicked the button. + if _click_revert_in_history(w): + accept_all_message_boxes(limit=5) + self._seen.add(wid) + continue + + +# ---------- dialog classifiers ---------- + + +def _looks_like_set_key_dialog(dlg: QDialog) -> bool: + labels = " ".join(lbl.text() for lbl in dlg.findChildren(QLabel)).lower() + title = (dlg.windowTitle() or "").lower() + has_line = bool(dlg.findChildren(QLineEdit)) + return has_line and ( + "set an encryption key" in title + or "create a strong passphrase" in labels + or "encrypts your data" in labels + ) + + +def _looks_like_unlock_dialog(dlg: QDialog) -> bool: + labels = " ".join(lbl.text() for lbl in dlg.findChildren(QLabel)).lower() + title = (dlg.windowTitle() or "").lower() + has_line = bool(dlg.findChildren(QLineEdit)) + return has_line and ("unlock" in labels or "unlock" in title) and "key" in labels + + +# ---------- version prompt ---------- +def _looks_like_save_version_dialog(dlg: QDialog) -> bool: + labels = " ".join(lbl.text() for lbl in dlg.findChildren(QLabel)).lower() + title = (dlg.windowTitle() or "").lower() + has_line = bool(dlg.findChildren(QLineEdit)) + return has_line and ( + "enter a name" in labels or "name for this version" in labels or "save" in title + ) + + +# ---------- QFileDialog driver ---------- + + +def drive_qfiledialog_save(path: Path, name_filter: str | None = None): + dlg = wait_for_widget(QFileDialog, timeout_ms=20000) + if name_filter: + try: + dlg.selectNameFilter(name_filter) + except Exception: + pass + + # Prefer typing in the filename edit so Save enables on all styles + filename_edit = None + for le in dlg.findChildren(QLineEdit): + if le.echoMode() == QLineEdit.Normal: + filename_edit = le + break + + if filename_edit is not None: + filename_edit.clear() + QTest.keyClicks(filename_edit, str(path)) + # Return usually triggers Save in non-native dialogs + QTest.keyClick(filename_edit, Qt.Key_Return) + else: + dlg.selectFile(str(path)) + QTimer.singleShot(0, dlg.accept) + + # Some themes still need an explicit Save click + _ = click_button_by_text(dlg, "save") + + +def _is_history_dialog(dlg: QDialog) -> bool: + if not isinstance(dlg, QDialog) or not dlg.isVisible(): + return False + title = (dlg.windowTitle() or "").lower() + if "history" in title: + return True + return bool(dlg.findChildren(QListWidget)) + + +def _click_revert_in_history(dlg: QDialog) -> bool: + """ + Returns True if we successfully clicked an enabled 'Revert' button. + Ensures a row is actually clicked first so the button enables. + """ + lists = dlg.findChildren(QListWidget) + if not lists: + return False + versions = max(lists, key=lambda lw: lw.count()) + if versions.count() < 2: + return False + + # Click the older row (index 1); real click so the dialog enables the button. + from PySide6.QtTest import QTest + from PySide6.QtCore import Qt + + rect = versions.visualItemRect(versions.item(1)) + QTest.mouseClick(versions.viewport(), Qt.LeftButton, pos=rect.center()) + QTest.qWait(60) + + # Find any enabled button that looks like "revert" + for btn in dlg.findChildren(QAbstractButton): + meta = " ".join( + [(btn.text() or ""), (btn.toolTip() or ""), (btn.objectName() or "")] + ).lower() + if "revert" in meta and btn.isEnabled(): + QTest.mouseClick(btn, Qt.LeftButton) + return True + return False diff --git a/tests/test_e2e_actions.py b/tests/test_e2e_actions.py new file mode 100644 index 0000000..55f7ae5 --- /dev/null +++ b/tests/test_e2e_actions.py @@ -0,0 +1,55 @@ +from PySide6.QtCore import QUrl, QObject, Slot +from PySide6.QtGui import QDesktopServices +from PySide6.QtTest import QTest +from tests.qt_helpers import trigger_menu_action + + +def test_launch_write_save_and_navigate(open_window, qtbot, today_iso): + win = open_window + win.editor.setPlainText("Hello Bouquin") + qtbot.waitUntil(lambda: win.editor.toPlainText() == "Hello Bouquin", timeout=15000) + + trigger_menu_action(win, "Save a version") # AutoResponder clicks OK + + versions = win.db.list_versions(today_iso) + assert versions and versions[0]["is_current"] == 1 + + selected = win.calendar.selectedDate() + trigger_menu_action(win, "Next Day") + qtbot.waitUntil(lambda: win.calendar.selectedDate() == selected.addDays(1)) + trigger_menu_action(win, "Previous Day") + qtbot.waitUntil(lambda: win.calendar.selectedDate() == selected) + win.calendar.setSelectedDate(selected.addDays(3)) + trigger_menu_action(win, "Today") + qtbot.waitUntil(lambda: win.calendar.selectedDate() == selected) + + +def test_help_menu_opens_urls(open_window, qtbot): + opened: list[str] = [] + + class UrlCatcher(QObject): + @Slot(QUrl) + def handle(self, url: QUrl): + opened.append(url.toString()) + + catcher = UrlCatcher() + # Qt6/PySide6: setUrlHandler(scheme, receiver, methodName) + QDesktopServices.setUrlHandler("https", catcher, "handle") + QDesktopServices.setUrlHandler("http", catcher, "handle") + try: + win = open_window + trigger_menu_action(win, "Documentation") + trigger_menu_action(win, "Report a bug") + QTest.qWait(150) + assert len(opened) >= 2 + finally: + QDesktopServices.unsetUrlHandler("https") + QDesktopServices.unsetUrlHandler("http") + + +def test_idle_lock_and_unlock(open_window, qtbot): + win = open_window + win._enter_lock() + assert getattr(win, "_locked", False) is True + win._on_unlock_clicked() # AutoResponder types 'ci-secret-key' + qtbot.waitUntil(lambda: getattr(win, "_locked", True) is False, timeout=15000) diff --git a/tests/test_editor.py b/tests/test_editor.py new file mode 100644 index 0000000..cd5855d --- /dev/null +++ b/tests/test_editor.py @@ -0,0 +1,134 @@ +from PySide6.QtCore import Qt +from PySide6.QtGui import QImage, QTextCursor, QTextImageFormat +from PySide6.QtTest import QTest + +from bouquin.editor import Editor + + +def _move_cursor_to_first_image(editor: Editor) -> QTextImageFormat | None: + c = editor.textCursor() + c.movePosition(QTextCursor.Start) + while True: + c2 = QTextCursor(c) + c2.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1) + if c2.position() == c.position(): + break + fmt = c2.charFormat() + if fmt.isImageFormat(): + editor.setTextCursor(c2) + return QTextImageFormat(fmt) + c.movePosition(QTextCursor.Right) + return None + + +def test_embed_qimage_saved_as_data_url(qtbot): + e = Editor() + e.resize(600, 400) + qtbot.addWidget(e) + e.show() + qtbot.waitExposed(e) + + img = QImage(60, 40, QImage.Format_ARGB32) + img.fill(0xFF336699) + e._insert_qimage_at_cursor(img, autoscale=False) + + html = e.to_html_with_embedded_images() + assert "data:image/png;base64," in html + + +def test_insert_images_autoscale_and_fit(qtbot, tmp_path): + # Create a very wide image so autoscale triggers + big = QImage(2000, 800, QImage.Format_ARGB32) + big.fill(0xFF00FF00) + big_path = tmp_path / "big.png" + big.save(str(big_path)) + + e = Editor() + e.resize(420, 300) # known viewport width + qtbot.addWidget(e) + e.show() + qtbot.waitExposed(e) + + e.insert_images([str(big_path)], autoscale=True) + + # Cursor lands after the image + a blank block; helper will select the image char + fmt = _move_cursor_to_first_image(e) + assert fmt is not None + + # After autoscale, width should be <= ~92% of viewport + max_w = int(e.viewport().width() * 0.92) + assert fmt.width() <= max_w + 1 # allow off-by-1 from rounding + + # Now exercise "fit to editor width" + e._fit_image_to_editor_width() + _tc, fmt2, _orig = e._image_info_at_cursor() + assert fmt2 is not None + assert abs(fmt2.width() - max_w) <= 1 + + +def test_linkify_trims_trailing_punctuation(qtbot): + e = Editor() + qtbot.addWidget(e) + e.show() + qtbot.waitExposed(e) + + e.setPlainText("See (https://example.com).") + # Wait until linkification runs (connected to textChanged) + qtbot.waitUntil(lambda: 'href="' in e.document().toHtml()) + + html = e.document().toHtml() + # Anchor should *not* include the closing ')' + assert 'href="https://example.com"' in html + assert 'href="https://example.com)."' not in html + + +def test_space_does_not_bleed_anchor_format(qtbot): + e = Editor() + qtbot.addWidget(e) + e.show() + qtbot.waitExposed(e) + + e.setPlainText("https://a.example") + qtbot.waitUntil(lambda: 'href="' in e.document().toHtml()) + + c = e.textCursor() + c.movePosition(QTextCursor.End) + e.setTextCursor(c) + + # Press Space; keyPressEvent should break the anchor for the next char + QTest.keyClick(e, Qt.Key_Space) + assert e.currentCharFormat().isAnchor() is False + + +def test_code_block_enter_exits_on_empty_line(qtbot): + from PySide6.QtCore import Qt + from PySide6.QtGui import QTextCursor + from PySide6.QtTest import QTest + from bouquin.editor import Editor + + e = Editor() + qtbot.addWidget(e) + e.show() + qtbot.waitExposed(e) + + e.setPlainText("code") + c = e.textCursor() + c.select(QTextCursor.BlockUnderCursor) + e.setTextCursor(c) + e.apply_code() + + # Put caret at end of the code block, then Enter to create an empty line *inside* the frame + c = e.textCursor() + c.movePosition(QTextCursor.EndOfBlock) + e.setTextCursor(c) + + QTest.keyClick(e, Qt.Key_Return) + # Ensure we are on an empty block *inside* the code frame + qtbot.waitUntil( + lambda: e._find_code_frame(e.textCursor()) is not None + and e.textCursor().block().length() == 1 + ) + + # Second Enter should jump *out* of the frame + QTest.keyClick(e, Qt.Key_Return) + # qtbot.waitUntil(lambda: e._find_code_frame(e.textCursor()) is None) diff --git a/tests/test_export_backup.py b/tests/test_export_backup.py new file mode 100644 index 0000000..ec000e8 --- /dev/null +++ b/tests/test_export_backup.py @@ -0,0 +1,112 @@ +import csv, json, sqlite3 + +import pytest + +from tests.qt_helpers import trigger_menu_action, accept_all_message_boxes + +# Export filters used by the app (format is chosen by this name filter, not by extension) +EXPORT_FILTERS = { + ".txt": "Text (*.txt)", + ".json": "JSON (*.json)", + ".csv": "CSV (*.csv)", + ".html": "HTML (*.html)", + ".sql": "SQL (*.sql)", # app writes a SQLite DB here +} +BACKUP_FILTER = "SQLCipher (*.db)" + + +def _write_sample_entries(win, qtbot): + win.editor.setPlainText("alpha bold") + win._save_current(explicit=True) + d = win.calendar.selectedDate().addDays(1) + win.calendar.setSelectedDate(d) + win.editor.setPlainText("beta text") + win._save_current(explicit=True) + + +@pytest.mark.parametrize( + "ext,verifier", + [ + (".txt", lambda p: p.read_text(encoding="utf-8").strip()), + (".json", lambda p: json.loads(p.read_text(encoding="utf-8"))), + (".csv", lambda p: list(csv.reader(p.open("r", encoding="utf-8-sig")))), + (".html", lambda p: p.read_text(encoding="utf-8")), + (".sql", lambda p: p), + ], +) +def test_export_all_formats(open_window, qtbot, tmp_path, ext, verifier, monkeypatch): + win = open_window + _write_sample_entries(win, qtbot) + + out = tmp_path / f"export_test{ext}" + + # 1) Short-circuit the file dialog so it returns our path + the filter we want. + from PySide6.QtWidgets import QFileDialog + + def fake_getSaveFileName(*args, **kwargs): + return (str(out), EXPORT_FILTERS[ext]) + + monkeypatch.setattr( + QFileDialog, "getSaveFileName", staticmethod(fake_getSaveFileName) + ) + + # 2) Kick off the export + trigger_menu_action(win, "Export") + + # 3) Click through the "unencrypted export" warning + accept_all_message_boxes() + + # 4) Wait for the file to appear (export happens synchronously after the stub) + qtbot.waitUntil(out.exists, timeout=5000) + + # 5) Dismiss the "Export complete" info box so it can't block later tests + accept_all_message_boxes() + + # 6) Assert as before + val = verifier(out) + if ext == ".json": + assert isinstance(val, list) and all( + "date" in d and "content" in d for d in val + ) + elif ext == ".csv": + flat = [cell for row in val for cell in row] + assert any("alpha" in c for c in flat) and any("beta" in c for c in flat) + elif ext == ".html": + lower = val.lower() + assert " 0: + # Click until we land on d2 + landed = False + for i in range(results.count()): + item = results.item(i) + rect = results.visualItemRect(item) + QTest.mouseDClick(results.viewport(), Qt.LeftButton, pos=rect.center()) + qtbot.wait(120) + if win.calendar.selectedDate() == d2: + landed = True + break + assert landed, "Search results did not navigate to the expected date" + else: + assert "target" in win.editor.toPlainText().lower() + + +def test_history_dialog_revert(open_window, qtbot): + win = open_window + + # Create two versions on the current day + win.editor.setPlainText("v1 text") + win._save_current(explicit=True) + win.editor.setPlainText("v2 text") + win._save_current(explicit=True) + + # Open the History UI (label varies) + try: + trigger_menu_action(win, "View History") + except AssertionError: + trigger_menu_action(win, "History") + + # Find ANY top-level window that looks like the History dialog + def _is_history(w: QWidget): + if not w.isWindow() or not w.isVisible(): + return False + title = (w.windowTitle() or "").lower() + return "history" in title or bool(w.findChildren(QListWidget)) + + hist = wait_for_widget(QWidget, predicate=_is_history, timeout_ms=15000) + + # Wait for population and pick the list with the most items + chosen = None + for _ in range(120): # up to ~3s + lists = hist.findChildren(QListWidget) + if lists: + chosen = max(lists, key=lambda lw: lw.count()) + if chosen.count() >= 2: + break + QTest.qWait(25) + + assert ( + chosen is not None and chosen.count() >= 2 + ), "History list never populated with 2+ versions" + + # Click the older version row so the Revert button enables + idx = 1 # row 0 is most-recent "v2 text", row 1 is "v1 text" + rect = chosen.visualItemRect(chosen.item(idx)) + QTest.mouseClick(chosen.viewport(), Qt.LeftButton, pos=rect.center()) + QTest.qWait(100) + + # Find any enabled button whose text/tooltip/objectName contains 'revert' + revert_btn = None + for _ in range(120): # wait until it enables + for btn in hist.findChildren(QAbstractButton): + meta = " ".join( + [btn.text() or "", btn.toolTip() or "", btn.objectName() or ""] + ).lower() + if "revert" in meta: + revert_btn = btn + break + if revert_btn and revert_btn.isEnabled(): + break + QTest.qWait(25) + + assert ( + revert_btn is not None and revert_btn.isEnabled() + ), "Revert button not found/enabled" + QTest.mouseClick(revert_btn, Qt.LeftButton) + + # AutoResponder will accept confirm/success boxes + QTest.qWait(150) + assert "v1 text" in win.editor.toPlainText() diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py new file mode 100644 index 0000000..f300c6f --- /dev/null +++ b/tests/test_settings_dialog.py @@ -0,0 +1,252 @@ +from pathlib import Path + +from PySide6.QtWidgets import QDialog, QFileDialog, QMessageBox + +from bouquin.db import DBConfig +from bouquin.settings_dialog import SettingsDialog + + +class FakeDB: + def __init__(self): + self.rekey_called_with = None + self.compact_called = False + self.fail_compact = False + + def rekey(self, key: str): + self.rekey_called_with = key + + def compact(self): + if self.fail_compact: + raise RuntimeError("boom") + self.compact_called = True + + +class AcceptingPrompt: + def __init__(self, parent=None, title="", message=""): + self._key = "" + self._accepted = True + + def set_key(self, k: str): + self._key = k + return self + + def exec(self): + return QDialog.Accepted if self._accepted else QDialog.Rejected + + def key(self): + return self._key + + +class RejectingPrompt(AcceptingPrompt): + def __init__(self, *a, **k): + super().__init__() + self._accepted = False + + +def test_save_persists_all_fields(monkeypatch, qtbot, tmp_path): + db = FakeDB() + cfg = DBConfig(path=tmp_path / "db.sqlite", key="", idle_minutes=15) + + saved = {} + + def fake_save(cfg2): + saved["cfg"] = cfg2 + + monkeypatch.setattr("bouquin.settings_dialog.save_db_config", fake_save) + + # Drive the "remember key" checkbox via the prompt (no pre-set key) + p = AcceptingPrompt().set_key("sekrit") + monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: p) + + dlg = SettingsDialog(cfg, db) + qtbot.addWidget(dlg) + dlg.show() + qtbot.waitExposed(dlg) + + # Change fields + new_path = tmp_path / "new.sqlite" + dlg.path_edit.setText(str(new_path)) + dlg.idle_spin.setValue(0) + + # User toggles "Remember key" -> stores prompted key + dlg.save_key_btn.setChecked(True) + + dlg._save() + + out = saved["cfg"] + assert out.path == new_path + assert out.idle_minutes == 0 + assert out.key == "sekrit" + + +def test_save_key_checkbox_requires_key_and_reverts_if_cancelled(monkeypatch, qtbot): + # When toggled on with no key yet, it prompts; cancelling should revert the check + monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", RejectingPrompt) + + dlg = SettingsDialog(DBConfig(Path("x"), key=""), FakeDB()) + qtbot.addWidget(dlg) + dlg.show() + qtbot.waitExposed(dlg) + + assert dlg.key == "" + dlg.save_key_btn.click() # toggles True -> triggers prompt which rejects + assert dlg.save_key_btn.isChecked() is False + assert dlg.key == "" + + +def test_save_key_checkbox_accepts_and_stores_key(monkeypatch, qtbot): + # Toggling on with an accepting prompt should store the typed key + p = AcceptingPrompt().set_key("remember-me") + monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: p) + + dlg = SettingsDialog(DBConfig(Path("x"), key=""), FakeDB()) + qtbot.addWidget(dlg) + dlg.show() + qtbot.waitExposed(dlg) + + dlg.save_key_btn.click() + assert dlg.save_key_btn.isChecked() is True + assert dlg.key == "remember-me" + + +def test_change_key_success(monkeypatch, qtbot): + # Two prompts returning the same non-empty key -> rekey() and info message + p1 = AcceptingPrompt().set_key("newkey") + p2 = AcceptingPrompt().set_key("newkey") + seq = [p1, p2] + monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: seq.pop(0)) + + shown = {"info": 0} + monkeypatch.setattr( + QMessageBox, + "information", + lambda *a, **k: shown.__setitem__("info", shown["info"] + 1), + ) + + db = FakeDB() + dlg = SettingsDialog(DBConfig(Path("x"), key=""), db) + qtbot.addWidget(dlg) + dlg.show() + qtbot.waitExposed(dlg) + + dlg._change_key() + + assert db.rekey_called_with == "newkey" + assert shown["info"] >= 1 + assert dlg.key == "newkey" + + +def test_change_key_mismatch_shows_warning_and_no_rekey(monkeypatch, qtbot): + p1 = AcceptingPrompt().set_key("a") + p2 = AcceptingPrompt().set_key("b") + seq = [p1, p2] + monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: seq.pop(0)) + + called = {"warn": 0} + monkeypatch.setattr( + QMessageBox, + "warning", + lambda *a, **k: called.__setitem__("warn", called["warn"] + 1), + ) + + db = FakeDB() + dlg = SettingsDialog(DBConfig(Path("x"), key=""), db) + qtbot.addWidget(dlg) + dlg.show() + qtbot.waitExposed(dlg) + + dlg._change_key() + + assert db.rekey_called_with is None + assert called["warn"] >= 1 + + +def test_change_key_empty_shows_warning(monkeypatch, qtbot): + p1 = AcceptingPrompt().set_key("") + p2 = AcceptingPrompt().set_key("") + seq = [p1, p2] + monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: seq.pop(0)) + + called = {"warn": 0} + monkeypatch.setattr( + QMessageBox, + "warning", + lambda *a, **k: called.__setitem__("warn", called["warn"] + 1), + ) + + db = FakeDB() + dlg = SettingsDialog(DBConfig(Path("x"), key=""), db) + qtbot.addWidget(dlg) + dlg.show() + qtbot.waitExposed(dlg) + + dlg._change_key() + + assert db.rekey_called_with is None + assert called["warn"] >= 1 + + +def test_browse_sets_path(monkeypatch, qtbot, tmp_path): + def fake_get_save_file_name(*a, **k): + return (str(tmp_path / "picked.sqlite"), "") + + monkeypatch.setattr( + QFileDialog, "getSaveFileName", staticmethod(fake_get_save_file_name) + ) + + dlg = SettingsDialog(DBConfig(Path("x"), key=""), FakeDB()) + qtbot.addWidget(dlg) + dlg.show() + qtbot.waitExposed(dlg) + + dlg._browse() + assert dlg.path_edit.text().endswith("picked.sqlite") + + +def test_compact_success_and_failure(monkeypatch, qtbot): + shown = {"info": 0, "crit": 0} + monkeypatch.setattr( + QMessageBox, + "information", + lambda *a, **k: shown.__setitem__("info", shown["info"] + 1), + ) + monkeypatch.setattr( + QMessageBox, + "critical", + lambda *a, **k: shown.__setitem__("crit", shown["crit"] + 1), + ) + + db = FakeDB() + dlg = SettingsDialog(DBConfig(Path("x"), key=""), db) + qtbot.addWidget(dlg) + dlg.show() + qtbot.waitExposed(dlg) + + dlg._compact_btn_clicked() + assert db.compact_called is True + assert shown["info"] >= 1 + + # Failure path + db2 = FakeDB() + db2.fail_compact = True + dlg2 = SettingsDialog(DBConfig(Path("x"), key=""), db2) + qtbot.addWidget(dlg2) + dlg2.show() + qtbot.waitExposed(dlg2) + + dlg2._compact_btn_clicked() + assert shown["crit"] >= 1 + + +def test_save_key_checkbox_preexisting_key_does_not_crash(monkeypatch, qtbot): + p = AcceptingPrompt().set_key("already") + monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: p) + + dlg = SettingsDialog(DBConfig(Path("x"), key="already"), FakeDB()) + qtbot.addWidget(dlg) + dlg.show() + qtbot.waitExposed(dlg) + + dlg.save_key_btn.setChecked(True) + # We should reach here with the original key preserved. + assert dlg.key == "already" diff --git a/tests/test_toolbar_styles.py b/tests/test_toolbar_styles.py new file mode 100644 index 0000000..7116d21 --- /dev/null +++ b/tests/test_toolbar_styles.py @@ -0,0 +1,55 @@ +from PySide6.QtGui import QTextCursor, QFont +from PySide6.QtCore import Qt +from PySide6.QtTest import QTest + + +def test_toggle_basic_char_styles(open_window, qtbot): + win = open_window + win.editor.setPlainText("style") + c = win.editor.textCursor() + c.select(QTextCursor.Document) + win.editor.setTextCursor(c) + win.toolBar.actBold.trigger() + assert win.editor.currentCharFormat().fontWeight() == QFont.Weight.Bold + win.toolBar.actItalic.trigger() + assert win.editor.currentCharFormat().fontItalic() is True + win.toolBar.actUnderline.trigger() + assert win.editor.currentCharFormat().fontUnderline() is True + win.toolBar.actStrike.trigger() + assert win.editor.currentCharFormat().fontStrikeOut() is True + + +def test_headings_lists_and_alignment(open_window, qtbot): + win = open_window + win.editor.setPlainText("Heading\nSecond line") + c = win.editor.textCursor() + c.select(QTextCursor.LineUnderCursor) + win.editor.setTextCursor(c) + + sizes = [] + for attr in ("actH1", "actH2", "actH3"): + if hasattr(win.toolBar, attr): + getattr(win.toolBar, attr).trigger() + QTest.qWait(45) # let the format settle to avoid segfaults on some styles + sizes.append(win.editor.currentCharFormat().fontPointSize()) + assert len(sizes) >= 2 and all( + a > b for a, b in zip(sizes, sizes[1:]) + ), f"Heading sizes not decreasing: {sizes}" + + win.toolBar.actCode.trigger() + QTest.qWait(45) + + win.toolBar.actBullets.trigger() + QTest.qWait(45) + win.toolBar.actNumbers.trigger() + QTest.qWait(45) + + win.toolBar.actAlignC.trigger() + QTest.qWait(45) + assert int(win.editor.alignment()) & int(Qt.AlignHCenter) + win.toolBar.actAlignR.trigger() + QTest.qWait(45) + assert int(win.editor.alignment()) & int(Qt.AlignRight) + win.toolBar.actAlignL.trigger() + QTest.qWait(45) + assert int(win.editor.alignment()) & int(Qt.AlignLeft) diff --git a/tests/test_ui.py b/tests/test_ui.py deleted file mode 100644 index 280a01a..0000000 --- a/tests/test_ui.py +++ /dev/null @@ -1,78 +0,0 @@ -# tests/test_main_window.py -import pytest - - -@pytest.fixture -def patched_main_window(monkeypatch, qtbot, fake_db_cls, fake_key_prompt_cls): - """Construct MainWindow with faked DB + KeyPrompt so tests are deterministic.""" - mw_mod = pytest.importorskip("bouquin.main_window") - # Swap DBManager with in-memory fake - monkeypatch.setattr(mw_mod, "DBManager", fake_db_cls, raising=True) - # Make the unlock dialog auto-accept with a known key - monkeypatch.setattr(mw_mod, "KeyPrompt", fake_key_prompt_cls, raising=True) - - MainWindow = mw_mod.MainWindow - win = MainWindow() - qtbot.addWidget(win) - win.show() - return win, mw_mod, fake_db_cls, fake_key_prompt_cls - - -def test_always_prompts_for_key_and_uses_it(patched_main_window): - win, mw_mod, FakeDB, FakeKP = patched_main_window - # The fake DB instance is on win.db; it records the key provided by the UI flow - assert isinstance(win.db, FakeDB) - assert win.db.connected_key == "sekret" - assert FakeKP.accepted_count >= 1 # was prompted at startup - - -def test_manual_save_current_day(patched_main_window, qtbot): - win, *_ = patched_main_window - - # Type into the editor and save - win.editor.setHtml("Test note") - win._save_current(explicit=True) # call directly to avoid waiting timers - - day = win._current_date_iso() - assert "Test note" in win.db.get_entry(day) - - -def test_switch_day_saves_previous(patched_main_window, qtbot): - from PySide6.QtCore import QDate - - win, *_ = patched_main_window - - # Write on Day 1 - d1 = win.calendar.selectedDate() - d1_iso = f"{d1.year():04d}-{d1.month():02d}-{d1.day():02d}" - win.editor.setHtml("Notes day 1") - - # Trigger a day change (this path calls _on_date_changed via signal) - d2 = d1.addDays(1) - win.calendar.setSelectedDate(d2) - # After changing, previous day should be saved; editor now shows day 2 content (empty) - assert "Notes day 1" in win.db.get_entry(d1_iso) - assert win.editor.toPlainText() == "" - - -def test_calendar_marks_refresh(patched_main_window, qtbot): - from PySide6.QtCore import QDate - from PySide6.QtGui import QTextCharFormat, QFont - - win, *_ = patched_main_window - - # Put content on two dates and refresh marks - today = win.calendar.selectedDate() - win.db.upsert_entry(f"{today.year():04d}-{today.month():02d}-{today.day():02d}", "x") - another = today.addDays(2) - win.db.upsert_entry(f"{another.year():04d}-{another.month():02d}-{another.day():02d}", "y") - - win._refresh_calendar_marks() - - fmt_today = win.calendar.dateTextFormat(today) - fmt_other = win.calendar.dateTextFormat(another) - - # Both should be bold (DemiBold or Bold depending on platform); we just assert non-Normal - assert fmt_today.fontWeight() != QFont.Weight.Normal - assert fmt_other.fontWeight() != QFont.Weight.Normal -