Refactor tests

This commit is contained in:
Miguel Jacq 2025-11-04 18:33:54 +11:00
parent ebb0fd6e11
commit a29fc9423e
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
10 changed files with 1069 additions and 121 deletions

0
tests/__init__.py Normal file
View file

View file

@ -1,56 +1,77 @@
import os import os
import sys
from pathlib import Path
import pytest import pytest
from PySide6.QtCore import QStandardPaths
from tests.qt_helpers import AutoResponder
# Run Qt without a visible display (CI-safe) # Force Qt *non-native* file dialog so we can type a filename programmatically.
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") 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 # Make project importable
def fake_key_prompt_cls(): PROJECT_ROOT = Path(__file__).resolve().parents[1]
"""A KeyPrompt stand-in that immediately returns Accepted with a fixed key.""" if str(PROJECT_ROOT) not in sys.path:
from PySide6.QtWidgets import QDialog sys.path.insert(0, str(PROJECT_ROOT))
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 @pytest.fixture(scope="session", autouse=True)
def fake_db_cls(): def enable_qstandardpaths_test_mode():
"""In-memory DB fake that mimics the subset of DBManager used by the UI.""" QStandardPaths.setTestModeEnabled(True)
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
def get_entry(self, date_iso: str) -> str: @pytest.fixture()
return self.data.get(date_iso, "") 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]: @pytest.fixture()
return [d for d, t in self.data.items() if t.strip()] 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}"

287
tests/qt_helpers.py Normal file
View file

@ -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

55
tests/test_e2e_actions.py Normal file
View file

@ -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)

134
tests/test_editor.py Normal file
View file

@ -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)

112
tests/test_export_backup.py Normal file
View file

@ -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 <b>bold</b>")
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 "<html" in lower and ("<article" in lower or "<body" in lower)
elif ext == ".txt":
assert "alpha" in val and "beta" in val
elif ext == ".sql":
con = sqlite3.connect(str(out))
cur = con.cursor()
cur.execute("SELECT name FROM sqlite_master WHERE type='table'")
names = {r[0] for r in cur.fetchall()}
assert {"pages", "versions"} <= names
con.close()
def test_backup_encrypted_database(open_window, qtbot, tmp_path, monkeypatch):
win = open_window
_write_sample_entries(win, qtbot)
from PySide6.QtWidgets import QFileDialog
def fake_getSaveFileName(*args, **kwargs):
return (str(tmp_path / "backup.db"), BACKUP_FILTER)
monkeypatch.setattr(
QFileDialog, "getSaveFileName", staticmethod(fake_getSaveFileName)
)
trigger_menu_action(win, "Backup")
backup = tmp_path / "backup.db"
qtbot.waitUntil(backup.exists, timeout=5000)
# The backup path is now ready; proceed as before...
sqlcipher3 = pytest.importorskip("sqlcipher3")
con = sqlcipher3.dbapi2.connect(str(backup))
cur = con.cursor()
cur.execute("PRAGMA key = 'ci-secret-key';")
ok = cur.execute("PRAGMA cipher_integrity_check;").fetchall()
assert ok == []
con.close()

View file

@ -0,0 +1,110 @@
from PySide6.QtCore import Qt
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QListWidget, QWidget, QAbstractButton
from tests.qt_helpers import (
trigger_menu_action,
wait_for_widget,
find_line_edit_by_placeholder,
)
def test_search_and_open_date(open_window, qtbot):
win = open_window
win.editor.setPlainText("lorem ipsum target")
win._save_current(explicit=True)
base = win.calendar.selectedDate()
d2 = base.addDays(7)
win.calendar.setSelectedDate(d2)
win.editor.setPlainText("target appears here, too")
win._save_current(explicit=True)
search_box = find_line_edit_by_placeholder(win, "search")
assert search_box is not None, "Search input not found"
search_box.setText("target")
QTest.qWait(150)
results = getattr(getattr(win, "search", None), "results", None)
if isinstance(results, QListWidget) and results.count() > 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()

View file

@ -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"

View file

@ -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)

View file

@ -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