Compare commits

...

3 commits

25 changed files with 988 additions and 100 deletions

View file

@ -1,6 +1,8 @@
# 0.1.11 # 0.1.11
* Add missing export extensions to export_by_extension * Add missing export extensions to export_by_extension
* Fix focusing on editor after leaving the app and returning
* More code coverage and removing obsolete bits of code
# 0.1.10.2 # 0.1.10.2

View file

@ -257,68 +257,31 @@ class DBManager:
).fetchall() ).fetchall()
return [dict(r) for r in rows] return [dict(r) for r in rows]
def get_version( def get_version(self, *, version_id: int) -> dict | None:
self,
*,
date_iso: str | None = None,
version_no: int | None = None,
version_id: int | None = None,
) -> dict | None:
""" """
Fetch a specific version by (date, version_no) OR by version_id. Fetch a specific version by version_id.
Returns a dict with keys: id, date, version_no, created_at, note, content. Returns a dict with keys: id, date, version_no, created_at, note, content.
""" """
cur = self.conn.cursor() cur = self.conn.cursor()
if version_id is not None: row = cur.execute(
row = cur.execute( "SELECT id, date, version_no, created_at, note, content "
"SELECT id, date, version_no, created_at, note, content " "FROM versions WHERE id=?;",
"FROM versions WHERE id=?;", (version_id,),
(version_id,), ).fetchone()
).fetchone()
else:
if date_iso is None or version_no is None:
raise ValueError(
"Provide either version_id OR (date_iso and version_no)"
)
row = cur.execute(
"SELECT id, date, version_no, created_at, note, content "
"FROM versions WHERE date=? AND version_no=?;",
(date_iso, version_no),
).fetchone()
return dict(row) if row else None return dict(row) if row else None
def revert_to_version( def revert_to_version(self, date_iso: str, version_id: int) -> None:
self,
date_iso: str,
*,
version_no: int | None = None,
version_id: int | None = None,
) -> None:
""" """
Point the page head (pages.current_version_id) to an existing version. Point the page head (pages.current_version_id) to an existing version.
Fast revert: no content is rewritten.
""" """
if self.conn is None:
raise RuntimeError("Database is not connected")
cur = self.conn.cursor() cur = self.conn.cursor()
if version_id is None: # Ensure that version_id belongs to the given date
if version_no is None: row = cur.execute(
raise ValueError("Provide version_no or version_id") "SELECT date FROM versions WHERE id=?;", (version_id,)
row = cur.execute( ).fetchone()
"SELECT id FROM versions WHERE date=? AND version_no=?;", if row is None or row["date"] != date_iso:
(date_iso, version_no), raise ValueError("version_id does not belong to the given date")
).fetchone()
if row is None:
raise ValueError("Version not found for this date")
version_id = int(row["id"])
else:
# Ensure that version_id belongs to the given date
row = cur.execute(
"SELECT date FROM versions WHERE id=?;", (version_id,)
).fetchone()
if row is None or row["date"] != date_iso:
raise ValueError("version_id does not belong to the given date")
with self.conn: with self.conn:
cur.execute( cur.execute(
@ -342,20 +305,18 @@ class DBManager:
).fetchall() ).fetchall()
return [(r[0], r[1]) for r in rows] return [(r[0], r[1]) for r in rows]
def export_json( def export_json(self, entries: Sequence[Entry], file_path: str) -> None:
self, entries: Sequence[Entry], file_path: str, pretty: bool = True
) -> None:
""" """
Export to json. Export to json.
""" """
data = [{"date": d, "content": c} for d, c in entries] data = [{"date": d, "content": c} for d, c in entries]
with open(file_path, "w", encoding="utf-8") as f: with open(file_path, "w", encoding="utf-8") as f:
if pretty: json.dump(data, f, ensure_ascii=False, indent=2)
json.dump(data, f, ensure_ascii=False, indent=2)
else:
json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
def export_csv(self, entries: Sequence[Entry], file_path: str) -> None: def export_csv(self, entries: Sequence[Entry], file_path: str) -> None:
"""
Export pages to CSV.
"""
# utf-8-sig adds a BOM so Excel opens as UTF-8 by default. # utf-8-sig adds a BOM so Excel opens as UTF-8 by default.
with open(file_path, "w", encoding="utf-8-sig", newline="") as f: with open(file_path, "w", encoding="utf-8-sig", newline="") as f:
writer = csv.writer(f) writer = csv.writer(f)
@ -369,6 +330,10 @@ class DBManager:
separator: str = "\n\n— — — — —\n\n", separator: str = "\n\n— — — — —\n\n",
strip_html: bool = True, strip_html: bool = True,
) -> None: ) -> None:
"""
Strip the HTML from the latest version of the pages
and save to a text file.
"""
import re, html as _html import re, html as _html
# Precompiled patterns # Precompiled patterns
@ -407,6 +372,9 @@ class DBManager:
def export_html( def export_html(
self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export" self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export"
) -> None: ) -> None:
"""
Export to HTML with a heading.
"""
parts = [ parts = [
"<!doctype html>", "<!doctype html>",
'<html lang="en">', '<html lang="en">',
@ -429,6 +397,10 @@ class DBManager:
def export_markdown( def export_markdown(
self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export" self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export"
) -> None: ) -> None:
"""
Export to HTML, similar to export_html, but then convert to Markdown
using markdownify, and finally save to file.
"""
parts = [ parts = [
"<!doctype html>", "<!doctype html>",
'<html lang="en">', '<html lang="en">',
@ -469,6 +441,10 @@ class DBManager:
cur.execute("DETACH DATABASE backup") cur.execute("DETACH DATABASE backup")
def export_by_extension(self, file_path: str) -> None: def export_by_extension(self, file_path: str) -> None:
"""
Fallback catch-all that runs one of the above functions based on
the extension of the file name that was chosen by the user.
"""
entries = self.get_all_entries() entries = self.get_all_entries()
ext = os.path.splitext(file_path)[1].lower() ext = os.path.splitext(file_path)[1].lower()
@ -483,7 +459,7 @@ class DBManager:
elif ext in {".sql", ".sqlite"}: elif ext in {".sql", ".sqlite"}:
self.export_sql(file_path) self.export_sql(file_path)
elif ext == ".md": elif ext == ".md":
self.export_markdown(file_path) self.export_markdown(entries, file_path)
else: else:
raise ValueError(f"Unsupported extension: {ext}") raise ValueError(f"Unsupported extension: {ext}")

View file

@ -140,10 +140,8 @@ class Editor(QTextEdit):
bc.setPosition(b.position() + b.length()) bc.setPosition(b.position() + b.length())
return blocks > 0 and (codeish / blocks) >= 0.6 return blocks > 0 and (codeish / blocks) >= 0.6
def _nearest_code_frame(self, cursor=None, tolerant: bool = False): def _nearest_code_frame(self, cursor, tolerant: bool = False):
"""Walk up parents from the cursor and return the first code frame.""" """Walk up parents from the cursor and return the first code frame."""
if cursor is None:
cursor = self.textCursor()
f = cursor.currentFrame() f = cursor.currentFrame()
while f: while f:
if self._is_code_frame(f, tolerant=tolerant): if self._is_code_frame(f, tolerant=tolerant):
@ -271,16 +269,6 @@ class Editor(QTextEdit):
cur.endEditBlock() cur.endEditBlock()
self.viewport().update() self.viewport().update()
def _safe_select(self, cur: QTextCursor, start: int, end: int):
"""Select [start, end] inclusive without exceeding document bounds."""
doc_max = max(0, self.document().characterCount() - 1)
s = max(0, min(start, doc_max))
e = max(0, min(end, doc_max))
if e < s:
s, e = e, s
cur.setPosition(s)
cur.setPosition(e, QTextCursor.KeepAnchor)
def _trim_url_end(self, url: str) -> str: def _trim_url_end(self, url: str) -> str:
# strip common trailing punctuation not part of the URL # strip common trailing punctuation not part of the URL
trimmed = url.rstrip(".,;:!?\"'") trimmed = url.rstrip(".,;:!?\"'")
@ -854,14 +842,6 @@ class Editor(QTextEdit):
break break
b = b.next() b = b.next()
def toggle_current_checkbox_state(self):
"""Tick/untick the current line if it starts with a checkbox."""
b = self.textCursor().block()
state, _ = self._checkbox_info_for_block(b)
if state is None:
return
self._set_block_checkbox_state(b, not state)
@Slot() @Slot()
def apply_weight(self): def apply_weight(self):
cur = self.currentCharFormat() cur = self.currentCharFormat()

View file

@ -156,7 +156,7 @@ class HistoryDialog(QDialog):
# Diff vs current (textual diff) # Diff vs current (textual diff)
cur = self._db.get_version(version_id=self._current_id) cur = self._db.get_version(version_id=self._current_id)
self.diff.setHtml(_colored_unified_diff_html(cur["content"], sel["content"])) self.diff.setHtml(_colored_unified_diff_html(cur["content"], sel["content"]))
# Enable revert only if selecting a non-current # Enable revert only if selecting a non-current version
self.btn_revert.setEnabled(sel_id != self._current_id) self.btn_revert.setEnabled(sel_id != self._current_id)
@Slot() @Slot()
@ -167,7 +167,7 @@ class HistoryDialog(QDialog):
sel_id = item.data(Qt.UserRole) sel_id = item.data(Qt.UserRole)
if sel_id == self._current_id: if sel_id == self._current_id:
return return
# Flip head pointer # Flip head pointer to the older version
try: try:
self._db.revert_to_version(self._date, version_id=sel_id) self._db.revert_to_version(self._date, version_id=sel_id)
except Exception as e: except Exception as e:

View file

@ -17,6 +17,12 @@ class KeyPrompt(QDialog):
title: str = "Enter key", title: str = "Enter key",
message: str = "Enter key", message: str = "Enter key",
): ):
"""
Prompt the user for the key required to decrypt the database.
Used when opening the app, unlocking the idle locked screen,
or when rekeying.
"""
super().__init__(parent) super().__init__(parent)
self.setWindowTitle(title) self.setWindowTitle(title)
v = QVBoxLayout(self) v = QVBoxLayout(self)

View file

@ -7,6 +7,9 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton
class LockOverlay(QWidget): class LockOverlay(QWidget):
def __init__(self, parent: QWidget, on_unlock: callable): def __init__(self, parent: QWidget, on_unlock: callable):
"""
Widget that 'locks' the screen after a configured idle time.
"""
super().__init__(parent) super().__init__(parent)
self.setObjectName("LockOverlay") self.setObjectName("LockOverlay")
self.setAttribute(Qt.WA_StyledBackground, True) self.setAttribute(Qt.WA_StyledBackground, True)
@ -39,6 +42,9 @@ class LockOverlay(QWidget):
self.hide() self.hide()
def _is_dark(self, pal: QPalette) -> bool: def _is_dark(self, pal: QPalette) -> bool:
"""
Detect if dark mode is in use.
"""
c = pal.color(QPalette.Window) c = pal.color(QPalette.Window)
luma = 0.2126 * c.redF() + 0.7152 * c.greenF() + 0.0722 * c.blueF() luma = 0.2126 * c.redF() + 0.7152 * c.greenF() + 0.0722 * c.blueF()
return luma < 0.5 return luma < 0.5
@ -58,7 +64,7 @@ class LockOverlay(QWidget):
self.setStyleSheet( self.setStyleSheet(
f""" f"""
#LockOverlay {{ background-color: rgb(0,0,0); }} /* opaque, no transparency */ #LockOverlay {{ background-color: rgb(0,0,0); }}
#LockOverlay QLabel#lockLabel {{ color: {accent_hex}; font-weight: 600; }} #LockOverlay QLabel#lockLabel {{ color: {accent_hex}; font-weight: 600; }}
#LockOverlay QPushButton#unlockButton {{ #LockOverlay QPushButton#unlockButton {{
@ -113,7 +119,7 @@ class LockOverlay(QWidget):
def changeEvent(self, ev): def changeEvent(self, ev):
super().changeEvent(ev) super().changeEvent(ev)
# Only re-style on palette flips # Only re-style on palette flips (user changed theme)
if ev.type() in (QEvent.PaletteChange, QEvent.ApplicationPaletteChange): if ev.type() in (QEvent.PaletteChange, QEvent.ApplicationPaletteChange):
self._apply_overlay_style() self._apply_overlay_style()

View file

@ -26,6 +26,7 @@ from PySide6.QtGui import (
QGuiApplication, QGuiApplication,
QPalette, QPalette,
QTextCharFormat, QTextCharFormat,
QTextCursor,
QTextListFormat, QTextListFormat,
) )
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
@ -121,7 +122,7 @@ class MainWindow(QMainWindow):
split = QSplitter() split = QSplitter()
split.addWidget(left_panel) split.addWidget(left_panel)
split.addWidget(self.editor) split.addWidget(self.editor)
split.setStretchFactor(1, 1) # editor grows split.setStretchFactor(1, 1)
container = QWidget() container = QWidget()
lay = QVBoxLayout(container) lay = QVBoxLayout(container)
@ -146,6 +147,16 @@ class MainWindow(QMainWindow):
QApplication.instance().installEventFilter(self) QApplication.instance().installEventFilter(self)
# Focus on the editor
self.setFocusPolicy(Qt.StrongFocus)
self.editor.setFocusPolicy(Qt.StrongFocus)
self.toolBar.setFocusPolicy(Qt.NoFocus)
for w in self.toolBar.findChildren(QWidget):
w.setFocusPolicy(Qt.NoFocus)
QGuiApplication.instance().applicationStateChanged.connect(
self._on_app_state_changed
)
# Status bar for feedback # Status bar for feedback
self.statusBar().showMessage("Ready", 800) self.statusBar().showMessage("Ready", 800)
@ -281,7 +292,7 @@ class MainWindow(QMainWindow):
if hasattr(self, "_lock_overlay"): if hasattr(self, "_lock_overlay"):
self._lock_overlay._apply_overlay_style() self._lock_overlay._apply_overlay_style()
self._apply_calendar_text_colors() self._apply_calendar_text_colors()
self._apply_link_css() # Reapply link styles based on the current theme self._apply_link_css()
self._apply_search_highlights(getattr(self, "_search_highlighted_dates", set())) self._apply_search_highlights(getattr(self, "_search_highlighted_dates", set()))
self.calendar.update() self.calendar.update()
self.editor.viewport().update() self.editor.viewport().update()
@ -298,7 +309,6 @@ class MainWindow(QMainWindow):
css = "" # Default to no custom styling for links (system or light theme) css = "" # Default to no custom styling for links (system or light theme)
try: try:
# Apply to the editor (QTextEdit or any other relevant widgets)
self.editor.document().setDefaultStyleSheet(css) self.editor.document().setDefaultStyleSheet(css)
except Exception: except Exception:
pass pass
@ -347,7 +357,6 @@ class MainWindow(QMainWindow):
self.calendar.setPalette(app_pal) self.calendar.setPalette(app_pal)
self.calendar.setStyleSheet("") self.calendar.setStyleSheet("")
# Keep weekend text color in sync with the current palette
self._apply_calendar_text_colors() self._apply_calendar_text_colors()
self.calendar.update() self.calendar.update()
@ -483,7 +492,8 @@ class MainWindow(QMainWindow):
# Inject the extra_data before the closing </body></html> # Inject the extra_data before the closing </body></html>
modified = re.sub(r"(<\/body><\/html>)", extra_data_html + r"\1", text) modified = re.sub(r"(<\/body><\/html>)", extra_data_html + r"\1", text)
text = modified text = modified
self.editor.setHtml(text) # Force a save now so we don't lose it.
self._set_editor_html_preserve_view(text)
self._dirty = True self._dirty = True
self._save_date(date_iso, True) self._save_date(date_iso, True)
@ -491,9 +501,7 @@ class MainWindow(QMainWindow):
QMessageBox.critical(self, "Read Error", str(e)) QMessageBox.critical(self, "Read Error", str(e))
return return
self.editor.blockSignals(True) self._set_editor_html_preserve_view(text)
self.editor.setHtml(text)
self.editor.blockSignals(False)
self._dirty = False self._dirty = False
# track which date the editor currently represents # track which date the editor currently represents
@ -852,9 +860,14 @@ If you want an encrypted backup, choose Backup instead of Export.
def eventFilter(self, obj, event): def eventFilter(self, obj, event):
if event.type() == QEvent.KeyPress and not self._locked: if event.type() == QEvent.KeyPress and not self._locked:
self._idle_timer.start() self._idle_timer.start()
if event.type() in (QEvent.ApplicationActivate, QEvent.WindowActivate):
QTimer.singleShot(0, self._focus_editor_now)
return super().eventFilter(obj, event) return super().eventFilter(obj, event)
def _enter_lock(self): def _enter_lock(self):
"""
Trigger the lock overlay and disable widgets
"""
if self._locked: if self._locked:
return return
self._locked = True self._locked = True
@ -870,6 +883,10 @@ If you want an encrypted backup, choose Backup instead of Export.
@Slot() @Slot()
def _on_unlock_clicked(self): def _on_unlock_clicked(self):
"""
Prompt for key to unlock screen
If successful, re-enable widgets
"""
try: try:
ok = self._prompt_for_key_until_valid(first_time=False) ok = self._prompt_for_key_until_valid(first_time=False)
except Exception as e: except Exception as e:
@ -886,6 +903,7 @@ If you want an encrypted backup, choose Backup instead of Export.
if tb: if tb:
tb.setEnabled(True) tb.setEnabled(True)
self._idle_timer.start() self._idle_timer.start()
QTimer.singleShot(0, self._focus_editor_now)
# ----------------- Close handlers ----------------- # # ----------------- Close handlers ----------------- #
def closeEvent(self, event): def closeEvent(self, event):
@ -901,3 +919,61 @@ If you want an encrypted backup, choose Backup instead of Export.
except Exception: except Exception:
pass pass
super().closeEvent(event) super().closeEvent(event)
# ----------------- Below logic helps focus the editor ----------------- #
def _focus_editor_now(self):
"""Give focus to the editor and ensure the caret is visible."""
if getattr(self, "_locked", False):
return
if not self.isActiveWindow():
return
# Belt-and-suspenders: do it now and once more on the next tick
self.editor.setFocus(Qt.ActiveWindowFocusReason)
self.editor.ensureCursorVisible()
QTimer.singleShot(
0,
lambda: (
self.editor.setFocus(Qt.ActiveWindowFocusReason),
self.editor.ensureCursorVisible(),
),
)
def _on_app_state_changed(self, state):
# Called on macOS/Wayland/Windows when the whole app re-activates
if state == Qt.ApplicationActive and self.isActiveWindow():
QTimer.singleShot(0, self._focus_editor_now)
def changeEvent(self, ev):
# Called on some platforms when the window's activation state flips
super().changeEvent(ev)
if ev.type() == QEvent.ActivationChange and self.isActiveWindow():
QTimer.singleShot(0, self._focus_editor_now)
def _set_editor_html_preserve_view(self, html: str):
ed = self.editor
# Save caret/selection and scroll
cur = ed.textCursor()
old_pos, old_anchor = cur.position(), cur.anchor()
v = ed.verticalScrollBar().value()
h = ed.horizontalScrollBar().value()
# Only touch the doc if it actually changed
ed.blockSignals(True)
if ed.toHtml() != html:
ed.setHtml(html)
ed.blockSignals(False)
# Restore scroll first
ed.verticalScrollBar().setValue(v)
ed.horizontalScrollBar().setValue(h)
# Restore caret/selection
cur = ed.textCursor()
cur.setPosition(old_anchor)
mode = (
QTextCursor.KeepAnchor if old_anchor != old_pos else QTextCursor.MoveAnchor
)
cur.setPosition(old_pos, mode)
ed.setTextCursor(cur)

View file

@ -18,6 +18,9 @@ class SaveDialog(QDialog):
title: str = "Enter a name for this version", title: str = "Enter a name for this version",
message: str = "Enter a name for this version?", message: str = "Enter a name for this version?",
): ):
"""
Used for explicitly saving a new version of a page.
"""
super().__init__(parent) super().__init__(parent)
self.setWindowTitle(title) self.setWindowTitle(title)
v = QVBoxLayout(self) v = QVBoxLayout(self)

View file

@ -70,7 +70,6 @@ class Search(QWidget):
try: try:
rows: Iterable[Row] = self._db.search_entries(q) rows: Iterable[Row] = self._db.search_entries(q)
except Exception: except Exception:
# be quiet on DB errors here; caller can surface if desired
rows = [] rows = []
self._populate_results(q, rows) self._populate_results(q, rows)

View file

@ -49,7 +49,7 @@ class ThemeManager(QObject):
scheme = getattr(hints, "colorScheme", None) scheme = getattr(hints, "colorScheme", None)
if callable(scheme): if callable(scheme):
scheme = hints.colorScheme() scheme = hints.colorScheme()
# 0=Light, 1=Dark in newer Qt; fall back to Light # 0=Light, 1=Dark; fall back to Light
theme = Theme.DARK if scheme == 1 else Theme.LIGHT theme = Theme.DARK if scheme == 1 else Theme.LIGHT
# Always use Fusion so palette applies consistently cross-platform # Always use Fusion so palette applies consistently cross-platform
@ -58,7 +58,6 @@ class ThemeManager(QObject):
if theme == Theme.DARK: if theme == Theme.DARK:
pal = self._dark_palette() pal = self._dark_palette()
self._app.setPalette(pal) self._app.setPalette(pal)
# keep stylesheet empty unless you need widget-specific tweaks
self._app.setStyleSheet("") self._app.setStyleSheet("")
else: else:
pal = self._light_palette() pal = self._light_palette()

View file

@ -140,6 +140,11 @@ class ToolBar(QToolBar):
for a in (self.actAlignL, self.actAlignC, self.actAlignR): for a in (self.actAlignL, self.actAlignC, self.actAlignR):
a.setActionGroup(self.grpAlign) a.setActionGroup(self.grpAlign)
self.grpLists = QActionGroup(self)
self.grpLists.setExclusive(True)
for a in (self.actBullets, self.actNumbers, self.actCheckboxes):
a.setActionGroup(self.grpLists)
# Add actions # Add actions
self.addActions( self.addActions(
[ [

View file

@ -102,3 +102,32 @@ def theme_parent_widget(qtbot):
parent = _Parent() parent = _Parent()
qtbot.addWidget(parent) qtbot.addWidget(parent)
return 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)
from bouquin.db import DBConfig
return DBConfig(
path=Path(temp_db_path),
key="testkey",
idle_minutes=0,
theme="system",
move_todos=True,
)

View file

@ -0,0 +1,117 @@
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

View file

@ -114,7 +114,7 @@ def test_export_by_extension_and_unknown(tmp_path):
import types import types
mgr.get_all_entries = types.MethodType(lambda self: entries, mgr) mgr.get_all_entries = types.MethodType(lambda self: entries, mgr)
for ext in [".json", ".csv", ".txt", ".html"]: for ext in [".json", ".csv", ".txt", ".html", ".md"]:
path = tmp_path / f"route{ext}" path = tmp_path / f"route{ext}"
mgr.export_by_extension(str(path)) mgr.export_by_extension(str(path))
assert path.exists() assert path.exists()

View file

@ -145,10 +145,6 @@ def test_linkify_trims_trailing_punctuation(qtbot):
def test_code_block_enter_exits_on_empty_line(qtbot): def test_code_block_enter_exits_on_empty_line(qtbot):
from PySide6.QtCore import Qt
from PySide6.QtGui import QTextCursor
from PySide6.QtTest import QTest
from bouquin.editor import Editor
e = _mk_editor() e = _mk_editor()
qtbot.addWidget(e) qtbot.addWidget(e)

View file

@ -0,0 +1,103 @@
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

View file

@ -0,0 +1,75 @@
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

136
tests/test_editor_more.py Normal file
View file

@ -0,0 +1,136 @@
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 (574584 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 (605614)
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 (680684)
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 (945946)
e.toggle_bullets()
e.toggle_bullets()
# numbers twice -> second call removes (955956)
e.toggle_numbers()
e.toggle_numbers()

View file

@ -0,0 +1,43 @@
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)

14
tests/test_main_module.py Normal file
View file

@ -0,0 +1,14 @@
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

View file

@ -0,0 +1,90 @@
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

View file

@ -0,0 +1,15 @@
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

View file

@ -0,0 +1,70 @@
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()

View file

@ -0,0 +1,37 @@
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

View file

@ -0,0 +1,111 @@
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())