parent
31604a0cd2
commit
39576ac7f3
54 changed files with 1616 additions and 4012 deletions
|
|
@ -1,133 +1,51 @@
|
|||
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
|
||||
import pytest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
# Ensure the nested package directory (repo_root/bouquin) is on sys.path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
PKG_PARENT = PROJECT_ROOT / "bouquin"
|
||||
if str(PKG_PARENT) not in sys.path:
|
||||
sys.path.insert(0, str(PKG_PARENT))
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
|
||||
# Make project importable
|
||||
from PySide6.QtWidgets import QApplication, QWidget
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
@pytest.fixture(scope="session")
|
||||
def app():
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
app = QApplication([])
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def enable_qstandardpaths_test_mode():
|
||||
QStandardPaths.setTestModeEnabled(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
|
||||
|
||||
|
||||
@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()
|
||||
def isolate_qsettings(tmp_path_factory):
|
||||
cfgdir = tmp_path_factory.mktemp("qt_cfg")
|
||||
os.environ["XDG_CONFIG_HOME"] = str(cfgdir)
|
||||
yield
|
||||
s.clear()
|
||||
|
||||
|
||||
@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
|
||||
|
||||
app = QApplication.instance()
|
||||
themes = ThemeManager(app, ThemeConfig())
|
||||
themes.apply(Theme.SYSTEM)
|
||||
win = MainWindow(themes=themes)
|
||||
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}"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def theme_parent_widget(qtbot):
|
||||
"""A minimal parent that provides .themes.apply(...) like MainWindow."""
|
||||
|
||||
class _ThemesStub:
|
||||
def __init__(self):
|
||||
self.applied = []
|
||||
|
||||
def apply(self, theme):
|
||||
self.applied.append(theme)
|
||||
|
||||
class _Parent(QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.themes = _ThemesStub()
|
||||
|
||||
parent = _Parent()
|
||||
qtbot.addWidget(parent)
|
||||
return parent
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def qapp():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
yield app
|
||||
# do not quit; pytest might still need it
|
||||
# app.quit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db_path(tmp_path):
|
||||
return tmp_path / "notebook.db"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cfg(temp_db_path):
|
||||
# Use the real DBConfig from the app (SQLCipher-backed)
|
||||
def tmp_db_cfg(tmp_path):
|
||||
from bouquin.db import DBConfig
|
||||
|
||||
db_path = tmp_path / "notebook.db"
|
||||
key = "test-secret-key"
|
||||
return DBConfig(
|
||||
path=Path(temp_db_path),
|
||||
key="testkey",
|
||||
idle_minutes=0,
|
||||
theme="system",
|
||||
move_todos=True,
|
||||
path=db_path, key=key, idle_minutes=0, theme="light", move_todos=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_db(tmp_db_cfg):
|
||||
from bouquin.db import DBManager
|
||||
|
||||
db = DBManager(tmp_db_cfg)
|
||||
ok = db.connect()
|
||||
assert ok, "DB connect() should succeed"
|
||||
yield db
|
||||
db.close()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
127
tests/test_db.py
Normal file
127
tests/test_db.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import json, csv
|
||||
import datetime as dt
|
||||
|
||||
|
||||
def _today():
|
||||
return dt.date.today().isoformat()
|
||||
|
||||
|
||||
def _yesterday():
|
||||
return (dt.date.today() - dt.timedelta(days=1)).isoformat()
|
||||
|
||||
|
||||
def _tomorrow():
|
||||
return (dt.date.today() + dt.timedelta(days=1)).isoformat()
|
||||
|
||||
|
||||
def _entry(text, i=0):
|
||||
return f"{text} line {i}\nsecond line\n\n- [x] done\n- [ ] todo"
|
||||
|
||||
|
||||
def test_connect_integrity_and_schema(fresh_db):
|
||||
d = _today()
|
||||
fresh_db.save_new_version(d, _entry("hello world"), "initial")
|
||||
vlist = fresh_db.list_versions(d)
|
||||
assert vlist
|
||||
v = fresh_db.get_version(version_id=vlist[0]["id"])
|
||||
assert v and "created_at" in v
|
||||
|
||||
|
||||
def test_save_and_get_entry_versions(fresh_db):
|
||||
d = _today()
|
||||
fresh_db.save_new_version(d, _entry("hello world"), "initial")
|
||||
txt = fresh_db.get_entry(d)
|
||||
assert "hello world" in txt
|
||||
|
||||
fresh_db.save_new_version(d, _entry("hello again"), "second")
|
||||
versions = fresh_db.list_versions(d)
|
||||
assert len(versions) >= 2
|
||||
assert any(v["is_current"] for v in versions)
|
||||
|
||||
first = sorted(versions, key=lambda v: v["version_no"])[0]
|
||||
fresh_db.revert_to_version(d, version_id=first["id"])
|
||||
txt2 = fresh_db.get_entry(d)
|
||||
assert "hello world" in txt2 and "again" not in txt2
|
||||
|
||||
|
||||
def test_dates_with_content_and_search(fresh_db):
|
||||
fresh_db.save_new_version(_today(), _entry("alpha bravo"), "t1")
|
||||
fresh_db.save_new_version(_yesterday(), _entry("bravo charlie"), "t2")
|
||||
fresh_db.save_new_version(_tomorrow(), _entry("delta alpha"), "t3")
|
||||
|
||||
dates = set(fresh_db.dates_with_content())
|
||||
assert _today() in dates and _yesterday() in dates and _tomorrow() in dates
|
||||
|
||||
hits = list(fresh_db.search_entries("alpha"))
|
||||
assert any(d == _today() for d, _ in hits)
|
||||
assert any(d == _tomorrow() for d, _ in hits)
|
||||
|
||||
|
||||
def test_get_all_entries_and_export_by_extension(fresh_db, tmp_path):
|
||||
for i in range(3):
|
||||
d = (dt.date.today() - dt.timedelta(days=i)).isoformat()
|
||||
fresh_db.save_new_version(d, _entry(f"note {i}"), f"note {i}")
|
||||
entries = fresh_db.get_all_entries()
|
||||
assert entries and all(len(t) == 2 for t in entries)
|
||||
|
||||
json_path = tmp_path / "export.json"
|
||||
fresh_db.export_json(entries, str(json_path))
|
||||
assert json_path.exists() and json.load(open(json_path)) is not None
|
||||
|
||||
csv_path = tmp_path / "export.csv"
|
||||
fresh_db.export_csv(entries, str(csv_path))
|
||||
assert csv_path.exists() and list(csv.reader(open(csv_path)))
|
||||
|
||||
txt_path = tmp_path / "export.txt"
|
||||
fresh_db.export_txt(entries, str(txt_path))
|
||||
assert txt_path.exists() and txt_path.read_text().strip()
|
||||
|
||||
md_path = tmp_path / "export.md"
|
||||
fresh_db.export_markdown(entries, str(md_path))
|
||||
md_text = md_path.read_text()
|
||||
assert md_path.exists() and entries[0][0] in md_text
|
||||
|
||||
html_path = tmp_path / "export.html"
|
||||
fresh_db.export_html(entries, str(html_path), title="My Notebook")
|
||||
assert html_path.exists() and "<html" in html_path.read_text().lower()
|
||||
|
||||
sql_path = tmp_path / "export.sql"
|
||||
fresh_db.export_sql(str(sql_path))
|
||||
assert sql_path.exists() and sql_path.read_bytes()
|
||||
|
||||
sqlc_path = tmp_path / "export.db"
|
||||
fresh_db.export_sqlcipher(str(sqlc_path))
|
||||
assert sqlc_path.exists() and sqlc_path.read_bytes()
|
||||
|
||||
for path in [json_path, csv_path, txt_path, md_path, html_path, sql_path]:
|
||||
path.unlink(missing_ok=True)
|
||||
fresh_db.export_by_extension(str(path))
|
||||
assert path.exists()
|
||||
|
||||
|
||||
def test_rekey_and_reopen(fresh_db, tmp_db_cfg):
|
||||
fresh_db.save_new_version(_today(), _entry("secure"), "before rekey")
|
||||
fresh_db.rekey("new-key-123")
|
||||
fresh_db.close()
|
||||
|
||||
from bouquin.db import DBManager
|
||||
|
||||
tmp_db_cfg.key = "new-key-123"
|
||||
db2 = DBManager(tmp_db_cfg)
|
||||
assert db2.connect()
|
||||
assert "secure" in db2.get_entry(_today())
|
||||
db2.close()
|
||||
|
||||
|
||||
def test_compact_and_close_dont_crash(fresh_db):
|
||||
fresh_db.compact()
|
||||
fresh_db.close()
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_export_by_extension_unsupported(fresh_db, tmp_path):
|
||||
p = tmp_path / "export.xyz"
|
||||
with pytest.raises(ValueError):
|
||||
fresh_db.export_by_extension(str(p))
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from bouquin.db import DBManager, DBConfig
|
||||
|
||||
# Use the same sqlite driver as the app (sqlcipher3) to prepare pre-0.1.5 "entries" DBs
|
||||
from sqlcipher3 import dbapi2 as sqlite
|
||||
|
||||
|
||||
def connect_raw_sqlcipher(db_path: Path, key: str):
|
||||
conn = sqlite.connect(str(db_path))
|
||||
conn.row_factory = sqlite.Row
|
||||
cur = conn.cursor()
|
||||
cur.execute(f"PRAGMA key = '{key}';")
|
||||
cur.execute("PRAGMA foreign_keys = ON;")
|
||||
cur.execute("PRAGMA journal_mode = WAL;").fetchone()
|
||||
return conn
|
||||
|
||||
|
||||
def test_migration_from_legacy_entries_table(cfg: DBConfig, tmp_path: Path):
|
||||
# Prepare a "legacy" DB that has only entries(date, content) and no pages/versions
|
||||
db_path = cfg.path
|
||||
conn = connect_raw_sqlcipher(db_path, cfg.key)
|
||||
cur = conn.cursor()
|
||||
cur.execute("CREATE TABLE entries(date TEXT PRIMARY KEY, content TEXT NOT NULL);")
|
||||
cur.execute(
|
||||
"INSERT INTO entries(date, content) VALUES(?, ?);",
|
||||
("2025-01-02", "<p>Hello</p>"),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Now use the real DBManager, which will run _ensure_schema and migrate
|
||||
mgr = DBManager(cfg)
|
||||
assert mgr.connect() is True
|
||||
|
||||
# After migration, legacy table should be gone and content reachable via get_entry
|
||||
text = mgr.get_entry("2025-01-02")
|
||||
assert "Hello" in text
|
||||
|
||||
cur = mgr.conn.cursor()
|
||||
# entries table should be dropped
|
||||
with pytest.raises(sqlite.OperationalError):
|
||||
cur.execute("SELECT count(*) FROM entries;").fetchone()
|
||||
|
||||
# pages & versions exist and head points to v1
|
||||
rows = cur.execute(
|
||||
"SELECT current_version_id FROM pages WHERE date='2025-01-02'"
|
||||
).fetchone()
|
||||
assert rows is not None and rows["current_version_id"] is not None
|
||||
vers = mgr.list_versions("2025-01-02")
|
||||
assert vers and vers[0]["version_no"] == 1 and vers[0]["is_current"] == 1
|
||||
|
||||
|
||||
def test_save_new_version_requires_connection_raises(cfg: DBConfig):
|
||||
mgr = DBManager(cfg)
|
||||
with pytest.raises(RuntimeError):
|
||||
mgr.save_new_version("2025-01-03", "<p>x</p>")
|
||||
|
||||
|
||||
def _bootstrap_db(cfg: DBConfig) -> DBManager:
|
||||
mgr = DBManager(cfg)
|
||||
assert mgr.connect() is True
|
||||
return mgr
|
||||
|
||||
|
||||
def test_revert_to_version_by_number_and_id_and_errors(cfg: DBConfig):
|
||||
mgr = _bootstrap_db(cfg)
|
||||
# Create two versions for the same date
|
||||
ver1_id, ver1_no = mgr.save_new_version("2025-01-04", "<p>v1</p>", note="init")
|
||||
ver2_id, ver2_no = mgr.save_new_version("2025-01-04", "<p>v2</p>", note="edit")
|
||||
assert ver1_no == 1 and ver2_no == 2
|
||||
|
||||
# Revert using version_id
|
||||
mgr.revert_to_version(date_iso="2025-01-04", version_id=ver2_id)
|
||||
cur = mgr.conn.cursor()
|
||||
head2 = cur.execute(
|
||||
"SELECT current_version_id FROM pages WHERE date=?", ("2025-01-04",)
|
||||
).fetchone()[0]
|
||||
assert head2 == ver2_id
|
||||
|
||||
# Error: version_id belongs to a different date
|
||||
other_id, _ = mgr.save_new_version("2025-01-05", "<p>other</p>")
|
||||
with pytest.raises(ValueError):
|
||||
mgr.revert_to_version(date_iso="2025-01-04", version_id=other_id)
|
||||
|
||||
|
||||
def test_export_by_extension_and_compact(cfg: DBConfig, tmp_path: Path):
|
||||
mgr = _bootstrap_db(cfg)
|
||||
# Seed a couple of entries
|
||||
mgr.save_new_version("2025-01-06", "<p>A</p>")
|
||||
mgr.save_new_version("2025-01-07", "<p>B</p>")
|
||||
|
||||
# Prepare output files
|
||||
out = tmp_path
|
||||
exts = [
|
||||
".json",
|
||||
".csv",
|
||||
".txt",
|
||||
".html",
|
||||
".sql",
|
||||
] # exclude .md due to different signature
|
||||
for ext in exts:
|
||||
path = out / f"export{ext}"
|
||||
mgr.export_by_extension(str(path))
|
||||
assert path.exists() and path.stat().st_size > 0
|
||||
|
||||
# Markdown export uses a different signature (entries + path)
|
||||
entries = mgr.get_all_entries()
|
||||
md_path = out / "export.md"
|
||||
mgr.export_markdown(entries, str(md_path))
|
||||
assert md_path.exists() and md_path.stat().st_size > 0
|
||||
|
||||
# Run VACUUM path
|
||||
mgr.compact() # should not raise
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
import bouquin.db as dbmod
|
||||
from bouquin.db import DBConfig, DBManager
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
def __init__(self, rows=None):
|
||||
self._rows = rows or []
|
||||
self.executed = []
|
||||
|
||||
def execute(self, sql, params=None):
|
||||
self.executed.append((sql, tuple(params) if params else None))
|
||||
return self
|
||||
|
||||
def fetchall(self):
|
||||
return list(self._rows)
|
||||
|
||||
def fetchone(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
|
||||
class FakeConn:
|
||||
def __init__(self, rows=None):
|
||||
self._rows = rows or []
|
||||
self.closed = False
|
||||
self.cursors = []
|
||||
self.row_factory = None
|
||||
|
||||
def cursor(self):
|
||||
c = FakeCursor(rows=self._rows)
|
||||
self.cursors.append(c)
|
||||
return c
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
def commit(self):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
def test_integrity_ok_ok(monkeypatch, tmp_path):
|
||||
mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x"))
|
||||
mgr.conn = FakeConn(rows=[])
|
||||
assert mgr._integrity_ok() is None
|
||||
|
||||
|
||||
def test_integrity_ok_raises(monkeypatch, tmp_path):
|
||||
mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x"))
|
||||
mgr.conn = FakeConn(rows=[("oops",), (None,)])
|
||||
try:
|
||||
mgr._integrity_ok()
|
||||
except Exception as e:
|
||||
assert isinstance(e, dbmod.sqlite.IntegrityError)
|
||||
|
||||
|
||||
def test_connect_closes_on_integrity_failure(monkeypatch, tmp_path):
|
||||
# Use a non-empty key to avoid SQLCipher complaining before our patch runs
|
||||
mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x"))
|
||||
# Make the integrity check raise so connect() takes the failure path
|
||||
monkeypatch.setattr(
|
||||
DBManager,
|
||||
"_integrity_ok",
|
||||
lambda self: (_ for _ in ()).throw(RuntimeError("bad")),
|
||||
)
|
||||
ok = mgr.connect()
|
||||
assert ok is False
|
||||
assert mgr.conn is None
|
||||
|
||||
|
||||
def test_rekey_not_connected_raises(tmp_path):
|
||||
mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="old"))
|
||||
mgr.conn = None
|
||||
import pytest
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
mgr.rekey("new")
|
||||
|
||||
|
||||
def test_rekey_reopen_failure(monkeypatch, tmp_path):
|
||||
mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="old"))
|
||||
mgr.conn = FakeConn(rows=[(None,)])
|
||||
monkeypatch.setattr(DBManager, "connect", lambda self: False)
|
||||
import pytest
|
||||
|
||||
with pytest.raises(Exception):
|
||||
mgr.rekey("new")
|
||||
|
||||
|
||||
def test_export_by_extension_and_unknown(tmp_path):
|
||||
mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x"))
|
||||
entries = [("2025-01-01", "<b>Hi</b>")]
|
||||
# Test each exporter writes the file
|
||||
p = tmp_path / "out.json"
|
||||
mgr.export_json(entries, str(p))
|
||||
assert p.exists() and p.stat().st_size > 0
|
||||
p = tmp_path / "out.csv"
|
||||
mgr.export_csv(entries, str(p))
|
||||
assert p.exists()
|
||||
p = tmp_path / "out.txt"
|
||||
mgr.export_txt(entries, str(p))
|
||||
assert p.exists()
|
||||
p = tmp_path / "out.html"
|
||||
mgr.export_html(entries, str(p))
|
||||
assert p.exists()
|
||||
p = tmp_path / "out.md"
|
||||
mgr.export_markdown(entries, str(p))
|
||||
assert p.exists()
|
||||
# Router
|
||||
import types
|
||||
|
||||
mgr.get_all_entries = types.MethodType(lambda self: entries, mgr)
|
||||
for ext in [".json", ".csv", ".txt", ".html", ".md"]:
|
||||
path = tmp_path / f"route{ext}"
|
||||
mgr.export_by_extension(str(path))
|
||||
assert path.exists()
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
mgr.export_by_extension(str(tmp_path / "x.zzz"))
|
||||
|
||||
|
||||
def test_compact_error_prints(monkeypatch, tmp_path, capsys):
|
||||
mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x"))
|
||||
|
||||
class BadConn:
|
||||
def cursor(self):
|
||||
raise RuntimeError("no")
|
||||
|
||||
mgr.conn = BadConn()
|
||||
mgr.compact()
|
||||
out = capsys.readouterr().out
|
||||
assert "Error:" in out
|
||||
|
|
@ -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)
|
||||
|
|
@ -1,339 +0,0 @@
|
|||
from PySide6.QtCore import Qt, QMimeData, QPoint, QUrl
|
||||
from PySide6.QtGui import QImage, QMouseEvent, QKeyEvent, QTextCursor, QTextImageFormat
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from bouquin.editor import Editor
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def _mk_editor() -> Editor:
|
||||
# pytest-qt ensures a QApplication exists
|
||||
app = QApplication.instance()
|
||||
tm = ThemeManager(app, ThemeConfig())
|
||||
return Editor(tm)
|
||||
|
||||
|
||||
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 _fmt_at(editor: Editor, pos: int):
|
||||
c = editor.textCursor()
|
||||
c.setPosition(pos)
|
||||
c.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 1)
|
||||
return c.charFormat()
|
||||
|
||||
|
||||
def test_space_breaks_link_anchor_and_styling(qtbot):
|
||||
e = _mk_editor()
|
||||
e.resize(600, 300)
|
||||
e.show()
|
||||
qtbot.waitExposed(e)
|
||||
|
||||
# Type a URL, which should be linkified (anchor + underline + blue)
|
||||
url = "https://mig5.net"
|
||||
QTest.keyClicks(e, url)
|
||||
qtbot.waitUntil(lambda: e.toPlainText() == url)
|
||||
|
||||
# Sanity: characters within the URL are anchors
|
||||
for i in range(len(url)):
|
||||
assert _fmt_at(e, i).isAnchor()
|
||||
|
||||
# Hit Space – Editor.keyPressEvent() should call _break_anchor_for_next_char()
|
||||
QTest.keyClick(e, Qt.Key_Space)
|
||||
|
||||
# Type some normal text; it must not inherit the link formatting
|
||||
tail = "this is a test"
|
||||
QTest.keyClicks(e, tail)
|
||||
qtbot.waitUntil(lambda: e.toPlainText().endswith(tail))
|
||||
|
||||
txt = e.toPlainText()
|
||||
# Find where our 'tail' starts
|
||||
start = txt.index(tail)
|
||||
end = start + len(tail)
|
||||
|
||||
# None of the trailing characters should be part of an anchor or visually underlined
|
||||
for i in range(start, end):
|
||||
fmt = _fmt_at(e, i)
|
||||
assert not fmt.isAnchor(), f"char {i} unexpectedly still has an anchor"
|
||||
assert not fmt.fontUnderline(), f"char {i} unexpectedly still underlined"
|
||||
|
||||
# Optional: ensure the HTML only wraps the URL in <a>, not the trailing text
|
||||
html = e.document().toHtml()
|
||||
assert re.search(
|
||||
r'<a [^>]*href="https?://mig5\.net"[^>]*>(?:<span[^>]*>)?https?://mig5\.net(?:</span>)?</a>\s+this is a test',
|
||||
html,
|
||||
re.S,
|
||||
), html
|
||||
assert "this is a test</a>" not in html
|
||||
|
||||
|
||||
def test_embed_qimage_saved_as_data_url(qtbot):
|
||||
e = _mk_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 = _mk_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 = _mk_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_code_block_enter_exits_on_empty_line(qtbot):
|
||||
|
||||
e = _mk_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._nearest_code_frame(e.textCursor(), tolerant=False) is not None
|
||||
and e.textCursor().block().length() == 1
|
||||
)
|
||||
|
||||
# Second Enter should jump *out* of the frame
|
||||
QTest.keyClick(e, Qt.Key_Return)
|
||||
|
||||
|
||||
class DummyMenu:
|
||||
def __init__(self):
|
||||
self.seps = 0
|
||||
self.subs = []
|
||||
self.exec_called = False
|
||||
|
||||
def addSeparator(self):
|
||||
self.seps += 1
|
||||
|
||||
def addMenu(self, title):
|
||||
m = DummyMenu()
|
||||
self.subs.append((title, m))
|
||||
return m
|
||||
|
||||
def addAction(self, *a, **k):
|
||||
pass
|
||||
|
||||
def exec(self, *a, **k):
|
||||
self.exec_called = True
|
||||
|
||||
|
||||
def _themes():
|
||||
app = QApplication.instance()
|
||||
return ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
|
||||
|
||||
def test_context_menu_adds_image_actions(monkeypatch, qtbot):
|
||||
e = Editor(_themes())
|
||||
qtbot.addWidget(e)
|
||||
# Fake an image at cursor
|
||||
qi = QImage(10, 10, QImage.Format_ARGB32)
|
||||
qi.fill(0xFF00FF00)
|
||||
imgfmt = QTextImageFormat()
|
||||
imgfmt.setName("x")
|
||||
imgfmt.setWidth(10)
|
||||
imgfmt.setHeight(10)
|
||||
tc = e.textCursor()
|
||||
monkeypatch.setattr(e, "_image_info_at_cursor", lambda: (tc, imgfmt, qi))
|
||||
|
||||
dummy = DummyMenu()
|
||||
monkeypatch.setattr(e, "createStandardContextMenu", lambda: dummy)
|
||||
|
||||
class Evt:
|
||||
def globalPos(self):
|
||||
return QPoint(0, 0)
|
||||
|
||||
e.contextMenuEvent(Evt())
|
||||
assert dummy.exec_called
|
||||
assert dummy.seps == 1
|
||||
assert any(t == "Image size" for t, _ in dummy.subs)
|
||||
|
||||
|
||||
def test_insert_from_mime_image_and_urls(tmp_path, qtbot):
|
||||
e = Editor(_themes())
|
||||
qtbot.addWidget(e)
|
||||
# Build a mime with an image
|
||||
mime = QMimeData()
|
||||
img = QImage(6, 6, QImage.Format_ARGB32)
|
||||
img.fill(0xFF0000FF)
|
||||
mime.setImageData(img)
|
||||
e.insertFromMimeData(mime)
|
||||
html = e.document().toHtml()
|
||||
assert "<img" in html
|
||||
|
||||
# Now with urls: local non-image + local image + remote url
|
||||
png = tmp_path / "t.png"
|
||||
img.save(str(png))
|
||||
txt = tmp_path / "x.txt"
|
||||
txt.write_text("hi", encoding="utf-8")
|
||||
mime2 = QMimeData()
|
||||
mime2.setUrls(
|
||||
[
|
||||
QUrl.fromLocalFile(str(txt)),
|
||||
QUrl.fromLocalFile(str(png)),
|
||||
QUrl("https://example.com/file"),
|
||||
]
|
||||
)
|
||||
e.insertFromMimeData(mime2)
|
||||
h2 = e.document().toHtml()
|
||||
assert 'href="file://' in h2 # local file link inserted
|
||||
assert "<img" in h2 # image inserted
|
||||
assert 'href="https://example.com/file"' in h2 # remote url link
|
||||
|
||||
|
||||
def test_mouse_release_ctrl_click_opens(monkeypatch, qtbot):
|
||||
e = Editor(_themes())
|
||||
qtbot.addWidget(e)
|
||||
# Anchor under cursor
|
||||
monkeypatch.setattr(e, "anchorAt", lambda p: "https://example.com")
|
||||
opened = {}
|
||||
from PySide6.QtGui import QDesktopServices as DS
|
||||
|
||||
monkeypatch.setattr(
|
||||
DS, "openUrl", lambda url: opened.setdefault("u", url.toString())
|
||||
)
|
||||
ev = QMouseEvent(
|
||||
QMouseEvent.MouseButtonRelease,
|
||||
QPoint(1, 1),
|
||||
Qt.LeftButton,
|
||||
Qt.LeftButton,
|
||||
Qt.ControlModifier,
|
||||
)
|
||||
e.mouseReleaseEvent(ev)
|
||||
assert opened.get("u") == "https://example.com"
|
||||
|
||||
|
||||
def test_keypress_space_breaks_anchor(monkeypatch, qtbot):
|
||||
e = Editor(_themes())
|
||||
qtbot.addWidget(e)
|
||||
called = {}
|
||||
monkeypatch.setattr(
|
||||
e, "_break_anchor_for_next_char", lambda: called.setdefault("x", True)
|
||||
)
|
||||
ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Space, Qt.NoModifier, " ")
|
||||
e.keyPressEvent(ev)
|
||||
assert called.get("x") is True
|
||||
|
||||
|
||||
def test_enter_leaves_code_frame(qtbot):
|
||||
e = Editor(_themes())
|
||||
qtbot.addWidget(e)
|
||||
e.setPlainText("")
|
||||
# Insert a code block frame
|
||||
e.apply_code()
|
||||
# Place cursor inside the empty code block
|
||||
c = e.textCursor()
|
||||
c.movePosition(QTextCursor.End)
|
||||
e.setTextCursor(c)
|
||||
# Press Enter; should jump outside the frame and start normal paragraph
|
||||
ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier)
|
||||
e.keyPressEvent(ev)
|
||||
# After enter, the cursor should not be inside a code frame
|
||||
assert e._nearest_code_frame(e.textCursor(), tolerant=False) is None
|
||||
|
||||
|
||||
def test_space_does_not_bleed_anchor_format(qtbot):
|
||||
e = _mk_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_editor_small_helpers(qtbot):
|
||||
app = QApplication.instance()
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
e = Editor(themes)
|
||||
qtbot.addWidget(e)
|
||||
# _approx returns True when |a-b| <= eps
|
||||
assert e._approx(1.0, 1.25, eps=0.3) is True
|
||||
assert e._approx(1.0, 1.6, eps=0.3) is False
|
||||
# Exercise helpers
|
||||
_ = e._is_heading_typing()
|
||||
e._apply_normal_typing()
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
import base64
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt, QMimeData, QByteArray
|
||||
from PySide6.QtGui import QImage, QTextCursor
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from PySide6.QtTest import QTest
|
||||
|
||||
from bouquin.editor import Editor
|
||||
from bouquin.theme import ThemeManager, ThemeConfig
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
a = QApplication.instance()
|
||||
if a is None:
|
||||
a = QApplication([])
|
||||
return a
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def editor(app, qtbot):
|
||||
themes = ThemeManager(app, ThemeConfig())
|
||||
e = Editor(themes)
|
||||
qtbot.addWidget(e)
|
||||
e.show()
|
||||
return e
|
||||
|
||||
|
||||
def test_todo_prefix_converts_to_checkbox_on_space(editor):
|
||||
editor.clear()
|
||||
editor.setPlainText("TODO")
|
||||
c = editor.textCursor()
|
||||
c.movePosition(QTextCursor.End)
|
||||
editor.setTextCursor(c)
|
||||
QTest.keyClick(editor, Qt.Key_Space)
|
||||
# Now the line should start with the checkbox glyph and a space
|
||||
assert editor.toPlainText().startswith("☐ ")
|
||||
|
||||
|
||||
def test_enter_inside_empty_code_frame_jumps_out(editor):
|
||||
editor.clear()
|
||||
editor.setPlainText("") # single empty block
|
||||
# Apply code block to current line
|
||||
editor.apply_code()
|
||||
# Cursor is inside the code frame. Press Enter on empty block should jump out.
|
||||
QTest.keyClick(editor, Qt.Key_Return)
|
||||
# We expect two blocks: one code block (with a newline inserted) and then a normal block
|
||||
txt = editor.toPlainText()
|
||||
assert "\n" in txt # a normal paragraph created after exiting the frame
|
||||
|
||||
|
||||
def test_insertFromMimeData_with_data_image(editor):
|
||||
# Build an in-memory PNG and embed as data URL inside HTML
|
||||
img = QImage(8, 8, QImage.Format_ARGB32)
|
||||
img.fill(0xFF00FF00) # green
|
||||
ba = QByteArray()
|
||||
from PySide6.QtCore import QBuffer, QIODevice
|
||||
|
||||
buf = QBuffer(ba)
|
||||
buf.open(QIODevice.WriteOnly)
|
||||
img.save(buf, "PNG")
|
||||
data_b64 = base64.b64encode(bytes(ba)).decode("ascii")
|
||||
html = f'<img src="data:image/png;base64,{data_b64}"/>'
|
||||
|
||||
md = QMimeData()
|
||||
md.setHtml(html)
|
||||
editor.insertFromMimeData(md)
|
||||
|
||||
# HTML export with embedded images should contain a data: URL
|
||||
h = editor.to_html_with_embedded_images()
|
||||
assert "data:image/png;base64," in h
|
||||
|
||||
|
||||
def test_toggle_checkboxes_selection(editor):
|
||||
editor.clear()
|
||||
editor.setPlainText("item 1\nitem 2")
|
||||
# Select both lines
|
||||
c = editor.textCursor()
|
||||
c.movePosition(QTextCursor.Start)
|
||||
c.movePosition(QTextCursor.End, QTextCursor.KeepAnchor)
|
||||
editor.setTextCursor(c)
|
||||
# Toggle on -> inserts ☐
|
||||
editor.toggle_checkboxes()
|
||||
assert editor.toPlainText().startswith("☐ ")
|
||||
# Toggle again -> remove ☐
|
||||
editor.toggle_checkboxes()
|
||||
assert not editor.toPlainText().startswith("☐ ")
|
||||
|
||||
|
||||
def test_heading_then_enter_reverts_to_normal(editor):
|
||||
editor.clear()
|
||||
editor.setPlainText("A heading")
|
||||
# Apply H2 via apply_heading(size=18)
|
||||
editor.apply_heading(18)
|
||||
c = editor.textCursor()
|
||||
c.movePosition(QTextCursor.End)
|
||||
editor.setTextCursor(c)
|
||||
# Press Enter -> new block should be Normal (not bold/large)
|
||||
QTest.keyClick(editor, Qt.Key_Return)
|
||||
# The new block exists
|
||||
txt = editor.toPlainText()
|
||||
assert "\n" in txt
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
from PySide6.QtCore import QUrl
|
||||
from PySide6.QtGui import QImage, QTextCursor, QTextImageFormat, QColor
|
||||
from bouquin.theme import ThemeManager
|
||||
from bouquin.editor import Editor
|
||||
|
||||
|
||||
def _mk_editor(qapp, cfg):
|
||||
themes = ThemeManager(qapp, cfg)
|
||||
ed = Editor(themes)
|
||||
ed.resize(400, 300)
|
||||
return ed
|
||||
|
||||
|
||||
def test_image_scale_and_reset(qapp, cfg):
|
||||
ed = _mk_editor(qapp, cfg)
|
||||
|
||||
# Register an image resource and insert it at the cursor
|
||||
img = QImage(20, 10, QImage.Format_ARGB32)
|
||||
img.fill(QColor(200, 0, 0))
|
||||
url = QUrl("test://img")
|
||||
from PySide6.QtGui import QTextDocument
|
||||
|
||||
ed.document().addResource(QTextDocument.ResourceType.ImageResource, url, img)
|
||||
|
||||
fmt = QTextImageFormat()
|
||||
fmt.setName(url.toString())
|
||||
# No explicit width -> code should use original width
|
||||
tc = ed.textCursor()
|
||||
tc.insertImage(fmt)
|
||||
|
||||
# Place cursor at start (on the image) and scale
|
||||
tc = ed.textCursor()
|
||||
tc.movePosition(QTextCursor.Start)
|
||||
ed.setTextCursor(tc)
|
||||
ed._scale_image_at_cursor(1.5) # increases width
|
||||
ed._reset_image_size() # restores to original width
|
||||
|
||||
# Ensure resulting HTML contains an <img> tag
|
||||
html = ed.toHtml()
|
||||
assert "<img" in html
|
||||
|
||||
|
||||
def test_apply_image_size_fallbacks(qapp, cfg):
|
||||
ed = _mk_editor(qapp, cfg)
|
||||
# Create a dummy image format with no width/height -> fallback branch inside _apply_image_size
|
||||
fmt = QTextImageFormat()
|
||||
fmt.setName("") # no resource available
|
||||
tc = ed.textCursor()
|
||||
# Insert a single character to have a valid cursor
|
||||
tc.insertText("x")
|
||||
tc.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, 1)
|
||||
ed._apply_image_size(tc, fmt, new_w=100.0, orig_img=None) # should not raise
|
||||
|
||||
|
||||
def test_to_html_with_embedded_images_and_link_tint(qapp, cfg):
|
||||
ed = _mk_editor(qapp, cfg)
|
||||
|
||||
# Insert an anchor + image and ensure HTML embedding + retint pass runs
|
||||
img = QImage(8, 8, QImage.Format_ARGB32)
|
||||
img.fill(QColor(0, 200, 0))
|
||||
url = QUrl("test://img2")
|
||||
from PySide6.QtGui import QTextDocument
|
||||
|
||||
ed.document().addResource(QTextDocument.ResourceType.ImageResource, url, img)
|
||||
|
||||
# Compose HTML with a link and an image referencing our resource
|
||||
ed.setHtml(
|
||||
f'<p><a href="http://example.com">link</a></p><p><img src="{url.toString()}"></p>'
|
||||
)
|
||||
|
||||
html = ed.to_html_with_embedded_images()
|
||||
# Embedded data URL should appear for the image
|
||||
assert "data:image" in html
|
||||
# The link should still be present (retinted internally) without crashing
|
||||
assert "example.com" in html
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
from PySide6.QtCore import Qt, QEvent, QUrl, QObject, Slot
|
||||
from PySide6.QtGui import QImage, QMouseEvent, QTextCursor
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from bouquin.editor import Editor
|
||||
from bouquin.theme import ThemeManager, ThemeConfig
|
||||
|
||||
|
||||
def _mk_editor() -> Editor:
|
||||
app = QApplication.instance()
|
||||
tm = ThemeManager(app, ThemeConfig())
|
||||
e = Editor(tm)
|
||||
e.resize(700, 400)
|
||||
e.show()
|
||||
return e
|
||||
|
||||
|
||||
def _point_for_char(e: Editor, pos: int):
|
||||
c = e.textCursor()
|
||||
c.setPosition(pos)
|
||||
r = e.cursorRect(c)
|
||||
return r.center()
|
||||
|
||||
|
||||
def test_trim_url_and_linkify_and_ctrl_mouse(qtbot):
|
||||
e = _mk_editor()
|
||||
qtbot.addWidget(e)
|
||||
assert e._trim_url_end("https://ex.com)") == "https://ex.com"
|
||||
assert e._trim_url_end("www.mysite.org]") == "www.mysite.org"
|
||||
|
||||
url = "https://example.org/path"
|
||||
QTest.keyClicks(e, url)
|
||||
qtbot.waitUntil(lambda: url in e.toPlainText())
|
||||
|
||||
p = _point_for_char(e, 0)
|
||||
move = QMouseEvent(
|
||||
QEvent.MouseMove, p, Qt.NoButton, Qt.NoButton, Qt.ControlModifier
|
||||
)
|
||||
e.mouseMoveEvent(move)
|
||||
assert e.viewport().cursor().shape() == Qt.PointingHandCursor
|
||||
|
||||
opened = {}
|
||||
|
||||
class Catcher(QObject):
|
||||
@Slot(QUrl)
|
||||
def handle(self, u: QUrl):
|
||||
opened["u"] = u.toString()
|
||||
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
|
||||
catcher = Catcher()
|
||||
QDesktopServices.setUrlHandler("https", catcher, "handle")
|
||||
try:
|
||||
rel = QMouseEvent(
|
||||
QEvent.MouseButtonRelease,
|
||||
p,
|
||||
Qt.LeftButton,
|
||||
Qt.LeftButton,
|
||||
Qt.ControlModifier,
|
||||
)
|
||||
e.mouseReleaseEvent(rel)
|
||||
got_signal = []
|
||||
e.linkActivated.connect(lambda href: got_signal.append(href))
|
||||
e.mouseReleaseEvent(rel)
|
||||
assert opened or got_signal
|
||||
finally:
|
||||
QDesktopServices.unsetUrlHandler("https")
|
||||
|
||||
|
||||
def test_insert_images_and_image_helpers(qtbot, tmp_path):
|
||||
e = _mk_editor()
|
||||
qtbot.addWidget(e)
|
||||
|
||||
# No image under cursor yet (412 guard)
|
||||
tc, fmt, orig = e._image_info_at_cursor()
|
||||
assert tc is None and fmt is None and orig is None
|
||||
|
||||
# Insert a real image file (574–584 path)
|
||||
img_path = tmp_path / "tiny.png"
|
||||
img = QImage(4, 4, QImage.Format_ARGB32)
|
||||
img.fill(0xFF336699)
|
||||
assert img.save(str(img_path), "PNG")
|
||||
e.insert_images([str(img_path)], autoscale=False)
|
||||
assert "<img" in e.toHtml()
|
||||
|
||||
# Guards when not on an image (453, 464)
|
||||
e._scale_image_at_cursor(1.1)
|
||||
e._fit_image_to_editor_width()
|
||||
|
||||
|
||||
def test_checkbox_click_and_enter_continuation(qtbot):
|
||||
e = _mk_editor()
|
||||
qtbot.addWidget(e)
|
||||
e.setPlainText("☐ task one")
|
||||
|
||||
# Need it visible for mouse coords
|
||||
e.resize(600, 300)
|
||||
e.show()
|
||||
qtbot.waitExposed(e)
|
||||
|
||||
# Click on the checkbox glyph to toggle (605–614)
|
||||
start_point = _point_for_char(e, 0)
|
||||
press = QMouseEvent(
|
||||
QEvent.MouseButtonPress,
|
||||
start_point,
|
||||
Qt.LeftButton,
|
||||
Qt.LeftButton,
|
||||
Qt.NoModifier,
|
||||
)
|
||||
e.mousePressEvent(press)
|
||||
assert e.toPlainText().startswith("☑ ")
|
||||
|
||||
# Press Enter at end -> new line with fresh checkbox (680–684)
|
||||
c = e.textCursor()
|
||||
c.movePosition(QTextCursor.End)
|
||||
e.setTextCursor(c)
|
||||
QTest.keyClick(e, Qt.Key_Return)
|
||||
lines = e.toPlainText().splitlines()
|
||||
assert len(lines) >= 2 and lines[1].startswith("☐ ")
|
||||
|
||||
|
||||
def test_heading_and_lists_toggle_remove(qtbot):
|
||||
e = _mk_editor()
|
||||
qtbot.addWidget(e)
|
||||
e.setPlainText("para")
|
||||
|
||||
# "Normal" path is size=0 (904…)
|
||||
e.apply_heading(0)
|
||||
|
||||
# bullets twice -> second call removes (945–946)
|
||||
e.toggle_bullets()
|
||||
e.toggle_bullets()
|
||||
# numbers twice -> second call removes (955–956)
|
||||
e.toggle_numbers()
|
||||
e.toggle_numbers()
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
import importlib
|
||||
|
||||
|
||||
def test___main___exports_main():
|
||||
entry_mod = importlib.import_module("bouquin.__main__")
|
||||
main_mod = importlib.import_module("bouquin.main")
|
||||
assert entry_mod.main is main_mod.main
|
||||
|
||||
|
||||
def test_main_entry_initializes_qt(monkeypatch):
|
||||
main_mod = importlib.import_module("bouquin.main")
|
||||
|
||||
# Fakes to avoid real Qt event loop
|
||||
class FakeApp:
|
||||
def __init__(self, argv):
|
||||
self.argv = argv
|
||||
self.name = None
|
||||
self.org = None
|
||||
|
||||
def setApplicationName(self, n):
|
||||
self.name = n
|
||||
|
||||
def setOrganizationName(self, n):
|
||||
self.org = n
|
||||
|
||||
def exec(self):
|
||||
return 0
|
||||
|
||||
class FakeWin:
|
||||
def __init__(self, themes=None):
|
||||
self.themes = themes
|
||||
self.shown = False
|
||||
|
||||
def show(self):
|
||||
self.shown = True
|
||||
|
||||
class FakeThemes:
|
||||
def __init__(self, app, cfg):
|
||||
self._applied = None
|
||||
self.app = app
|
||||
self.cfg = cfg
|
||||
|
||||
def apply(self, t):
|
||||
self._applied = t
|
||||
|
||||
class FakeSettings:
|
||||
def __init__(self):
|
||||
self._map = {"ui/theme": "dark"}
|
||||
|
||||
def value(self, k, default=None, type=None):
|
||||
return self._map.get(k, default)
|
||||
|
||||
def fake_get_settings():
|
||||
return FakeSettings()
|
||||
|
||||
monkeypatch.setattr(main_mod, "QApplication", FakeApp)
|
||||
monkeypatch.setattr(main_mod, "MainWindow", FakeWin)
|
||||
monkeypatch.setattr(main_mod, "ThemeManager", FakeThemes)
|
||||
monkeypatch.setattr(main_mod, "get_settings", fake_get_settings)
|
||||
|
||||
exits = {}
|
||||
|
||||
def fake_exit(code):
|
||||
exits["code"] = code
|
||||
|
||||
monkeypatch.setattr(main_mod.sys, "exit", fake_exit)
|
||||
|
||||
main_mod.main()
|
||||
assert exits.get("code", None) == 0
|
||||
|
|
@ -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 <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()
|
||||
|
|
@ -1,100 +1,57 @@
|
|||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QKeySequence, QTextCursor
|
||||
from PySide6.QtTest import QTest
|
||||
import pytest
|
||||
|
||||
from tests.qt_helpers import trigger_menu_action
|
||||
from PySide6.QtGui import QTextCursor
|
||||
from bouquin.markdown_editor import MarkdownEditor
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
|
||||
|
||||
def _cursor_info(editor):
|
||||
"""Return (start, end, selectedText) for the current selection."""
|
||||
tc: QTextCursor = editor.textCursor()
|
||||
start = min(tc.anchor(), tc.position())
|
||||
end = max(tc.anchor(), tc.position())
|
||||
return start, end, tc.selectedText()
|
||||
@pytest.fixture
|
||||
def editor(app, qtbot):
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
ed = MarkdownEditor(themes)
|
||||
qtbot.addWidget(ed)
|
||||
ed.show()
|
||||
return ed
|
||||
|
||||
|
||||
def test_find_actions_and_shortcuts(open_window, qtbot):
|
||||
win = open_window
|
||||
|
||||
# Actions should be present under Navigate and advertise canonical shortcuts
|
||||
act_find = trigger_menu_action(win, "Find on page")
|
||||
assert act_find.shortcut().matches(QKeySequence.Find) == QKeySequence.ExactMatch
|
||||
|
||||
act_next = trigger_menu_action(win, "Find Next")
|
||||
assert act_next.shortcut().matches(QKeySequence.FindNext) == QKeySequence.ExactMatch
|
||||
|
||||
act_prev = trigger_menu_action(win, "Find Previous")
|
||||
assert (
|
||||
act_prev.shortcut().matches(QKeySequence.FindPrevious)
|
||||
== QKeySequence.ExactMatch
|
||||
)
|
||||
|
||||
# "Find on page" should open the bar and focus the input
|
||||
act_find.trigger()
|
||||
qtbot.waitUntil(lambda: win.findBar.isVisible())
|
||||
qtbot.waitUntil(lambda: win.findBar.edit.hasFocus())
|
||||
from bouquin.find_bar import FindBar
|
||||
|
||||
|
||||
def test_find_navigate_case_sensitive_and_close_focus(open_window, qtbot):
|
||||
win = open_window
|
||||
@pytest.mark.gui
|
||||
def test_findbar_basic_navigation(qtbot, editor):
|
||||
editor.from_markdown("alpha\nbeta\nalpha\nGamma\n")
|
||||
editor.moveCursor(QTextCursor.Start)
|
||||
|
||||
# Mixed-case content with three matches
|
||||
text = "alpha … ALPHA … alpha"
|
||||
win.editor.setPlainText(text)
|
||||
qtbot.waitUntil(lambda: win.editor.toPlainText() == text)
|
||||
fb = FindBar(editor, parent=editor)
|
||||
qtbot.addWidget(fb)
|
||||
fb.show_bar()
|
||||
fb.edit.setText("alpha")
|
||||
fb.find_next()
|
||||
pos1 = editor.textCursor().position()
|
||||
fb.find_next()
|
||||
pos2 = editor.textCursor().position()
|
||||
assert pos2 > pos1
|
||||
|
||||
# Open the find bar from the menu
|
||||
trigger_menu_action(win, "Find on page").trigger()
|
||||
qtbot.waitUntil(lambda: win.findBar.isVisible())
|
||||
win.findBar.edit.clear()
|
||||
QTest.keyClicks(win.findBar.edit, "alpha")
|
||||
fb.find_prev()
|
||||
pos3 = editor.textCursor().position()
|
||||
assert pos3 <= pos2
|
||||
|
||||
# 1) First hit (case-insensitive default)
|
||||
QTest.keyClick(win.findBar.edit, Qt.Key_Return)
|
||||
qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection())
|
||||
s0, e0, sel0 = _cursor_info(win.editor)
|
||||
assert sel0.lower() == "alpha"
|
||||
fb.case.setChecked(True)
|
||||
fb.refresh()
|
||||
fb.hide_bar()
|
||||
|
||||
# 2) Next → uppercase ALPHA (case-insensitive)
|
||||
trigger_menu_action(win, "Find Next").trigger()
|
||||
qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection())
|
||||
s1, e1, sel1 = _cursor_info(win.editor)
|
||||
assert sel1.upper() == "ALPHA"
|
||||
|
||||
# 3) Next → the *other* lowercase "alpha"
|
||||
trigger_menu_action(win, "Find Next").trigger()
|
||||
qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection())
|
||||
s2, e2, sel2 = _cursor_info(win.editor)
|
||||
assert sel2.lower() == "alpha"
|
||||
# Ensure we didn't wrap back to the very first "alpha"
|
||||
assert s2 != s0
|
||||
def test_show_bar_seeds_selection(qtbot, editor):
|
||||
from PySide6.QtGui import QTextCursor
|
||||
|
||||
# 4) Case-sensitive: skip ALPHA and only hit lowercase
|
||||
win.findBar.case.setChecked(True)
|
||||
# Put the caret at start to make the next search deterministic
|
||||
tc = win.editor.textCursor()
|
||||
tc.setPosition(0)
|
||||
win.editor.setTextCursor(tc)
|
||||
editor.from_markdown("alpha beta")
|
||||
c = editor.textCursor()
|
||||
c.movePosition(QTextCursor.Start)
|
||||
c.movePosition(QTextCursor.NextWord, QTextCursor.KeepAnchor)
|
||||
editor.setTextCursor(c)
|
||||
|
||||
win.findBar.find_next()
|
||||
qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection())
|
||||
s_cs1, e_cs1, sel_cs1 = _cursor_info(win.editor)
|
||||
assert sel_cs1 == "alpha"
|
||||
|
||||
win.findBar.find_next()
|
||||
qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection())
|
||||
s_cs2, e_cs2, sel_cs2 = _cursor_info(win.editor)
|
||||
assert sel_cs2 == "alpha"
|
||||
assert s_cs2 != s_cs1 # it's the other lowercase match
|
||||
|
||||
# 5) Previous goes back to the earlier lowercase match
|
||||
win.findBar.find_prev()
|
||||
qtbot.waitUntil(lambda: win.editor.textCursor().hasSelection())
|
||||
s_prev, e_prev, sel_prev = _cursor_info(win.editor)
|
||||
assert sel_prev == "alpha"
|
||||
assert s_prev == s_cs1
|
||||
|
||||
# 6) Close returns focus to editor
|
||||
win.findBar.closeBtn.click()
|
||||
qtbot.waitUntil(lambda: not win.findBar.isVisible())
|
||||
qtbot.waitUntil(lambda: win.editor.hasFocus())
|
||||
fb = FindBar(editor, parent=editor)
|
||||
qtbot.addWidget(fb)
|
||||
fb.show_bar()
|
||||
assert fb.edit.text().lower() == "alpha"
|
||||
fb.hide_bar()
|
||||
|
|
|
|||
19
tests/test_history_dialog.py
Normal file
19
tests/test_history_dialog.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from PySide6.QtWidgets import QWidget
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from bouquin.history_dialog import HistoryDialog
|
||||
|
||||
|
||||
def test_history_dialog_lists_and_revert(qtbot, fresh_db):
|
||||
d = "2001-01-01"
|
||||
fresh_db.save_new_version(d, "v1", "first")
|
||||
fresh_db.save_new_version(d, "v2", "second")
|
||||
|
||||
w = QWidget()
|
||||
dlg = HistoryDialog(fresh_db, d, parent=w)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
|
||||
dlg.list.setCurrentRow(1)
|
||||
qtbot.mouseClick(dlg.btn_revert, Qt.LeftButton)
|
||||
assert fresh_db.get_entry(d) == "v1"
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import pytest
|
||||
from PySide6.QtWidgets import QApplication, QListWidgetItem
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from bouquin.db import DBConfig, DBManager
|
||||
from bouquin.history_dialog import HistoryDialog
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
a = QApplication.instance()
|
||||
if a is None:
|
||||
a = QApplication([])
|
||||
return a
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
cfg = DBConfig(path=tmp_path / "h.db", key="k")
|
||||
db = DBManager(cfg)
|
||||
assert db.connect()
|
||||
# Seed two versions for a date
|
||||
db.save_new_version("2025-02-10", "<p>v1</p>", note="v1", set_current=True)
|
||||
db.save_new_version("2025-02-10", "<p>v2</p>", note="v2", set_current=True)
|
||||
return db
|
||||
|
||||
|
||||
def test_revert_early_returns(app, db, qtbot):
|
||||
dlg = HistoryDialog(db, date_iso="2025-02-10")
|
||||
qtbot.addWidget(dlg)
|
||||
|
||||
# (1) No current item -> returns immediately
|
||||
dlg.list.setCurrentItem(None)
|
||||
dlg._revert() # should not crash and should not accept
|
||||
|
||||
# (2) Selecting the current item -> still returns early
|
||||
# Build an item with the *current* id as payload
|
||||
cur_id = next(v["id"] for v in db.list_versions("2025-02-10") if v["is_current"])
|
||||
it = QListWidgetItem("current")
|
||||
it.setData(Qt.UserRole, cur_id)
|
||||
dlg.list.addItem(it)
|
||||
dlg.list.setCurrentItem(it)
|
||||
dlg._revert() # should return early (no accept called)
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
from PySide6.QtWidgets import QListWidgetItem
|
||||
from PySide6.QtCore import Qt
|
||||
from bouquin.history_dialog import HistoryDialog
|
||||
|
||||
|
||||
class FakeDB:
|
||||
def __init__(self):
|
||||
self.fail_revert = False
|
||||
|
||||
def list_versions(self, date_iso):
|
||||
# Simulate two versions; mark second as current
|
||||
return [
|
||||
{
|
||||
"id": 1,
|
||||
"version_no": 1,
|
||||
"created_at": "2025-01-01T10:00:00Z",
|
||||
"note": None,
|
||||
"is_current": False,
|
||||
"content": "<p>a</p>",
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"version_no": 2,
|
||||
"created_at": "2025-01-02T10:00:00Z",
|
||||
"note": None,
|
||||
"is_current": True,
|
||||
"content": "<p>b</p>",
|
||||
},
|
||||
]
|
||||
|
||||
def get_version(self, version_id):
|
||||
if version_id == 2:
|
||||
return {"content": "<p>b</p>"}
|
||||
return {"content": "<p>a</p>"}
|
||||
|
||||
def revert_to_version(self, date, version_id=None, version_no=None):
|
||||
if self.fail_revert:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
|
||||
def test_on_select_no_item(qtbot):
|
||||
dlg = HistoryDialog(FakeDB(), "2025-01-01")
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.list.clear()
|
||||
dlg._on_select()
|
||||
|
||||
|
||||
def test_revert_failure_shows_critical(qtbot, monkeypatch):
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
fake = FakeDB()
|
||||
fake.fail_revert = True
|
||||
dlg = HistoryDialog(fake, "2025-01-01")
|
||||
qtbot.addWidget(dlg)
|
||||
item = QListWidgetItem("v1")
|
||||
item.setData(Qt.UserRole, 1) # different from current 2
|
||||
dlg.list.addItem(item)
|
||||
dlg.list.setCurrentItem(item)
|
||||
msgs = {}
|
||||
|
||||
def fake_crit(parent, title, text):
|
||||
msgs["t"] = (title, text)
|
||||
|
||||
monkeypatch.setattr(QMessageBox, "critical", staticmethod(fake_crit))
|
||||
dlg._revert()
|
||||
assert "Revert failed" in msgs["t"][0]
|
||||
9
tests/test_key_prompt.py
Normal file
9
tests/test_key_prompt.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
from bouquin.key_prompt import KeyPrompt
|
||||
|
||||
|
||||
def test_key_prompt_roundtrip(qtbot):
|
||||
kp = KeyPrompt()
|
||||
qtbot.addWidget(kp)
|
||||
kp.show()
|
||||
kp.edit.setText("swordfish")
|
||||
assert kp.key() == "swordfish"
|
||||
18
tests/test_lock_overlay.py
Normal file
18
tests/test_lock_overlay.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import pytest
|
||||
from PySide6.QtCore import QEvent
|
||||
from PySide6.QtWidgets import QWidget
|
||||
from bouquin.lock_overlay import LockOverlay
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_lock_overlay_reacts_to_theme(qtbot):
|
||||
host = QWidget()
|
||||
qtbot.addWidget(host)
|
||||
host.show()
|
||||
|
||||
ol = LockOverlay(host, on_unlock=lambda: None)
|
||||
qtbot.addWidget(ol)
|
||||
ol.show()
|
||||
|
||||
ev = QEvent(QEvent.Type.PaletteChange)
|
||||
ol.changeEvent(ev)
|
||||
11
tests/test_main.py
Normal file
11
tests/test_main.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import importlib
|
||||
|
||||
|
||||
def test_main_module_has_main():
|
||||
m = importlib.import_module("bouquin.main")
|
||||
assert hasattr(m, "main")
|
||||
|
||||
|
||||
def test_dunder_main_imports_main():
|
||||
m = importlib.import_module("bouquin.__main__")
|
||||
assert hasattr(m, "main")
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import runpy
|
||||
import types
|
||||
import sys
|
||||
|
||||
|
||||
def test_dunder_main_executes_without_launching_qt(monkeypatch):
|
||||
# Replace bouquin.main with a stub that records invocation and returns immediately
|
||||
calls = {"called": False}
|
||||
mod = types.SimpleNamespace(main=lambda: calls.__setitem__("called", True))
|
||||
monkeypatch.setitem(sys.modules, "bouquin.main", mod)
|
||||
|
||||
# Running the module as __main__ should call mod.main() but not start a Qt loop
|
||||
runpy.run_module("bouquin.__main__", run_name="__main__")
|
||||
assert calls["called"] is True
|
||||
79
tests/test_main_window.py
Normal file
79
tests/test_main_window.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import pytest
|
||||
from PySide6.QtCore import QDate
|
||||
|
||||
from bouquin.main_window import MainWindow
|
||||
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||
from bouquin.settings import get_settings
|
||||
from bouquin.key_prompt import KeyPrompt
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db):
|
||||
s = get_settings()
|
||||
s.setValue("db/path", str(tmp_db_cfg.path))
|
||||
s.setValue("db/key", tmp_db_cfg.key)
|
||||
s.setValue("ui/idle_minutes", 0)
|
||||
s.setValue("ui/theme", "light")
|
||||
s.setValue("ui/move_todos", True)
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
w = MainWindow(themes=themes)
|
||||
qtbot.addWidget(w)
|
||||
w.show()
|
||||
|
||||
date = QDate.currentDate().toString("yyyy-MM-dd")
|
||||
w._load_selected_date(date)
|
||||
w.editor.from_markdown("hello **world**")
|
||||
w._on_text_changed()
|
||||
qtbot.wait(5500) # let the 5s autosave QTimer fire
|
||||
assert "world" in fresh_db.get_entry(date)
|
||||
|
||||
w.search.search.setText("world")
|
||||
qtbot.wait(50)
|
||||
assert not w.search.results.isHidden()
|
||||
|
||||
w._sync_toolbar()
|
||||
w._adjust_day(-1) # previous day
|
||||
w._adjust_day(+1) # next day
|
||||
|
||||
# Auto-accept the unlock KeyPrompt with the correct key
|
||||
def _auto_accept_keyprompt():
|
||||
for wdg in QApplication.topLevelWidgets():
|
||||
if isinstance(wdg, KeyPrompt):
|
||||
wdg.edit.setText(tmp_db_cfg.key)
|
||||
wdg.accept()
|
||||
|
||||
w._enter_lock()
|
||||
QTimer.singleShot(0, _auto_accept_keyprompt)
|
||||
w._on_unlock_clicked()
|
||||
qtbot.wait(50) # let the nested event loop process the acceptance
|
||||
|
||||
|
||||
def test_load_yesterday_todos_moves_items(qtbot, app, tmp_db_cfg, fresh_db):
|
||||
from PySide6.QtCore import QDate
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
from bouquin.settings import get_settings
|
||||
|
||||
s = get_settings()
|
||||
s.setValue("db/path", str(tmp_db_cfg.path))
|
||||
s.setValue("db/key", tmp_db_cfg.key)
|
||||
s.setValue("ui/move_todos", True)
|
||||
s.setValue("ui/theme", "light")
|
||||
|
||||
y = QDate.currentDate().addDays(-1).toString("yyyy-MM-dd")
|
||||
fresh_db.save_new_version(y, "- [ ] carry me\n- [x] done", "seed")
|
||||
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
from bouquin.main_window import MainWindow
|
||||
|
||||
w = MainWindow(themes=themes)
|
||||
qtbot.addWidget(w)
|
||||
w.show()
|
||||
|
||||
w._load_yesterday_todos()
|
||||
|
||||
assert "carry me" in w.editor.to_markdown()
|
||||
y_txt = fresh_db.get_entry(y)
|
||||
assert "carry me" not in y_txt or "- [ ]" not in y_txt
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
from PySide6.QtCore import QDate
|
||||
from bouquin.theme import ThemeManager
|
||||
from bouquin.main_window import MainWindow
|
||||
from bouquin.settings import save_db_config
|
||||
from bouquin.db import DBManager
|
||||
|
||||
|
||||
def _bootstrap_window(qapp, cfg):
|
||||
# Ensure DB exists and key is valid in settings
|
||||
mgr = DBManager(cfg)
|
||||
assert mgr.connect() is True
|
||||
save_db_config(cfg)
|
||||
|
||||
themes = ThemeManager(qapp, cfg)
|
||||
win = MainWindow(themes)
|
||||
# Force an initial selected date
|
||||
win.calendar.setSelectedDate(QDate.currentDate())
|
||||
return win
|
||||
|
||||
|
||||
def test_move_todos_copies_unchecked(qapp, cfg, tmp_path):
|
||||
cfg.move_todos = True
|
||||
win = _bootstrap_window(qapp, cfg)
|
||||
|
||||
# Seed yesterday with both checked and unchecked items using the same HTML pattern the app expects
|
||||
y = QDate.currentDate().addDays(-1).toString("yyyy-MM-dd")
|
||||
html = (
|
||||
"<p><span>☐</span> Unchecked 1</p>"
|
||||
"<p><span>☑</span> Checked 1</p>"
|
||||
"<p><span>☐</span> Unchecked 2</p>"
|
||||
)
|
||||
win.db.save_new_version(y, html)
|
||||
|
||||
# Ensure today starts blank
|
||||
today_iso = QDate.currentDate().toString("yyyy-MM-dd")
|
||||
win.editor.setHtml("<p></p>")
|
||||
_html = win.editor.toHtml()
|
||||
win.db.save_new_version(today_iso, _html)
|
||||
|
||||
# Invoke the move-todos logic
|
||||
win._load_yesterday_todos()
|
||||
|
||||
# Verify today's entry now contains only the unchecked items
|
||||
txt = win.db.get_entry(today_iso)
|
||||
assert "Unchecked 1" in txt and "Unchecked 2" in txt and "Checked 1" not in txt
|
||||
|
||||
|
||||
def test_adjust_and_save_paths(qapp, cfg):
|
||||
win = _bootstrap_window(qapp, cfg)
|
||||
|
||||
# Move date selection and jump to today
|
||||
before = win.calendar.selectedDate()
|
||||
win._adjust_day(-1)
|
||||
assert win.calendar.selectedDate().toString("yyyy-MM-dd") != before.toString(
|
||||
"yyyy-MM-dd"
|
||||
)
|
||||
win._adjust_today()
|
||||
assert win.calendar.selectedDate() == QDate.currentDate()
|
||||
|
||||
# Save path exercises success feedback + dirty flag reset
|
||||
win.editor.setHtml("<p>content</p>")
|
||||
win._dirty = True
|
||||
win._save_date(QDate.currentDate().toString("yyyy-MM-dd"), explicit=True)
|
||||
assert win._dirty is False
|
||||
|
||||
|
||||
def test_restore_window_position(qapp, cfg, tmp_path):
|
||||
win = _bootstrap_window(qapp, cfg)
|
||||
|
||||
# Save geometry/state into settings and restore it (covers maximize singleShot branch too)
|
||||
geom = win.saveGeometry()
|
||||
state = win.saveState()
|
||||
s = win.settings
|
||||
s.setValue("ui/geometry", geom)
|
||||
s.setValue("ui/window_state", state)
|
||||
s.sync()
|
||||
|
||||
win._restore_window_position() # should restore without error
|
||||
|
||||
|
||||
def test_idle_lock_unlock_flow(qapp, cfg):
|
||||
win = _bootstrap_window(qapp, cfg)
|
||||
|
||||
# Enter lock
|
||||
win._enter_lock()
|
||||
assert getattr(win, "_locked", False) is True
|
||||
|
||||
# Disabling idle minutes should unlock and hide overlay
|
||||
win._apply_idle_minutes(0)
|
||||
assert getattr(win, "_locked", False) is False
|
||||
63
tests/test_markdown_editor.py
Normal file
63
tests/test_markdown_editor.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import pytest
|
||||
|
||||
from PySide6.QtGui import QImage, QColor
|
||||
from bouquin.markdown_editor import MarkdownEditor
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def editor(app, qtbot):
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
ed = MarkdownEditor(themes)
|
||||
qtbot.addWidget(ed)
|
||||
ed.show()
|
||||
return ed
|
||||
|
||||
|
||||
def test_from_and_to_markdown_roundtrip(editor):
|
||||
md = "# Title\n\nThis is **bold** and _italic_ and ~~strike~~.\n\n- [ ] task\n- [x] done\n\n```\ncode\n```"
|
||||
editor.from_markdown(md)
|
||||
out = editor.to_markdown()
|
||||
assert "Title" in out and "task" in out and "code" in out
|
||||
|
||||
|
||||
def test_apply_styles_and_headings(editor, qtbot):
|
||||
editor.from_markdown("hello world")
|
||||
editor.selectAll()
|
||||
editor.apply_weight()
|
||||
editor.apply_italic()
|
||||
editor.apply_strikethrough()
|
||||
editor.apply_heading(24)
|
||||
md = editor.to_markdown()
|
||||
assert "**" in md and "*~~~~*" in md
|
||||
|
||||
|
||||
def test_toggle_lists_and_checkboxes(editor):
|
||||
editor.from_markdown("item one\nitem two\n")
|
||||
editor.toggle_bullets()
|
||||
assert "- " in editor.to_markdown()
|
||||
editor.toggle_numbers()
|
||||
assert "1. " in editor.to_markdown()
|
||||
editor.toggle_checkboxes()
|
||||
md = editor.to_markdown()
|
||||
assert "- [ ]" in md or "- [x]" in md
|
||||
|
||||
|
||||
def test_insert_image_from_path(editor, tmp_path):
|
||||
img = tmp_path / "pic.png"
|
||||
qimg = QImage(2, 2, QImage.Format_RGBA8888)
|
||||
qimg.fill(QColor(255, 0, 0))
|
||||
assert qimg.save(str(img)) # ensure a valid PNG on disk
|
||||
|
||||
editor.insert_image_from_path(img)
|
||||
md = editor.to_markdown()
|
||||
# Images are saved as base64 data URIs in markdown
|
||||
assert "data:image/image/png;base64" in md
|
||||
|
||||
|
||||
def test_apply_code_inline(editor):
|
||||
editor.from_markdown("alpha beta")
|
||||
editor.selectAll()
|
||||
editor.apply_code()
|
||||
md = editor.to_markdown()
|
||||
assert ("`" in md) or ("```" in md)
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
from PySide6.QtWidgets import QApplication, QMessageBox
|
||||
from bouquin.main_window import MainWindow
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
from bouquin.db import DBConfig
|
||||
|
||||
|
||||
def _themes_light():
|
||||
app = QApplication.instance()
|
||||
return ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
|
||||
|
||||
def _themes_dark():
|
||||
app = QApplication.instance()
|
||||
return ThemeManager(app, ThemeConfig(theme=Theme.DARK))
|
||||
|
||||
|
||||
class FakeDBErr:
|
||||
def __init__(self, cfg):
|
||||
pass
|
||||
|
||||
def connect(self):
|
||||
raise Exception("file is not a database")
|
||||
|
||||
|
||||
class FakeDBOk:
|
||||
def __init__(self, cfg):
|
||||
pass
|
||||
|
||||
def connect(self):
|
||||
return True
|
||||
|
||||
def save_new_version(self, date, text, note):
|
||||
raise RuntimeError("nope")
|
||||
|
||||
def get_entry(self, date):
|
||||
return "<p>hi</p>"
|
||||
|
||||
def get_entries_days(self):
|
||||
return []
|
||||
|
||||
|
||||
def test_try_connect_sqlcipher_error(monkeypatch, qtbot, tmp_path):
|
||||
# Config with a key so __init__ calls _try_connect immediately
|
||||
cfg = DBConfig(tmp_path / "db.sqlite", key="x")
|
||||
(tmp_path / "db.sqlite").write_text("", encoding="utf-8")
|
||||
monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg)
|
||||
monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBErr)
|
||||
msgs = {}
|
||||
monkeypatch.setattr(
|
||||
QMessageBox, "critical", staticmethod(lambda p, t, m: msgs.setdefault("m", m))
|
||||
)
|
||||
w = MainWindow(_themes_light()) # auto-calls _try_connect
|
||||
qtbot.addWidget(w)
|
||||
assert "incorrect" in msgs.get("m", "").lower()
|
||||
|
||||
|
||||
def test_apply_link_css_dark(qtbot, monkeypatch, tmp_path):
|
||||
cfg = DBConfig(tmp_path / "db.sqlite", key="x")
|
||||
(tmp_path / "db.sqlite").write_text("", encoding="utf-8")
|
||||
monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg)
|
||||
monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBOk)
|
||||
w = MainWindow(_themes_dark())
|
||||
qtbot.addWidget(w)
|
||||
w._apply_link_css()
|
||||
css = w.editor.document().defaultStyleSheet()
|
||||
assert "a {" in css
|
||||
|
||||
|
||||
def test_restore_window_position_first_run(qtbot, monkeypatch, tmp_path):
|
||||
cfg = DBConfig(tmp_path / "db.sqlite", key="x")
|
||||
(tmp_path / "db.sqlite").write_text("", encoding="utf-8")
|
||||
monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg)
|
||||
monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBOk)
|
||||
w = MainWindow(_themes_light())
|
||||
qtbot.addWidget(w)
|
||||
called = {}
|
||||
|
||||
class FakeSettings:
|
||||
def value(self, key, default=None, type=None):
|
||||
if key == "main/geometry":
|
||||
return None
|
||||
if key == "main/windowState":
|
||||
return None
|
||||
if key == "main/maximized":
|
||||
return False
|
||||
return default
|
||||
|
||||
w.settings = FakeSettings()
|
||||
monkeypatch.setattr(
|
||||
w, "_move_to_cursor_screen_center", lambda: called.setdefault("x", True)
|
||||
)
|
||||
w._restore_window_position()
|
||||
assert called.get("x") is True
|
||||
|
||||
|
||||
def test_on_insert_image_calls_editor(qtbot, monkeypatch, tmp_path):
|
||||
cfg = DBConfig(tmp_path / "db.sqlite", key="x")
|
||||
(tmp_path / "db.sqlite").write_text("", encoding="utf-8")
|
||||
monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg)
|
||||
monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBOk)
|
||||
w = MainWindow(_themes_light())
|
||||
qtbot.addWidget(w)
|
||||
captured = {}
|
||||
monkeypatch.setattr(
|
||||
w.editor, "insert_images", lambda paths: captured.setdefault("p", paths)
|
||||
)
|
||||
# Simulate file dialog returning paths
|
||||
monkeypatch.setattr(
|
||||
"bouquin.main_window.QFileDialog.getOpenFileNames",
|
||||
staticmethod(lambda *a, **k: (["/tmp/a.png", "/tmp/b.jpg"], "Images")),
|
||||
)
|
||||
w._on_insert_image()
|
||||
assert captured.get("p") == ["/tmp/a.png", "/tmp/b.jpg"]
|
||||
8
tests/test_save_dialog.py
Normal file
8
tests/test_save_dialog.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from bouquin.save_dialog import SaveDialog
|
||||
|
||||
|
||||
def test_save_dialog_note_text(qtbot):
|
||||
dlg = SaveDialog()
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
assert dlg.note_text()
|
||||
22
tests/test_search.py
Normal file
22
tests/test_search.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
from bouquin.search import Search
|
||||
|
||||
|
||||
def test_search_widget_populates_results(qtbot, fresh_db):
|
||||
fresh_db.save_new_version("2024-01-01", "alpha bravo", "seed")
|
||||
fresh_db.save_new_version("2024-01-02", "bravo charlie", "seed")
|
||||
fresh_db.save_new_version("2024-01-03", "delta alpha bravo", "seed")
|
||||
|
||||
s = Search(fresh_db)
|
||||
qtbot.addWidget(s)
|
||||
s.show()
|
||||
|
||||
emitted = []
|
||||
s.resultDatesChanged.connect(lambda ds: emitted.append(tuple(ds)))
|
||||
s.search.setText("alpha")
|
||||
qtbot.wait(50)
|
||||
assert s.results.count() >= 2
|
||||
assert emitted and {"2024-01-01", "2024-01-03"}.issubset(set(emitted[-1]))
|
||||
|
||||
s.search.setText("")
|
||||
qtbot.wait(50)
|
||||
assert s.results.isHidden()
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
from bouquin.search import Search as SearchWidget
|
||||
|
||||
|
||||
class DummyDB:
|
||||
def search_entries(self, q):
|
||||
return []
|
||||
|
||||
|
||||
def test_make_html_snippet_no_match_triggers_start_window(qtbot):
|
||||
w = SearchWidget(db=DummyDB())
|
||||
qtbot.addWidget(w)
|
||||
html = "<p>" + ("x" * 300) + "</p>" # long text, no token present
|
||||
frag, left, right = w._make_html_snippet(html, "notfound", radius=10, maxlen=80)
|
||||
assert frag != ""
|
||||
assert left is False and right is True
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
from PySide6.QtWidgets import QApplication
|
||||
import pytest
|
||||
|
||||
from bouquin.db import DBConfig, DBManager
|
||||
from bouquin.search import Search
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
# Ensure a single QApplication exists
|
||||
a = QApplication.instance()
|
||||
if a is None:
|
||||
a = QApplication([])
|
||||
yield a
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_db(tmp_path):
|
||||
cfg = DBConfig(path=tmp_path / "test.db", key="testkey")
|
||||
db = DBManager(cfg)
|
||||
assert db.connect() is True
|
||||
# Seed a couple of entries
|
||||
db.save_new_version("2025-01-01", "<p>Hello world first day</p>")
|
||||
db.save_new_version(
|
||||
"2025-01-02", "<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>"
|
||||
)
|
||||
db.save_new_version(
|
||||
"2025-01-03",
|
||||
"<p>Long content begins "
|
||||
+ ("x" * 200)
|
||||
+ " middle token here "
|
||||
+ ("y" * 200)
|
||||
+ " ends.</p>",
|
||||
)
|
||||
return db
|
||||
|
||||
|
||||
def test_search_exception_path_closed_db_triggers_quiet_handling(app, fresh_db, qtbot):
|
||||
# Close the DB to provoke an exception inside Search._search
|
||||
fresh_db.close()
|
||||
w = Search(fresh_db)
|
||||
w.show()
|
||||
qtbot.addWidget(w)
|
||||
|
||||
# Typing should not raise; exception path returns empty results
|
||||
w._search("anything")
|
||||
assert w.results.isHidden() # remains hidden because there are no rows
|
||||
|
||||
|
||||
def test_make_html_snippet_ellipses_both_sides(app, fresh_db):
|
||||
w = Search(fresh_db)
|
||||
# Choose a query so that the first match sits well inside a long string,
|
||||
# forcing both left and right ellipses.
|
||||
html = fresh_db.get_entry("2025-01-03")
|
||||
snippet, left_ell, right_ell = w._make_html_snippet(html, "middle")
|
||||
assert snippet # non-empty
|
||||
assert left_ell is True
|
||||
assert right_ell is True
|
||||
|
||||
|
||||
def test_search_results_middle(app, fresh_db, qtbot):
|
||||
w = Search(fresh_db)
|
||||
w.show()
|
||||
qtbot.addWidget(w)
|
||||
# Choose a query so that the first match sits well inside a long string,
|
||||
# forcing both left and right ellipses.
|
||||
assert fresh_db.connect()
|
||||
|
||||
w._search("middle")
|
||||
assert w.results.isVisible()
|
||||
11
tests/test_search_helpers.py
Normal file
11
tests/test_search_helpers.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from bouquin.search import Search
|
||||
|
||||
|
||||
def test_make_html_snippet_and_strip_markdown(qtbot, fresh_db):
|
||||
s = Search(fresh_db)
|
||||
long = (
|
||||
"This is **bold** text with alpha in the middle and some more trailing content."
|
||||
)
|
||||
frag, left, right = s._make_html_snippet(long, "alpha", radius=10, maxlen=40)
|
||||
assert "alpha" in frag
|
||||
s._strip_markdown("**bold** _italic_ ~~strike~~ 1. item - [x] check")
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
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()
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QListWidgetItem
|
||||
|
||||
# The widget class is named `Search` in bouquin.search
|
||||
from bouquin.search import Search as SearchWidget
|
||||
|
||||
|
||||
class FakeDB:
|
||||
def __init__(self, rows):
|
||||
self.rows = rows
|
||||
|
||||
def search_entries(self, q):
|
||||
return list(self.rows)
|
||||
|
||||
|
||||
def test_search_empty_clears_and_hides(qtbot):
|
||||
w = SearchWidget(db=FakeDB([]))
|
||||
qtbot.addWidget(w)
|
||||
w.show()
|
||||
qtbot.waitExposed(w)
|
||||
dates = []
|
||||
w.resultDatesChanged.connect(lambda ds: dates.extend(ds))
|
||||
w._search(" ")
|
||||
assert w.results.isHidden()
|
||||
assert dates == []
|
||||
|
||||
|
||||
def test_populate_empty_hides(qtbot):
|
||||
w = SearchWidget(db=FakeDB([]))
|
||||
qtbot.addWidget(w)
|
||||
w._populate_results("x", [])
|
||||
assert w.results.isHidden()
|
||||
|
||||
|
||||
def test_open_selected_emits_when_present(qtbot):
|
||||
w = SearchWidget(db=FakeDB([]))
|
||||
qtbot.addWidget(w)
|
||||
got = {}
|
||||
w.openDateRequested.connect(lambda d: got.setdefault("d", d))
|
||||
it = QListWidgetItem("x")
|
||||
it.setData(Qt.ItemDataRole.UserRole, "")
|
||||
w._open_selected(it)
|
||||
assert "d" not in got
|
||||
it.setData(Qt.ItemDataRole.UserRole, "2025-01-02")
|
||||
w._open_selected(it)
|
||||
assert got["d"] == "2025-01-02"
|
||||
|
||||
|
||||
def test_make_html_snippet_edge_cases(qtbot):
|
||||
w = SearchWidget(db=FakeDB([]))
|
||||
qtbot.addWidget(w)
|
||||
# Empty HTML -> empty fragment, no ellipses
|
||||
frag, l, r = w._make_html_snippet("", "hello")
|
||||
assert frag == "" and not l and not r
|
||||
# Small doc around token -> should not show ellipses
|
||||
frag, l, r = w._make_html_snippet("<p>Hello world</p>", "world")
|
||||
assert "<b>world</b>" in frag or "world" in frag
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import pytest
|
||||
from bouquin.search import Search
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def search_widget(qapp):
|
||||
# We don't need a real DB for snippet generation – pass None
|
||||
return Search(db=None)
|
||||
|
||||
|
||||
def test_make_html_snippet_empty(search_widget: Search):
|
||||
html = ""
|
||||
frag, has_prev, has_next = search_widget._make_html_snippet(
|
||||
html, "", radius=10, maxlen=20
|
||||
)
|
||||
assert frag == "" and has_prev is False and has_next is False
|
||||
|
||||
|
||||
def test_make_html_snippet_phrase_preferred(search_widget: Search):
|
||||
html = "<p>Alpha beta gamma delta</p>"
|
||||
frag, has_prev, has_next = search_widget._make_html_snippet(
|
||||
html, "beta gamma", radius=1, maxlen=10
|
||||
)
|
||||
# We expect a window that includes the phrase and has previous text
|
||||
assert "beta" in frag and "gamma" in frag
|
||||
assert has_prev is True
|
||||
|
||||
|
||||
def test_make_html_snippet_token_fallback_and_window_flags(search_widget: Search):
|
||||
html = "<p>One two three four five six seven eight nine ten eleven twelve</p>"
|
||||
# Use tokens such that the phrase doesn't exist, but individual tokens do
|
||||
frag, has_prev, has_next = search_widget._make_html_snippet(
|
||||
html, "eleven two", radius=3, maxlen=20
|
||||
)
|
||||
assert "two" in frag
|
||||
# The snippet should be a slice within the text (has more following content)
|
||||
assert has_next is True
|
||||
36
tests/test_settings.py
Normal file
36
tests/test_settings.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
from pathlib import Path
|
||||
from bouquin.settings import (
|
||||
default_db_path,
|
||||
get_settings,
|
||||
load_db_config,
|
||||
save_db_config,
|
||||
)
|
||||
from bouquin.db import DBConfig
|
||||
|
||||
|
||||
def test_default_db_path_returns_writable_path(app, tmp_path):
|
||||
p = default_db_path()
|
||||
assert isinstance(p, Path)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def test_load_and_save_db_config_roundtrip(app, tmp_path):
|
||||
s = get_settings()
|
||||
for k in ["db/path", "db/key", "ui/idle_minutes", "ui/theme", "ui/move_todos"]:
|
||||
s.remove(k)
|
||||
|
||||
cfg = DBConfig(
|
||||
path=tmp_path / "notes.db",
|
||||
key="abc123",
|
||||
idle_minutes=7,
|
||||
theme="dark",
|
||||
move_todos=True,
|
||||
)
|
||||
save_db_config(cfg)
|
||||
|
||||
loaded = load_db_config()
|
||||
assert loaded.path == cfg.path
|
||||
assert loaded.key == cfg.key
|
||||
assert loaded.idle_minutes == cfg.idle_minutes
|
||||
assert loaded.theme == cfg.theme
|
||||
assert loaded.move_todos == cfg.move_todos
|
||||
|
|
@ -1,296 +1,180 @@
|
|||
from pathlib import Path
|
||||
|
||||
from PySide6.QtWidgets import QDialog, QFileDialog, QMessageBox, QWidget
|
||||
|
||||
from bouquin.db import DBConfig
|
||||
import pytest
|
||||
from bouquin.settings_dialog import SettingsDialog
|
||||
from bouquin.theme import Theme
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox, QWidget
|
||||
|
||||
|
||||
class _ThemeSpy:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
@pytest.mark.gui
|
||||
def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db, tmp_path):
|
||||
# Provide a parent that exposes a real ThemeManager (dialog calls parent().themes.set(...))
|
||||
app = QApplication.instance()
|
||||
parent = QWidget()
|
||||
parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
dlg = SettingsDialog(tmp_db_cfg, fresh_db, parent=parent)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
|
||||
def set(self, t):
|
||||
self.calls.append(t)
|
||||
dlg.path_edit.setText(str(tmp_path / "alt.db"))
|
||||
dlg.idle_spin.setValue(3)
|
||||
dlg.theme_light.setChecked(True)
|
||||
dlg.move_todos.setChecked(True)
|
||||
|
||||
# Auto-accept the modal QMessageBox that _compact_btn_clicked() shows
|
||||
def _auto_accept_msgbox():
|
||||
for w in QApplication.topLevelWidgets():
|
||||
if isinstance(w, QMessageBox):
|
||||
w.accept()
|
||||
|
||||
QTimer.singleShot(0, _auto_accept_msgbox)
|
||||
dlg._compact_btn_clicked()
|
||||
qtbot.wait(50)
|
||||
|
||||
dlg._save()
|
||||
cfg = dlg.config
|
||||
assert cfg.path.name == "alt.db"
|
||||
assert cfg.idle_minutes == 3
|
||||
assert cfg.theme in ("light", "dark", "system")
|
||||
|
||||
|
||||
class _Parent(QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.themes = _ThemeSpy()
|
||||
def test_save_key_toggle_roundtrip(qtbot, tmp_db_cfg, fresh_db, app):
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox
|
||||
from bouquin.key_prompt import KeyPrompt
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
parent = QWidget()
|
||||
parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
|
||||
dlg = SettingsDialog(tmp_db_cfg, fresh_db, parent=parent)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
|
||||
# Ensure a clean starting state (suite may leave settings toggled on)
|
||||
dlg.save_key_btn.setChecked(False)
|
||||
dlg.key = ""
|
||||
|
||||
# Robust popup pump so we never miss late dialogs
|
||||
def _pump():
|
||||
for w in QApplication.topLevelWidgets():
|
||||
if isinstance(w, KeyPrompt):
|
||||
w.edit.setText("supersecret")
|
||||
w.accept()
|
||||
elif isinstance(w, QMessageBox):
|
||||
w.accept()
|
||||
|
||||
timer = QTimer()
|
||||
timer.setInterval(10)
|
||||
timer.timeout.connect(_pump)
|
||||
timer.start()
|
||||
try:
|
||||
dlg.save_key_btn.setChecked(True)
|
||||
qtbot.waitUntil(lambda: dlg.key == "supersecret", timeout=1000)
|
||||
assert dlg.save_key_btn.isChecked()
|
||||
|
||||
dlg.save_key_btn.setChecked(False)
|
||||
qtbot.waitUntil(lambda: dlg.key == "", timeout=1000)
|
||||
assert dlg.key == ""
|
||||
finally:
|
||||
timer.stop()
|
||||
|
||||
|
||||
class FakeDB:
|
||||
def __init__(self):
|
||||
self.rekey_called_with = None
|
||||
self.compact_called = False
|
||||
self.fail_compact = False
|
||||
def test_change_key_mismatch_shows_error(qtbot, tmp_db_cfg, tmp_path, app):
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import QApplication, QMessageBox, QWidget
|
||||
from bouquin.key_prompt import KeyPrompt
|
||||
from bouquin.db import DBManager, DBConfig
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
|
||||
def rekey(self, key: str):
|
||||
self.rekey_called_with = key
|
||||
cfg = DBConfig(
|
||||
path=tmp_path / "iso.db",
|
||||
key="oldkey",
|
||||
idle_minutes=0,
|
||||
theme="light",
|
||||
move_todos=True,
|
||||
)
|
||||
db = DBManager(cfg)
|
||||
assert db.connect()
|
||||
db.save_new_version("2000-01-01", "seed", "seed")
|
||||
|
||||
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)
|
||||
|
||||
# Provide a lightweight parent that mimics MainWindow’s `themes` API
|
||||
class _ThemeSpy:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def set(self, theme):
|
||||
self.calls.append(theme)
|
||||
|
||||
class _Parent(QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.themes = _ThemeSpy()
|
||||
|
||||
parent = _Parent()
|
||||
qtbot.addWidget(parent)
|
||||
parent = QWidget()
|
||||
parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
dlg = SettingsDialog(cfg, db, parent=parent)
|
||||
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)
|
||||
keys = ["one", "two"]
|
||||
|
||||
# User toggles "Remember key" -> stores prompted key
|
||||
dlg.save_key_btn.setChecked(True)
|
||||
def _pump_popups():
|
||||
for w in QApplication.topLevelWidgets():
|
||||
if isinstance(w, KeyPrompt):
|
||||
w.edit.setText(keys.pop(0) if keys else "zzz")
|
||||
w.accept()
|
||||
elif isinstance(w, QMessageBox):
|
||||
w.accept()
|
||||
|
||||
dlg._save()
|
||||
|
||||
out = saved["cfg"]
|
||||
assert out.path == new_path
|
||||
assert out.idle_minutes == 0
|
||||
assert out.key == "sekrit"
|
||||
assert parent.themes.calls and parent.themes.calls[-1] == Theme.SYSTEM
|
||||
timer = QTimer()
|
||||
timer.setInterval(10)
|
||||
timer.timeout.connect(_pump_popups)
|
||||
timer.start()
|
||||
try:
|
||||
dlg._change_key()
|
||||
finally:
|
||||
timer.stop()
|
||||
db.close()
|
||||
db2 = DBManager(cfg)
|
||||
assert db2.connect()
|
||||
db2.close()
|
||||
|
||||
|
||||
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)
|
||||
def test_change_key_success(qtbot, tmp_path, app):
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import QApplication, QWidget, QMessageBox
|
||||
from bouquin.key_prompt import KeyPrompt
|
||||
from bouquin.db import DBManager, DBConfig
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
|
||||
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),
|
||||
cfg = DBConfig(
|
||||
path=tmp_path / "iso2.db",
|
||||
key="oldkey",
|
||||
idle_minutes=0,
|
||||
theme="light",
|
||||
move_todos=True,
|
||||
)
|
||||
db = DBManager(cfg)
|
||||
assert db.connect()
|
||||
db.save_new_version("2001-01-01", "seed", "seed")
|
||||
|
||||
db = FakeDB()
|
||||
dlg = SettingsDialog(DBConfig(Path("x"), key=""), db)
|
||||
parent = QWidget()
|
||||
parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
dlg = SettingsDialog(cfg, db, parent=parent)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.show()
|
||||
qtbot.waitExposed(dlg)
|
||||
|
||||
dlg._change_key()
|
||||
keys = ["newkey", "newkey"]
|
||||
|
||||
assert db.rekey_called_with == "newkey"
|
||||
assert shown["info"] >= 1
|
||||
assert dlg.key == "newkey"
|
||||
def _pump():
|
||||
for w in QApplication.topLevelWidgets():
|
||||
if isinstance(w, KeyPrompt):
|
||||
w.edit.setText(keys.pop(0) if keys else "newkey")
|
||||
w.accept()
|
||||
elif isinstance(w, QMessageBox):
|
||||
w.accept()
|
||||
|
||||
timer = QTimer()
|
||||
timer.setInterval(10)
|
||||
timer.timeout.connect(_pump)
|
||||
timer.start()
|
||||
try:
|
||||
dlg._change_key()
|
||||
finally:
|
||||
timer.stop()
|
||||
qtbot.wait(50)
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def test_save_unchecked_clears_key_and_applies_theme(qtbot, tmp_path):
|
||||
parent = _Parent()
|
||||
qtbot.addWidget(parent)
|
||||
cfg = DBConfig(tmp_path / "db.sqlite", key="sekrit", idle_minutes=5)
|
||||
dlg = SettingsDialog(cfg, FakeDB(), parent=parent)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.save_key_btn.setChecked(False)
|
||||
# Trigger save
|
||||
dlg._save()
|
||||
assert dlg.config.key == "" # cleared
|
||||
assert parent.themes.calls # applied some theme
|
||||
db.close()
|
||||
cfg.key = "newkey"
|
||||
db2 = DBManager(cfg)
|
||||
assert db2.connect()
|
||||
assert "seed" in db2.get_entry("2001-01-01")
|
||||
db2.close()
|
||||
|
|
|
|||
|
|
@ -1,111 +0,0 @@
|
|||
import pytest
|
||||
from PySide6.QtWidgets import QApplication, QDialog, QWidget
|
||||
|
||||
from bouquin.db import DBConfig, DBManager
|
||||
from bouquin.settings_dialog import SettingsDialog
|
||||
from bouquin.settings import APP_NAME, APP_ORG
|
||||
from bouquin.key_prompt import KeyPrompt
|
||||
from bouquin.theme import Theme, ThemeManager, ThemeConfig
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
a = QApplication.instance()
|
||||
if a is None:
|
||||
a = QApplication([])
|
||||
a.setApplicationName(APP_NAME)
|
||||
a.setOrganizationName(APP_ORG)
|
||||
return a
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
cfg = DBConfig(path=tmp_path / "s.db", key="abc")
|
||||
m = DBManager(cfg)
|
||||
assert m.connect()
|
||||
return m
|
||||
|
||||
|
||||
def test_theme_radio_initialisation_dark_and_light(app, db, monkeypatch, qtbot):
|
||||
# Dark preselection
|
||||
parent = _ParentWithThemes(app)
|
||||
qtbot.addWidget(parent)
|
||||
dlg = SettingsDialog(db.cfg, db, parent=parent)
|
||||
qtbot.addWidget(dlg)
|
||||
dlg.theme_dark.setChecked(True)
|
||||
dlg._save()
|
||||
assert dlg.config.theme == Theme.DARK.value
|
||||
|
||||
# Light preselection
|
||||
parent2 = _ParentWithThemes(app)
|
||||
qtbot.addWidget(parent2)
|
||||
dlg2 = SettingsDialog(db.cfg, db, parent=parent2)
|
||||
qtbot.addWidget(dlg2)
|
||||
dlg2.theme_light.setChecked(True)
|
||||
dlg2._save()
|
||||
assert dlg2.config.theme == Theme.LIGHT.value
|
||||
|
||||
|
||||
def test_change_key_cancel_branches(app, db, monkeypatch, qtbot):
|
||||
parent = _ParentWithThemes(app)
|
||||
qtbot.addWidget(parent)
|
||||
dlg = SettingsDialog(db.cfg, db, parent=parent)
|
||||
qtbot.addWidget(dlg)
|
||||
|
||||
# First prompt cancelled -> early return
|
||||
monkeypatch.setattr(KeyPrompt, "exec", lambda self: QDialog.Rejected)
|
||||
dlg._change_key() # should just return without altering key
|
||||
assert dlg.key == ""
|
||||
|
||||
# First OK, second cancelled -> early return at the second branch
|
||||
state = {"calls": 0}
|
||||
|
||||
def _exec(self):
|
||||
state["calls"] += 1
|
||||
return QDialog.Accepted if state["calls"] == 1 else QDialog.Rejected
|
||||
|
||||
monkeypatch.setattr(KeyPrompt, "exec", _exec)
|
||||
# Also monkeypatch to control key() values
|
||||
monkeypatch.setattr(KeyPrompt, "key", lambda self: "new-secret")
|
||||
dlg._change_key()
|
||||
# Because the second prompt was rejected, key should remain unchanged
|
||||
assert dlg.key == ""
|
||||
|
||||
|
||||
def test_save_key_checkbox_cancel_restores_checkbox(app, db, monkeypatch, qtbot):
|
||||
parent = _ParentWithThemes(app)
|
||||
qtbot.addWidget(parent)
|
||||
dlg = SettingsDialog(db.cfg, db, parent=parent)
|
||||
qtbot.addWidget(dlg)
|
||||
qtbot.addWidget(dlg)
|
||||
|
||||
# Simulate user checking the box, but cancelling the prompt -> code unchecks it again
|
||||
monkeypatch.setattr(KeyPrompt, "exec", lambda self: QDialog.Rejected)
|
||||
dlg.save_key_btn.setChecked(True)
|
||||
# The slot toggled should run and revert it to unchecked
|
||||
assert dlg.save_key_btn.isChecked() is False
|
||||
|
||||
|
||||
def test_change_key_exception_path(app, db, monkeypatch, qtbot):
|
||||
parent = _ParentWithThemes(app)
|
||||
qtbot.addWidget(parent)
|
||||
dlg = SettingsDialog(db.cfg, db, parent=parent)
|
||||
qtbot.addWidget(dlg)
|
||||
|
||||
# Accept both prompts and supply a key
|
||||
monkeypatch.setattr(KeyPrompt, "exec", lambda self: QDialog.Accepted)
|
||||
monkeypatch.setattr(KeyPrompt, "key", lambda self: "boom")
|
||||
|
||||
# Force DB rekey to raise to exercise the except-branch
|
||||
monkeypatch.setattr(
|
||||
db, "rekey", lambda new_key: (_ for _ in ()).throw(RuntimeError("fail"))
|
||||
)
|
||||
|
||||
# Should not raise; error is handled internally
|
||||
dlg._change_key()
|
||||
|
||||
|
||||
class _ParentWithThemes(QWidget):
|
||||
def __init__(self, app):
|
||||
super().__init__()
|
||||
self.themes = ThemeManager(app, ThemeConfig())
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
from bouquin.db import DBConfig
|
||||
import bouquin.settings as settings
|
||||
|
||||
|
||||
class FakeSettings:
|
||||
def __init__(self):
|
||||
self.store = {}
|
||||
|
||||
def value(self, key, default=None, type=None):
|
||||
return self.store.get(key, default)
|
||||
|
||||
def setValue(self, key, value):
|
||||
self.store[key] = value
|
||||
|
||||
|
||||
def test_save_and_load_db_config_roundtrip(monkeypatch, tmp_path):
|
||||
fake = FakeSettings()
|
||||
monkeypatch.setattr(settings, "get_settings", lambda: fake)
|
||||
|
||||
cfg = DBConfig(path=tmp_path / "db.sqlite", key="k", idle_minutes=7, theme="dark")
|
||||
settings.save_db_config(cfg)
|
||||
|
||||
# Now read back into a new DBConfig
|
||||
cfg2 = settings.load_db_config()
|
||||
assert cfg2.path == cfg.path
|
||||
assert cfg2.key == "k"
|
||||
assert cfg2.idle_minutes == "7"
|
||||
assert cfg2.theme == "dark"
|
||||
21
tests/test_theme.py
Normal file
21
tests/test_theme.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import pytest
|
||||
from PySide6.QtGui import QPalette
|
||||
|
||||
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||
|
||||
|
||||
def test_theme_manager_apply_light_and_dark(app):
|
||||
cfg = ThemeConfig(theme=Theme.LIGHT)
|
||||
mgr = ThemeManager(app, cfg)
|
||||
mgr.apply(Theme.LIGHT)
|
||||
assert isinstance(app.palette(), QPalette)
|
||||
|
||||
mgr.set(Theme.DARK)
|
||||
assert isinstance(app.palette(), QPalette)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_theme_manager_system_roundtrip(app, qtbot):
|
||||
cfg = ThemeConfig(theme=Theme.SYSTEM)
|
||||
mgr = ThemeManager(app, cfg)
|
||||
mgr.apply(cfg.theme)
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
from bouquin.theme import Theme
|
||||
|
||||
|
||||
def test_apply_link_css_dark_theme(open_window, qtbot):
|
||||
win = open_window
|
||||
# Switch to dark and apply link CSS
|
||||
win.themes.set(Theme.DARK)
|
||||
win._apply_link_css()
|
||||
css = win.editor.document().defaultStyleSheet()
|
||||
assert "#FFA500" in css and "a:visited" in css
|
||||
|
||||
|
||||
def test_apply_link_css_light_theme(open_window, qtbot):
|
||||
win = open_window
|
||||
# Switch to light and apply link CSS
|
||||
win.themes.set(Theme.LIGHT)
|
||||
win._apply_link_css()
|
||||
css = win.editor.document().defaultStyleSheet()
|
||||
assert css == "" or "a {" not in css
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
from PySide6.QtWidgets import QApplication
|
||||
from PySide6.QtGui import QPalette, QColor
|
||||
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
|
||||
|
||||
def test_theme_manager_applies_palettes(qtbot):
|
||||
app = QApplication.instance()
|
||||
tm = ThemeManager(app, ThemeConfig())
|
||||
|
||||
# Light palette should set Link to the light blue
|
||||
tm.apply(Theme.LIGHT)
|
||||
pal = app.palette()
|
||||
assert pal.color(QPalette.Link) == QColor("#1a73e8")
|
||||
|
||||
# Dark palette should set Link to lavender-ish
|
||||
tm.apply(Theme.DARK)
|
||||
pal = app.palette()
|
||||
assert pal.color(QPalette.Link) == QColor("#FFA500")
|
||||
44
tests/test_toolbar.py
Normal file
44
tests/test_toolbar.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import pytest
|
||||
from PySide6.QtWidgets import QWidget
|
||||
from bouquin.markdown_editor import MarkdownEditor
|
||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def editor(app, qtbot):
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
ed = MarkdownEditor(themes)
|
||||
qtbot.addWidget(ed)
|
||||
ed.show()
|
||||
return ed
|
||||
|
||||
|
||||
from bouquin.toolbar import ToolBar
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def test_toolbar_signals_and_styling(qtbot, editor):
|
||||
host = QWidget()
|
||||
qtbot.addWidget(host)
|
||||
host.show()
|
||||
|
||||
tb = ToolBar(parent=host)
|
||||
qtbot.addWidget(tb)
|
||||
tb.show()
|
||||
|
||||
tb.boldRequested.connect(editor.apply_weight)
|
||||
tb.italicRequested.connect(editor.apply_italic)
|
||||
tb.strikeRequested.connect(editor.apply_strikethrough)
|
||||
tb.codeRequested.connect(lambda: editor.apply_code())
|
||||
tb.headingRequested.connect(lambda s: editor.apply_heading(s))
|
||||
tb.bulletsRequested.connect(lambda: editor.toggle_bullets())
|
||||
tb.numbersRequested.connect(lambda: editor.toggle_numbers())
|
||||
tb.checkboxesRequested.connect(lambda: editor.toggle_checkboxes())
|
||||
|
||||
editor.from_markdown("hello")
|
||||
editor.selectAll()
|
||||
tb.boldRequested.emit()
|
||||
tb.italicRequested.emit()
|
||||
tb.strikeRequested.emit()
|
||||
tb.headingRequested.emit(24)
|
||||
assert editor.to_markdown()
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
from bouquin.toolbar import ToolBar
|
||||
|
||||
|
||||
def test_style_letter_button_handles_missing_widget(qtbot):
|
||||
tb = ToolBar()
|
||||
qtbot.addWidget(tb)
|
||||
# Create a dummy action detached from toolbar to force widgetForAction->None
|
||||
from PySide6.QtGui import QAction
|
||||
|
||||
act = QAction("X", tb)
|
||||
# No crash and early return
|
||||
tb._style_letter_button(act, "X")
|
||||
|
||||
|
||||
def test_style_letter_button_sets_tooltip_and_accessible(qtbot):
|
||||
tb = ToolBar()
|
||||
qtbot.addWidget(tb)
|
||||
# Use an existing action so widgetForAction returns a button
|
||||
act = tb.actBold
|
||||
tb._style_letter_button(act, "B", bold=True, tooltip="Bold")
|
||||
btn = tb.widgetForAction(act)
|
||||
assert btn.toolTip() == "Bold"
|
||||
assert btn.accessibleName() == "Bold"
|
||||
|
|
@ -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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue