diff --git a/.gitignore b/.gitignore index 2352872..8652982 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,3 @@ __pycache__ .pytest_cache dist -.coverage diff --git a/CHANGELOG.md b/CHANGELOG.md index c560986..e69fc13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,6 @@ * Add Backup option (database remains encrypted with SQLCipher) * Add ability to run VACUUM (compact) on the database in settings * Add ability to store images in the page - * Lots more tests, over 80% coverage # 0.1.8 diff --git a/README.md b/README.md index 3a9bba1..ea1cbdc 100644 --- a/README.md +++ b/README.md @@ -36,10 +36,6 @@ There is deliberately no network connectivity or syncing intended. Make sure you have `libxcb-cursor0` installed (it may be called something else on non-Debian distributions). -### From PyPi/pip - - * `pip install bouquin` - ### From source * Clone this repo or download the tarball from the releases page @@ -51,9 +47,14 @@ Make sure you have `libxcb-cursor0` installed (it may be called something else o * Download the whl and run it +### From PyPi/pip + + * `pip install bouquin` + + ## How to run the tests * Clone the repo * Ensure you have poetry installed * Run `poetry install --with test` - * Run `poetry run pytest -vvvv --cov=bouquin` + * Run `poetry run pytest -vvv` diff --git a/bouquin/history_dialog.py b/bouquin/history_dialog.py index 5ee404c..9cccda7 100644 --- a/bouquin/history_dialog.py +++ b/bouquin/history_dialog.py @@ -167,10 +167,22 @@ class HistoryDialog(QDialog): return sel = self._db.get_version(version_id=sel_id) vno = sel["version_no"] + # Confirm + if ( + QMessageBox.question( + self, + "Revert", + f"Revert {self._date} to version v{vno}?\n\nYou can always change your mind later.", + QMessageBox.Yes | QMessageBox.No, + ) + != QMessageBox.Yes + ): + return # Flip head pointer try: self._db.revert_to_version(self._date, version_id=sel_id) except Exception as e: QMessageBox.critical(self, "Revert failed", str(e)) return + QMessageBox.information(self, "Reverted", f"{self._date} is now at v{vno}.") self.accept() # let the caller refresh the editor diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 1428384..cbcb312 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -627,7 +627,7 @@ If you want an encrypted backup, choose Backup instead of Export. elif selected_filter.startswith("SQL"): self.db.export_sql(filename) else: - self.db.export_by_extension(filename) + self.db.export_by_extension(entries, filename) QMessageBox.information(self, "Export complete", f"Saved to:\n{filename}") except Exception as e: diff --git a/bouquin/settings_dialog.py b/bouquin/settings_dialog.py index 0a3dfd8..947deaa 100644 --- a/bouquin/settings_dialog.py +++ b/bouquin/settings_dialog.py @@ -232,7 +232,7 @@ class SettingsDialog(QDialog): self.save_key_btn.setChecked(False) self.save_key_btn.blockSignals(False) return - self.key = p1.key() or "" + self.key = p1.key() or "" else: self.key = "" diff --git a/pyproject.toml b/pyproject.toml index 30c541a..8e30123 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bouquin" -version = "0.1.9" +version = "0.1.8" description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." authors = ["Miguel Jacq "] readme = "README.md" diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/conftest.py b/tests/conftest.py index 1900f40..7f51d8c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,77 +1,56 @@ import os -import sys -from pathlib import Path import pytest -from PySide6.QtCore import QStandardPaths -from tests.qt_helpers import AutoResponder -# 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") +# Run Qt without a visible display (CI-safe) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") -# 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_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 -@pytest.fixture(scope="session", autouse=True) -def enable_qstandardpaths_test_mode(): - QStandardPaths.setTestModeEnabled(True) +@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 + def connect(self): + # record the key that UI supplied + self.connected_key = self.cfg.key + return True -@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 get_entry(self, date_iso: str) -> str: + return self.data.get(date_iso, "") + def upsert_entry(self, date_iso: str, content: str) -> None: + self.data[date_iso] = content -@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 dates_with_content(self) -> list[str]: + return [d for d, t in self.data.items() if t.strip()] + def close(self) -> None: + self.closed = True -@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}" + return FakeDB diff --git a/tests/qt_helpers.py b/tests/qt_helpers.py deleted file mode 100644 index 1b9b9a3..0000000 --- a/tests/qt_helpers.py +++ /dev/null @@ -1,287 +0,0 @@ -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 deleted file mode 100644 index 55f7ae5..0000000 --- a/tests/test_e2e_actions.py +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index cd5855d..0000000 --- a/tests/test_editor.py +++ /dev/null @@ -1,134 +0,0 @@ -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 deleted file mode 100644 index ec000e8..0000000 --- a/tests/test_export_backup.py +++ /dev/null @@ -1,112 +0,0 @@ -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 deleted file mode 100644 index f300c6f..0000000 --- a/tests/test_settings_dialog.py +++ /dev/null @@ -1,252 +0,0 @@ -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 deleted file mode 100644 index 7116d21..0000000 --- a/tests/test_toolbar_styles.py +++ /dev/null @@ -1,55 +0,0 @@ -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 new file mode 100644 index 0000000..280a01a --- /dev/null +++ b/tests/test_ui.py @@ -0,0 +1,78 @@ +# 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 +