Refactor tests
This commit is contained in:
parent
ebb0fd6e11
commit
a29fc9423e
10 changed files with 1069 additions and 121 deletions
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
|
|
@ -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}"
|
||||
|
|
|
|||
287
tests/qt_helpers.py
Normal file
287
tests/qt_helpers.py
Normal 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
55
tests/test_e2e_actions.py
Normal 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
134
tests/test_editor.py
Normal 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
112
tests/test_export_backup.py
Normal 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()
|
||||
110
tests/test_search_history.py
Normal file
110
tests/test_search_history.py
Normal 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()
|
||||
252
tests/test_settings_dialog.py
Normal file
252
tests/test_settings_dialog.py
Normal 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"
|
||||
55
tests/test_toolbar_styles.py
Normal file
55
tests/test_toolbar_styles.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue