Code cleanup, more tests

This commit is contained in:
Miguel Jacq 2025-11-11 13:12:30 +11:00
parent 1c0052a0cf
commit bfd0314109
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
16 changed files with 1212 additions and 478 deletions

View file

@ -98,31 +98,6 @@ class DBManager:
CREATE INDEX IF NOT EXISTS ix_versions_date_created ON versions(date, created_at); CREATE INDEX IF NOT EXISTS ix_versions_date_created ON versions(date, created_at);
""" """
) )
# If < 0.1.5 'entries' table exists and nothing has been migrated yet, try to migrate.
pre_0_1_5 = cur.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='entries';"
).fetchone()
pages_empty = cur.execute("SELECT 1 FROM pages LIMIT 1;").fetchone() is None
if pre_0_1_5 and pages_empty:
# Seed pages and versions (all as version 1)
cur.execute("INSERT OR IGNORE INTO pages(date) SELECT date FROM entries;")
cur.execute(
"INSERT INTO versions(date, version_no, content) "
"SELECT date, 1, content FROM entries;"
)
# Point head to v1 for each page
cur.execute(
"""
UPDATE pages
SET current_version_id = (
SELECT v.id FROM versions v
WHERE v.date = pages.date AND v.version_no = 1
);
"""
)
cur.execute("DROP TABLE IF EXISTS entries;")
self.conn.commit() self.conn.commit()
def rekey(self, new_key: str) -> None: def rekey(self, new_key: str) -> None:
@ -130,8 +105,6 @@ class DBManager:
Change the SQLCipher passphrase in-place, then reopen the connection Change the SQLCipher passphrase in-place, then reopen the connection
with the new key to verify. with the new key to verify.
""" """
if self.conn is None:
raise RuntimeError("Database is not connected")
cur = self.conn.cursor() cur = self.conn.cursor()
# Change the encryption key of the currently open database # Change the encryption key of the currently open database
cur.execute(f"PRAGMA rekey = '{new_key}';").fetchone() cur.execute(f"PRAGMA rekey = '{new_key}';").fetchone()
@ -191,7 +164,8 @@ class DBManager:
""" """
SELECT p.date SELECT p.date
FROM pages p FROM pages p
JOIN versions v ON v.id = p.current_version_id JOIN versions v
ON v.id = p.current_version_id
WHERE TRIM(v.content) <> '' WHERE TRIM(v.content) <> ''
ORDER BY p.date; ORDER BY p.date;
""" """
@ -210,8 +184,6 @@ class DBManager:
Append a new version for this date. Returns (version_id, version_no). Append a new version for this date. Returns (version_id, version_no).
If set_current=True, flips the page head to this new version. If set_current=True, flips the page head to this new version.
""" """
if self.conn is None:
raise RuntimeError("Database is not connected")
with self.conn: # transaction with self.conn: # transaction
cur = self.conn.cursor() cur = self.conn.cursor()
# Ensure page row exists # Ensure page row exists
@ -326,44 +298,13 @@ class DBManager:
entries: Sequence[Entry], entries: Sequence[Entry],
file_path: str, file_path: str,
separator: str = "\n\n— — — — —\n\n", separator: str = "\n\n— — — — —\n\n",
strip_html: bool = True,
) -> None: ) -> None:
""" """
Strip the HTML from the latest version of the pages Strip the the latest version of the pages to a text file.
and save to a text file.
""" """
import re, html as _html
# Precompiled patterns
STYLE_SCRIPT_RE = re.compile(r"(?is)<(script|style)[^>]*>.*?</\1>")
COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
BR_RE = re.compile(r"(?i)<br\\s*/?>")
BLOCK_END_RE = re.compile(r"(?i)</(p|div|section|article|li|h[1-6])\\s*>")
TAG_RE = re.compile(r"<[^>]+>")
WS_ENDS_RE = re.compile(r"[ \\t]+\\n")
MULTINEWLINE_RE = re.compile(r"\\n{3,}")
def _strip(s: str) -> str:
# 1) Remove <style> and <script> blocks *including their contents*
s = STYLE_SCRIPT_RE.sub("", s)
# 2) Remove HTML comments
s = COMMENT_RE.sub("", s)
# 3) Turn some block-ish boundaries into newlines before removing tags
s = BR_RE.sub("\n", s)
s = BLOCK_END_RE.sub("\n", s)
# 4) Drop remaining tags
s = TAG_RE.sub("", s)
# 5) Unescape entities (&nbsp; etc.)
s = _html.unescape(s)
# 6) Tidy whitespace
s = WS_ENDS_RE.sub("\n", s)
s = MULTINEWLINE_RE.sub("\n\n", s)
return s.strip()
with open(file_path, "w", encoding="utf-8") as f: with open(file_path, "w", encoding="utf-8") as f:
for i, (d, c) in enumerate(entries): for i, (d, c) in enumerate(entries):
body = _strip(c) if strip_html else c f.write(f"{d}\n{c}\n")
f.write(f"{d}\n{body}\n")
if i < len(entries) - 1: if i < len(entries) - 1:
f.write(separator) f.write(separator)
@ -396,8 +337,8 @@ class DBManager:
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 Export the data to a markdown file. Since the data is already Markdown,
using markdownify, and finally save to file. nothing more to do.
""" """
parts = [] parts = []
for d, c in entries: for d, c in entries:

View file

@ -288,6 +288,19 @@ class MainWindow(QMainWindow):
# apply once on startup so links / calendar colors are set immediately # apply once on startup so links / calendar colors are set immediately
self._retheme_overrides() self._retheme_overrides()
@property
def editor(self) -> MarkdownEditor | None:
"""Get the currently active editor."""
return self.tab_widget.currentWidget()
def _call_editor(self, method_name, *args):
"""
Call the relevant method of the MarkdownEditor class on bind
"""
getattr(self.editor, method_name)(*args)
# ----------- Database connection/key management methods ------------ #
def _try_connect(self) -> bool: def _try_connect(self) -> bool:
""" """
Try to connect to the database. Try to connect to the database.
@ -488,17 +501,6 @@ class MainWindow(QMainWindow):
# Remember this as the "previous" editor for next switch # Remember this as the "previous" editor for next switch
self._prev_editor = editor self._prev_editor = editor
def _call_editor(self, method_name, *args):
"""
Call the relevant method of the MarkdownEditor class on bind
"""
getattr(self.editor, method_name)(*args)
@property
def editor(self) -> MarkdownEditor | None:
"""Get the currently active editor."""
return self.tab_widget.currentWidget()
def _date_from_calendar_pos(self, pos) -> QDate | None: def _date_from_calendar_pos(self, pos) -> QDate | None:
"""Translate a QCalendarWidget local pos to the QDate under the cursor.""" """Translate a QCalendarWidget local pos to the QDate under the cursor."""
view: QTableView = self.calendar.findChild( view: QTableView = self.calendar.findChild(
@ -599,6 +601,215 @@ class MainWindow(QMainWindow):
if action == open_in_new_tab_action and clicked_date and clicked_date.isValid(): if action == open_in_new_tab_action and clicked_date and clicked_date.isValid():
self._open_date_in_tab(clicked_date) self._open_date_in_tab(clicked_date)
def _load_selected_date(self, date_iso=False, extra_data=False):
"""Load a date into the current editor"""
if not date_iso:
date_iso = self._current_date_iso()
qd = QDate.fromString(date_iso, "yyyy-MM-dd")
# Keep calendar in sync
with QSignalBlocker(self.calendar):
self.calendar.setSelectedDate(qd)
self._load_date_into_editor(qd, extra_data)
self.editor.current_date = qd
# Update tab title
current_index = self.tab_widget.currentIndex()
if current_index >= 0:
self.tab_widget.setTabText(current_index, date_iso)
# Keep tabs sorted by date
self._reorder_tabs_by_date()
def _load_date_into_editor(self, date: QDate, extra_data=False):
"""Load a specific date's content into a given editor."""
date_iso = date.toString("yyyy-MM-dd")
text = self.db.get_entry(date_iso)
if extra_data:
# Append extra data as markdown
if text and not text.endswith("\n"):
text += "\n"
text += extra_data
# Force a save now so we don't lose it.
self._set_editor_markdown_preserve_view(text)
self._dirty = True
self._save_date(date_iso, True)
self._set_editor_markdown_preserve_view(text)
self._dirty = False
def _set_editor_markdown_preserve_view(self, markdown: str):
# Save caret/selection and scroll
cur = self.editor.textCursor()
old_pos, old_anchor = cur.position(), cur.anchor()
v = self.editor.verticalScrollBar().value()
h = self.editor.horizontalScrollBar().value()
# Only touch the doc if it actually changed
self.editor.blockSignals(True)
if self.editor.to_markdown() != markdown:
self.editor.from_markdown(markdown)
self.editor.blockSignals(False)
# Restore scroll first
self.editor.verticalScrollBar().setValue(v)
self.editor.horizontalScrollBar().setValue(h)
# Restore caret/selection (bounded to new doc length)
doc_length = self.editor.document().characterCount() - 1
old_pos = min(old_pos, doc_length)
old_anchor = min(old_anchor, doc_length)
cur = self.editor.textCursor()
cur.setPosition(old_anchor)
mode = (
QTextCursor.KeepAnchor if old_anchor != old_pos else QTextCursor.MoveAnchor
)
cur.setPosition(old_pos, mode)
self.editor.setTextCursor(cur)
# Refresh highlights if the theme changed
if hasattr(self, "findBar"):
self.findBar.refresh()
def _save_editor_content(self, editor: MarkdownEditor):
"""Save a specific editor's content to its associated date."""
if not hasattr(editor, "current_date"):
return
date_iso = editor.current_date.toString("yyyy-MM-dd")
md = editor.to_markdown()
self.db.save_new_version(date_iso, md, note="autosave")
def _on_text_changed(self):
self._dirty = True
self._save_timer.start(5000) # autosave after idle
def _adjust_day(self, delta: int):
"""Move selection by delta days (negative for previous)."""
d = self.calendar.selectedDate().addDays(delta)
self.calendar.setSelectedDate(d)
def _adjust_today(self):
"""Jump to today."""
today = QDate.currentDate()
self._create_new_tab(today)
def _load_yesterday_todos(self):
if not self.cfg.move_todos:
return
yesterday_str = QDate.currentDate().addDays(-1).toString("yyyy-MM-dd")
text = self.db.get_entry(yesterday_str)
unchecked_items = []
# Split into lines and find unchecked checkbox items
lines = text.split("\n")
remaining_lines = []
for line in lines:
# Check for unchecked markdown checkboxes: - [ ] or - [☐]
if re.match(r"^\s*-\s*\[\s*\]\s+", line) or re.match(
r"^\s*-\s*\[☐\]\s+", line
):
# Extract the text after the checkbox
item_text = re.sub(r"^\s*-\s*\[[\s☐]\]\s+", "", line)
unchecked_items.append(f"- [ ] {item_text}")
else:
# Keep all other lines
remaining_lines.append(line)
# Save modified content back if we moved items
if unchecked_items:
modified_text = "\n".join(remaining_lines)
self.db.save_new_version(
yesterday_str,
modified_text,
"Unchecked checkbox items moved to next day",
)
# Join unchecked items into markdown format
unchecked_str = "\n".join(unchecked_items) + "\n"
# Load the unchecked items into the current editor
self._load_selected_date(False, unchecked_str)
else:
return False
def _on_date_changed(self):
"""
When the calendar selection changes, save the previous day's note if dirty,
so we don't lose that text, then load the newly selected day into current tab.
"""
# Skip if we're showing a context menu (right-click shouldn't load dates)
if getattr(self, "_showing_context_menu", False):
return
# Stop pending autosave and persist current buffer if needed
try:
self._save_timer.stop()
except Exception:
pass
# Save the current editor's content if dirty
if hasattr(self.editor, "current_date") and self._dirty:
prev_date_iso = self.editor.current_date.toString("yyyy-MM-dd")
self._save_date(prev_date_iso, explicit=False)
# Now load the newly selected date into the current tab
new_date = self.calendar.selectedDate()
self._load_date_into_editor(new_date)
self.editor.current_date = new_date
# Update tab title
current_index = self.tab_widget.currentIndex()
if current_index >= 0:
self.tab_widget.setTabText(current_index, new_date.toString("yyyy-MM-dd"))
# Keep tabs sorted by date
self._reorder_tabs_by_date()
def _save_date(self, date_iso: str, explicit: bool = False, note: str = "autosave"):
"""
Save editor contents into the given date. Shows status on success.
explicit=True means user invoked Save: show feedback even if nothing changed.
"""
if not self._dirty and not explicit:
return
text = self.editor.to_markdown()
self.db.save_new_version(date_iso, text, note)
self._dirty = False
self._refresh_calendar_marks()
# Feedback in the status bar
from datetime import datetime as _dt
self.statusBar().showMessage(
f"Saved {date_iso} at {_dt.now().strftime('%H:%M:%S')}", 2000
)
def _save_current(self, explicit: bool = False):
"""Save the current editor's content."""
try:
self._save_timer.stop()
except Exception:
pass
if explicit:
# Prompt for a note
dlg = SaveDialog(self)
if dlg.exec() != QDialog.Accepted:
return
note = dlg.note_text()
else:
note = "autosave"
# Save the current editor's date
date_iso = self.editor.current_date.toString("yyyy-MM-dd")
self._save_date(date_iso, explicit, note)
try:
self._save_timer.start()
except Exception:
pass
# ----------------- Some theme helpers -------------------# # ----------------- Some theme helpers -------------------#
def _retheme_overrides(self): def _retheme_overrides(self):
if hasattr(self, "_lock_overlay"): if hasattr(self, "_lock_overlay"):
@ -620,15 +831,7 @@ class MainWindow(QMainWindow):
else: else:
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: self.editor.document().setDefaultStyleSheet(css)
self.editor.document().setDefaultStyleSheet(css)
except Exception:
pass
try:
self.search.document().setDefaultStyleSheet(css)
except Exception:
pass
def _apply_calendar_theme(self, theme: Theme): def _apply_calendar_theme(self, theme: Theme):
"""Use orange accents on the calendar in dark mode only.""" """Use orange accents on the calendar in dark mode only."""
@ -681,6 +884,8 @@ class MainWindow(QMainWindow):
self.calendar.setWeekdayTextFormat(Qt.Saturday, fmt) self.calendar.setWeekdayTextFormat(Qt.Saturday, fmt)
self.calendar.setWeekdayTextFormat(Qt.Sunday, fmt) self.calendar.setWeekdayTextFormat(Qt.Sunday, fmt)
# --------------- Search sidebar/results helpers ---------------- #
def _on_search_dates_changed(self, date_strs: list[str]): def _on_search_dates_changed(self, date_strs: list[str]):
dates = set() dates = set()
for ds in date_strs or []: for ds in date_strs or []:
@ -721,18 +926,15 @@ class MainWindow(QMainWindow):
fmt.setFontWeight(QFont.Weight.Normal) # remove bold only fmt.setFontWeight(QFont.Weight.Normal) # remove bold only
self.calendar.setDateTextFormat(d, fmt) self.calendar.setDateTextFormat(d, fmt)
self._marked_dates = set() self._marked_dates = set()
try: for date_iso in self.db.dates_with_content():
for date_iso in self.db.dates_with_content(): qd = QDate.fromString(date_iso, "yyyy-MM-dd")
qd = QDate.fromString(date_iso, "yyyy-MM-dd") if qd.isValid():
if qd.isValid(): fmt = self.calendar.dateTextFormat(qd)
fmt = self.calendar.dateTextFormat(qd) fmt.setFontWeight(QFont.Weight.Bold) # add bold only
fmt.setFontWeight(QFont.Weight.Bold) # add bold only self.calendar.setDateTextFormat(qd, fmt)
self.calendar.setDateTextFormat(qd, fmt) self._marked_dates.add(qd)
self._marked_dates.add(qd)
except Exception:
pass
# --- UI handlers --------------------------------------------------------- # -------------------- UI handlers ------------------- #
def _bind_toolbar(self): def _bind_toolbar(self):
if getattr(self, "_toolbar_bound", False): if getattr(self, "_toolbar_bound", False):
@ -805,150 +1007,6 @@ class MainWindow(QMainWindow):
self.toolBar.actBullets.setChecked(bool(bullets_on)) self.toolBar.actBullets.setChecked(bool(bullets_on))
self.toolBar.actNumbers.setChecked(bool(numbers_on)) self.toolBar.actNumbers.setChecked(bool(numbers_on))
def _load_selected_date(self, date_iso=False, extra_data=False):
"""Load a date into the current editor"""
if not date_iso:
date_iso = self._current_date_iso()
qd = QDate.fromString(date_iso, "yyyy-MM-dd")
# Keep calendar in sync
with QSignalBlocker(self.calendar):
self.calendar.setSelectedDate(qd)
self._load_date_into_editor(qd, extra_data)
self.editor.current_date = qd
# Update tab title
current_index = self.tab_widget.currentIndex()
if current_index >= 0:
self.tab_widget.setTabText(current_index, date_iso)
# Keep tabs sorted by date
self._reorder_tabs_by_date()
def _load_date_into_editor(self, date: QDate, extra_data=False):
"""Load a specific date's content into a given editor."""
date_iso = date.toString("yyyy-MM-dd")
try:
text = self.db.get_entry(date_iso)
if extra_data:
# Append extra data as markdown
if text and not text.endswith("\n"):
text += "\n"
text += extra_data
# Force a save now so we don't lose it.
self._set_editor_markdown_preserve_view(text)
self._dirty = True
self._save_date(date_iso, True)
except Exception as e:
QMessageBox.critical(self, "Read Error", str(e))
return
self._set_editor_markdown_preserve_view(text)
self._dirty = False
def _save_editor_content(self, editor: MarkdownEditor):
"""Save a specific editor's content to its associated date."""
if not hasattr(editor, "current_date"):
return
date_iso = editor.current_date.toString("yyyy-MM-dd")
try:
md = editor.to_markdown()
self.db.save_new_version(date_iso, md, note="autosave")
except Exception as e:
QMessageBox.critical(self, "Save Error", str(e))
def _on_text_changed(self):
self._dirty = True
self._save_timer.start(5000) # autosave after idle
def _adjust_day(self, delta: int):
"""Move selection by delta days (negative for previous)."""
d = self.calendar.selectedDate().addDays(delta)
self.calendar.setSelectedDate(d)
def _adjust_today(self):
"""Jump to today."""
today = QDate.currentDate()
self._create_new_tab(today)
def _load_yesterday_todos(self):
try:
if not self.cfg.move_todos:
return
yesterday_str = QDate.currentDate().addDays(-1).toString("yyyy-MM-dd")
text = self.db.get_entry(yesterday_str)
unchecked_items = []
# Split into lines and find unchecked checkbox items
lines = text.split("\n")
remaining_lines = []
for line in lines:
# Check for unchecked markdown checkboxes: - [ ] or - [☐]
if re.match(r"^\s*-\s*\[\s*\]\s+", line) or re.match(
r"^\s*-\s*\[☐\]\s+", line
):
# Extract the text after the checkbox
item_text = re.sub(r"^\s*-\s*\[[\s☐]\]\s+", "", line)
unchecked_items.append(f"- [ ] {item_text}")
else:
# Keep all other lines
remaining_lines.append(line)
# Save modified content back if we moved items
if unchecked_items:
modified_text = "\n".join(remaining_lines)
self.db.save_new_version(
yesterday_str,
modified_text,
"Unchecked checkbox items moved to next day",
)
# Join unchecked items into markdown format
unchecked_str = "\n".join(unchecked_items) + "\n"
# Load the unchecked items into the current editor
self._load_selected_date(False, unchecked_str)
else:
return False
except Exception as e:
raise SystemError(e)
def _on_date_changed(self):
"""
When the calendar selection changes, save the previous day's note if dirty,
so we don't lose that text, then load the newly selected day into current tab.
"""
# Skip if we're showing a context menu (right-click shouldn't load dates)
if getattr(self, "_showing_context_menu", False):
return
# Stop pending autosave and persist current buffer if needed
try:
self._save_timer.stop()
except Exception:
pass
# Save the current editor's content if dirty
if hasattr(self.editor, "current_date") and self._dirty:
prev_date_iso = self.editor.current_date.toString("yyyy-MM-dd")
self._save_date(prev_date_iso, explicit=False)
# Now load the newly selected date into the current tab
new_date = self.calendar.selectedDate()
self._load_date_into_editor(new_date)
self.editor.current_date = new_date
# Update tab title
current_index = self.tab_widget.currentIndex()
if current_index >= 0:
self.tab_widget.setTabText(current_index, new_date.toString("yyyy-MM-dd"))
# Keep tabs sorted by date
self._reorder_tabs_by_date()
# ----------- History handler ------------# # ----------- History handler ------------#
def _open_history(self): def _open_history(self):
if hasattr(self.editor, "current_date"): if hasattr(self.editor, "current_date"):
@ -977,52 +1035,6 @@ class MainWindow(QMainWindow):
for path_str in paths: for path_str in paths:
self.editor.insert_image_from_path(Path(path_str)) self.editor.insert_image_from_path(Path(path_str))
# --------------- Database saving of content ---------------- #
def _save_date(self, date_iso: str, explicit: bool = False, note: str = "autosave"):
"""
Save editor contents into the given date. Shows status on success.
explicit=True means user invoked Save: show feedback even if nothing changed.
"""
if not self._dirty and not explicit:
return
text = self.editor.to_markdown()
try:
self.db.save_new_version(date_iso, text, note)
except Exception as e:
QMessageBox.critical(self, "Save Error", str(e))
return
self._dirty = False
self._refresh_calendar_marks()
# Feedback in the status bar
from datetime import datetime as _dt
self.statusBar().showMessage(
f"Saved {date_iso} at {_dt.now().strftime('%H:%M:%S')}", 2000
)
def _save_current(self, explicit: bool = False):
"""Save the current editor's content."""
try:
self._save_timer.stop()
except Exception:
pass
if explicit:
# Prompt for a note
dlg = SaveDialog(self)
if dlg.exec() != QDialog.Accepted:
return
note = dlg.note_text()
else:
note = "autosave"
# Save the current editor's date
date_iso = self.editor.current_date.toString("yyyy-MM-dd")
self._save_date(date_iso, explicit, note)
try:
self._save_timer.start()
except Exception:
pass
# ----------- Settings handler ------------# # ----------- Settings handler ------------#
def _open_settings(self): def _open_settings(self):
dlg = SettingsDialog(self.cfg, self.db, self) dlg = SettingsDialog(self.cfg, self.db, self)
@ -1218,12 +1230,9 @@ If you want an encrypted backup, choose Backup instead of Export.
self._idle_timer.stop() self._idle_timer.stop()
# If currently locked, unlock when user disables the timer: # If currently locked, unlock when user disables the timer:
if getattr(self, "_locked", False): if getattr(self, "_locked", False):
try: self._locked = False
self._locked = False if hasattr(self, "_lock_overlay"):
if hasattr(self, "_lock_overlay"): self._lock_overlay.hide()
self._lock_overlay.hide()
except Exception:
pass
else: else:
self._idle_timer.setInterval(minutes * 60 * 1000) self._idle_timer.setInterval(minutes * 60 * 1000)
if not getattr(self, "_locked", False): if not getattr(self, "_locked", False):
@ -1232,12 +1241,9 @@ If you want an encrypted backup, choose Backup instead of Export.
def eventFilter(self, obj, event): def eventFilter(self, obj, event):
# Catch right-clicks on calendar BEFORE selectionChanged can fire # Catch right-clicks on calendar BEFORE selectionChanged can fire
if obj == self.calendar and event.type() == QEvent.MouseButtonPress: if obj == self.calendar and event.type() == QEvent.MouseButtonPress:
try: # QMouseEvent in PySide6
# QMouseEvent in PySide6 if event.button() == Qt.RightButton:
if event.button() == Qt.RightButton: self._showing_context_menu = True
self._showing_context_menu = True
except Exception:
pass
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()
@ -1290,20 +1296,17 @@ If you want an encrypted backup, choose Backup instead of Export.
# ----------------- Close handlers ----------------- # # ----------------- Close handlers ----------------- #
def closeEvent(self, event): def closeEvent(self, event):
try: # Save window position
# Save window position self.settings.setValue("main/geometry", self.saveGeometry())
self.settings.setValue("main/geometry", self.saveGeometry()) self.settings.setValue("main/windowState", self.saveState())
self.settings.setValue("main/windowState", self.saveState()) self.settings.setValue("main/maximized", self.isMaximized())
self.settings.setValue("main/maximized", self.isMaximized())
# Ensure we save all tabs before closing # Ensure we save all tabs before closing
for i in range(self.tab_widget.count()): for i in range(self.tab_widget.count()):
editor = self.tab_widget.widget(i) editor = self.tab_widget.widget(i)
if editor: if editor:
self._save_editor_content(editor) self._save_editor_content(editor)
self.db.close() self.db.close()
except Exception:
pass
super().closeEvent(event) super().closeEvent(event)
# ----------------- Below logic helps focus the editor ----------------- # # ----------------- Below logic helps focus the editor ----------------- #
@ -1339,38 +1342,3 @@ If you want an encrypted backup, choose Backup instead of Export.
super().changeEvent(ev) super().changeEvent(ev)
if ev.type() == QEvent.ActivationChange and self.isActiveWindow(): if ev.type() == QEvent.ActivationChange and self.isActiveWindow():
QTimer.singleShot(0, self._focus_editor_now) QTimer.singleShot(0, self._focus_editor_now)
def _set_editor_markdown_preserve_view(self, markdown: str):
# Save caret/selection and scroll
cur = self.editor.textCursor()
old_pos, old_anchor = cur.position(), cur.anchor()
v = self.editor.verticalScrollBar().value()
h = self.editor.horizontalScrollBar().value()
# Only touch the doc if it actually changed
self.editor.blockSignals(True)
if self.editor.to_markdown() != markdown:
self.editor.from_markdown(markdown)
self.editor.blockSignals(False)
# Restore scroll first
self.editor.verticalScrollBar().setValue(v)
self.editor.horizontalScrollBar().setValue(h)
# Restore caret/selection (bounded to new doc length)
doc_length = self.editor.document().characterCount() - 1
old_pos = min(old_pos, doc_length)
old_anchor = min(old_anchor, doc_length)
cur = self.editor.textCursor()
cur.setPosition(old_anchor)
mode = (
QTextCursor.KeepAnchor if old_anchor != old_pos else QTextCursor.MoveAnchor
)
cur.setPosition(old_pos, mode)
self.editor.setTextCursor(cur)
# Refresh highlights if the theme changed
if hasattr(self, "findBar"):
self.findBar.refresh()

View file

@ -32,11 +32,7 @@ class MarkdownHighlighter(QSyntaxHighlighter):
self.theme_manager = theme_manager self.theme_manager = theme_manager
self._setup_formats() self._setup_formats()
# Recompute formats whenever the app theme changes # Recompute formats whenever the app theme changes
try: self.theme_manager.themeChanged.connect(self._on_theme_changed)
self.theme_manager.themeChanged.connect(self._on_theme_changed)
self.textChanged.connect(self._refresh_codeblock_margins)
except Exception:
pass
def _on_theme_changed(self, *_): def _on_theme_changed(self, *_):
self._setup_formats() self._setup_formats()
@ -57,7 +53,7 @@ class MarkdownHighlighter(QSyntaxHighlighter):
self.strike_format = QTextCharFormat() self.strike_format = QTextCharFormat()
self.strike_format.setFontStrikeOut(True) self.strike_format.setFontStrikeOut(True)
# Code: `code` # Inline code: `code`
mono = QFontDatabase.systemFont(QFontDatabase.FixedFont) mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
self.code_format = QTextCharFormat() self.code_format = QTextCharFormat()
self.code_format.setFont(mono) self.code_format.setFont(mono)
@ -100,36 +96,6 @@ class MarkdownHighlighter(QSyntaxHighlighter):
# Also make them very faint in case they still show # Also make them very faint in case they still show
self.syntax_format.setForeground(QColor(250, 250, 250)) self.syntax_format.setForeground(QColor(250, 250, 250))
def _refresh_codeblock_margins(self):
"""Give code blocks a small left/right margin to separate them visually."""
doc = self.document()
block = doc.begin()
in_code = False
while block.isValid():
txt = block.text().strip()
cursor = QTextCursor(block)
fmt = block.blockFormat()
if txt.startswith("```"):
# fence lines: small vertical spacing, same left indent
need = (12, 6, 6) # left, top, bottom (px-like)
if (fmt.leftMargin(), fmt.topMargin(), fmt.bottomMargin()) != need:
fmt.setLeftMargin(12)
fmt.setRightMargin(6)
fmt.setTopMargin(6)
fmt.setBottomMargin(6)
cursor.setBlockFormat(fmt)
in_code = not in_code
elif in_code:
# inside the code block
if fmt.leftMargin() != 12 or fmt.rightMargin() != 6:
fmt.setLeftMargin(12)
fmt.setRightMargin(6)
cursor.setBlockFormat(fmt)
block = block.next()
def highlightBlock(self, text: str): def highlightBlock(self, text: str):
"""Apply formatting to a block of text based on markdown syntax.""" """Apply formatting to a block of text based on markdown syntax."""
if not text: if not text:
@ -244,12 +210,6 @@ class MarkdownEditor(QTextEdit):
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp") _IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp")
# Checkbox characters (Unicode for display, markdown for storage)
_CHECK_UNCHECKED_DISPLAY = ""
_CHECK_CHECKED_DISPLAY = ""
_CHECK_UNCHECKED_STORAGE = "[ ]"
_CHECK_CHECKED_STORAGE = "[x]"
def __init__(self, theme_manager: ThemeManager, *args, **kwargs): def __init__(self, theme_manager: ThemeManager, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -267,6 +227,12 @@ class MarkdownEditor(QTextEdit):
font.setPointSize(10) font.setPointSize(10)
self.setFont(font) self.setFont(font)
# Checkbox characters (Unicode for display, markdown for storage)
self._CHECK_UNCHECKED_DISPLAY = ""
self._CHECK_CHECKED_DISPLAY = ""
self._CHECK_UNCHECKED_STORAGE = "[ ]"
self._CHECK_CHECKED_STORAGE = "[x]"
# Install syntax highlighter # Install syntax highlighter
self.highlighter = MarkdownHighlighter(self.document(), theme_manager) self.highlighter = MarkdownHighlighter(self.document(), theme_manager)
@ -300,12 +266,16 @@ class MarkdownEditor(QTextEdit):
line = block.text() line = block.text()
pos_in_block = c.position() - block.position() pos_in_block = c.position() - block.position()
# Transform only this line: # Transform markldown checkboxes and 'TODO' to unicode checkboxes
# - "TODO " at start (with optional indent) -> "- ☐ "
# - "- [ ] " -> " ☐ " and "- [x] " -> " ☑ "
def transform_line(s: str) -> str: def transform_line(s: str) -> str:
s = s.replace("- [x] ", f"{self._CHECK_CHECKED_DISPLAY} ") s = s.replace(
s = s.replace("- [ ] ", f"{self._CHECK_UNCHECKED_DISPLAY} ") f"- {self._CHECK_CHECKED_STORAGE} ",
f"{self._CHECK_CHECKED_DISPLAY} ",
)
s = s.replace(
f"- {self._CHECK_UNCHECKED_STORAGE} ",
f"{self._CHECK_UNCHECKED_DISPLAY} ",
)
s = re.sub( s = re.sub(
r"^([ \t]*)TODO\b[:\-]?\s+", r"^([ \t]*)TODO\b[:\-]?\s+",
lambda m: f"{m.group(1)}\n{self._CHECK_UNCHECKED_DISPLAY} ", lambda m: f"{m.group(1)}\n{self._CHECK_UNCHECKED_DISPLAY} ",
@ -332,13 +302,17 @@ class MarkdownEditor(QTextEdit):
self._updating = False self._updating = False
def to_markdown(self) -> str: def to_markdown(self) -> str:
"""Export current content as markdown (convert Unicode checkboxes back to markdown).""" """Export current content as markdown."""
# First, extract any embedded images and convert to markdown # First, extract any embedded images and convert to markdown
text = self._extract_images_to_markdown() text = self._extract_images_to_markdown()
# Convert Unicode checkboxes back to markdown syntax # Convert Unicode checkboxes back to markdown syntax
text = text.replace(f"{self._CHECK_CHECKED_DISPLAY} ", "- [x] ") text = text.replace(
text = text.replace(f"{self._CHECK_UNCHECKED_DISPLAY} ", "- [ ] ") f"{self._CHECK_CHECKED_DISPLAY} ", f"- {self._CHECK_CHECKED_STORAGE} "
)
text = text.replace(
f"{self._CHECK_UNCHECKED_DISPLAY} ", f"- {self._CHECK_UNCHECKED_STORAGE} "
)
return text return text
@ -377,13 +351,13 @@ class MarkdownEditor(QTextEdit):
return "\n".join(result) return "\n".join(result)
def from_markdown(self, markdown_text: str): def from_markdown(self, markdown_text: str):
"""Load markdown text into the editor (convert markdown checkboxes to Unicode).""" """Load markdown text into the editor."""
# Convert markdown checkboxes to Unicode for display # Convert markdown checkboxes to Unicode for display
display_text = markdown_text.replace( display_text = markdown_text.replace(
"- [x] ", f"{self._CHECK_CHECKED_DISPLAY} " f"- {self._CHECK_CHECKED_STORAGE} ", f"{self._CHECK_CHECKED_DISPLAY} "
) )
display_text = display_text.replace( display_text = display_text.replace(
"- [ ] ", f"{self._CHECK_UNCHECKED_DISPLAY} " f"- {self._CHECK_UNCHECKED_STORAGE} ", f"{self._CHECK_UNCHECKED_DISPLAY} "
) )
# Also convert any plain 'TODO ' at the start of a line to an unchecked checkbox # Also convert any plain 'TODO ' at the start of a line to an unchecked checkbox
display_text = re.sub( display_text = re.sub(
@ -420,40 +394,34 @@ class MarkdownEditor(QTextEdit):
mime_type = match.group(2) mime_type = match.group(2)
b64_data = match.group(3) b64_data = match.group(3)
try: # Decode base64 to image
# Decode base64 to image img_bytes = base64.b64decode(b64_data)
img_bytes = base64.b64decode(b64_data) image = QImage.fromData(img_bytes)
image = QImage.fromData(img_bytes)
if image.isNull(): if image.isNull():
continue
# Use original image size - no scaling
original_width = image.width()
original_height = image.height()
# Create image format with original base64
img_format = QTextImageFormat()
img_format.setName(f"data:image/{mime_type};base64,{b64_data}")
img_format.setWidth(original_width)
img_format.setHeight(original_height)
# Add image to document resources
self.document().addResource(
QTextDocument.ResourceType.ImageResource, img_format.name(), image
)
# Replace markdown with rendered image
cursor = QTextCursor(self.document())
cursor.setPosition(match.start())
cursor.setPosition(match.end(), QTextCursor.MoveMode.KeepAnchor)
cursor.insertImage(img_format)
except Exception as e:
# If image fails to render, leave the markdown as-is
print(f"Failed to render image: {e}")
continue continue
# Use original image size - no scaling
original_width = image.width()
original_height = image.height()
# Create image format with original base64
img_format = QTextImageFormat()
img_format.setName(f"data:image/{mime_type};base64,{b64_data}")
img_format.setWidth(original_width)
img_format.setHeight(original_height)
# Add image to document resources
self.document().addResource(
QTextDocument.ResourceType.ImageResource, img_format.name(), image
)
# Replace markdown with rendered image
cursor = QTextCursor(self.document())
cursor.setPosition(match.start())
cursor.setPosition(match.end(), QTextCursor.MoveMode.KeepAnchor)
cursor.insertImage(img_format)
def _get_current_line(self) -> str: def _get_current_line(self) -> str:
"""Get the text of the current line.""" """Get the text of the current line."""
cursor = self.textCursor() cursor = self.textCursor()
@ -616,9 +584,8 @@ class MarkdownEditor(QTextEdit):
def char_rect_at(doc_pos, ch): def char_rect_at(doc_pos, ch):
c = QTextCursor(self.document()) c = QTextCursor(self.document())
c.setPosition(doc_pos) c.setPosition(doc_pos)
start_rect = self.cursorRect( # caret rect at char start (viewport coords)
c start_rect = self.cursorRect(c)
) # caret rect at char start (viewport coords)
# Use the actual font at this position for an accurate width # Use the actual font at this position for an accurate width
fmt_font = ( fmt_font = (
@ -638,9 +605,8 @@ class MarkdownEditor(QTextEdit):
icon = self._CHECK_CHECKED_DISPLAY icon = self._CHECK_CHECKED_DISPLAY
if icon: if icon:
doc_pos = ( # absolute document position of the icon
block.position() + i doc_pos = block.position() + i
) # absolute document position of the icon
r = char_rect_at(doc_pos, icon) r = char_rect_at(doc_pos, icon)
if r.contains(pt): if r.contains(pt):
@ -653,9 +619,10 @@ class MarkdownEditor(QTextEdit):
edit = QTextCursor(self.document()) edit = QTextCursor(self.document())
edit.beginEditBlock() edit.beginEditBlock()
edit.setPosition(doc_pos) edit.setPosition(doc_pos)
# icon + space
edit.movePosition( edit.movePosition(
QTextCursor.Right, QTextCursor.KeepAnchor, len(icon) + 1 QTextCursor.Right, QTextCursor.KeepAnchor, len(icon) + 1
) # icon + space )
edit.insertText(f"{new_icon} ") edit.insertText(f"{new_icon} ")
edit.endEditBlock() edit.endEditBlock()
return # handled return # handled
@ -745,7 +712,7 @@ class MarkdownEditor(QTextEdit):
if cursor.hasSelection(): if cursor.hasSelection():
# Wrap selection in code fence # Wrap selection in code fence
selected = cursor.selectedText() selected = cursor.selectedText()
# Note: selectedText() uses Unicode paragraph separator, replace with newline # selectedText() uses Unicode paragraph separator, replace with newline
selected = selected.replace("\u2029", "\n") selected = selected.replace("\u2029", "\n")
new_text = f"```\n{selected}\n```" new_text = f"```\n{selected}\n```"
cursor.insertText(new_text) cursor.insertText(new_text)
@ -881,7 +848,7 @@ class MarkdownEditor(QTextEdit):
if not path.exists(): if not path.exists():
return return
# Read the ORIGINAL image file bytes for base64 encoding # Read the original image file bytes for base64 encoding
with open(path, "rb") as f: with open(path, "rb") as f:
img_data = f.read() img_data = f.read()
@ -905,17 +872,13 @@ class MarkdownEditor(QTextEdit):
if image.isNull(): if image.isNull():
return return
# Use ORIGINAL size - no scaling!
original_width = image.width()
original_height = image.height()
# Create image format with original base64 # Create image format with original base64
img_format = QTextImageFormat() img_format = QTextImageFormat()
img_format.setName(f"data:image/{mime_type};base64,{b64_data}") img_format.setName(f"data:image/{mime_type};base64,{b64_data}")
img_format.setWidth(original_width) img_format.setWidth(image.width())
img_format.setHeight(original_height) img_format.setHeight(image.height())
# Add ORIGINAL image to document resources # Add original image to document resources
self.document().addResource( self.document().addResource(
QTextDocument.ResourceType.ImageResource, img_format.name(), image QTextDocument.ResourceType.ImageResource, img_format.name(), image
) )

View file

@ -66,10 +66,7 @@ class Search(QWidget):
self.resultDatesChanged.emit([]) # clear highlights self.resultDatesChanged.emit([]) # clear highlights
return return
try: rows: Iterable[Row] = self._db.search_entries(q)
rows: Iterable[Row] = self._db.search_entries(q)
except Exception:
rows = []
self._populate_results(q, rows) self._populate_results(q, rows)

View file

@ -157,7 +157,8 @@ class SettingsDialog(QDialog):
priv.addWidget(self.idle_spin, 0, Qt.AlignLeft) priv.addWidget(self.idle_spin, 0, Qt.AlignLeft)
# Explanation for idle option (autolock) # Explanation for idle option (autolock)
self.idle_spin_label = QLabel( self.idle_spin_label = QLabel(
"Bouquin will automatically lock the notepad after this length of time, after which you'll need to re-enter the key to unlock it. " "Bouquin will automatically lock the notepad after this length of time, "
"after which you'll need to re-enter the key to unlock it. "
"Set to 0 (never) to never lock." "Set to 0 (never) to never lock."
) )
self.idle_spin_label.setWordWrap(True) self.idle_spin_label.setWordWrap(True)
@ -198,7 +199,7 @@ class SettingsDialog(QDialog):
self.compact_label.setPalette(cpal) self.compact_label.setPalette(cpal)
maint_row = QHBoxLayout() maint_row = QHBoxLayout()
maint_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the button maint_row.setContentsMargins(24, 0, 0, 0)
maint_row.addWidget(self.compact_label) maint_row.addWidget(self.compact_label)
maint.addLayout(maint_row) maint.addLayout(maint_row)
@ -270,7 +271,7 @@ class SettingsDialog(QDialog):
self, "Key changed", "The notebook was re-encrypted with the new key!" self, "Key changed", "The notebook was re-encrypted with the new key!"
) )
except Exception as e: except Exception as e:
QMessageBox.critical(self, "Error", f"Could not change key:\n{e}") QMessageBox.critical(self, "Error", e)
@Slot(bool) @Slot(bool)
def _save_key_btn_clicked(self, checked: bool): def _save_key_btn_clicked(self, checked: bool):
@ -291,11 +292,9 @@ class SettingsDialog(QDialog):
def _compact_btn_clicked(self): def _compact_btn_clicked(self):
try: try:
self._db.compact() self._db.compact()
QMessageBox.information( QMessageBox.information(self, "Success", "Database compacted successfully!")
self, "Compact complete", "Database compacted successfully!"
)
except Exception as e: except Exception as e:
QMessageBox.critical(self, "Error", f"Could not compact database:\n{e}") QMessageBox.critical(self, "Error", e)
@property @property
def config(self) -> DBConfig: def config(self) -> DBConfig:

View file

@ -1,9 +1,10 @@
import pytest import pytest
import json, csv import json, csv
import datetime as dt import datetime as dt
from sqlcipher3 import dbapi2 as sqlite
from bouquin.db import DBManager from bouquin.db import DBManager
def _today(): def _today():
return dt.date.today().isoformat() return dt.date.today().isoformat()
@ -101,7 +102,6 @@ def test_rekey_and_reopen(fresh_db, tmp_db_cfg):
fresh_db.rekey("new-key-123") fresh_db.rekey("new-key-123")
fresh_db.close() fresh_db.close()
tmp_db_cfg.key = "new-key-123" tmp_db_cfg.key = "new-key-123"
db2 = DBManager(tmp_db_cfg) db2 = DBManager(tmp_db_cfg)
assert db2.connect() assert db2.connect()
@ -112,3 +112,57 @@ def test_rekey_and_reopen(fresh_db, tmp_db_cfg):
def test_compact_and_close_dont_crash(fresh_db): def test_compact_and_close_dont_crash(fresh_db):
fresh_db.compact() fresh_db.compact()
fresh_db.close() fresh_db.close()
def test_connect_integrity_failure(monkeypatch, tmp_db_cfg):
db = DBManager(tmp_db_cfg)
# simulate cursor() ok, but integrity check raising
called = {"ok": False}
def bad_integrity(self):
called["ok"] = True
raise sqlite.Error("bad cipher")
monkeypatch.setattr(DBManager, "_integrity_ok", bad_integrity, raising=True)
ok = db.connect()
assert not ok and called["ok"]
assert db.conn is None
def test_rekey_reopen_failure(monkeypatch, tmp_db_cfg):
db = DBManager(tmp_db_cfg)
assert db.connect()
# Monkeypatch connect() on the instance so the reconnect attempt fails
def fail_connect():
return False
monkeypatch.setattr(db, "connect", fail_connect, raising=False)
with pytest.raises(sqlite.Error):
db.rekey("newkey")
def test_revert_wrong_date_raises(fresh_db):
d1, d2 = "2024-01-01", "2024-01-02"
v1_id, _ = fresh_db.save_new_version(d1, "one", "seed")
fresh_db.save_new_version(d2, "two", "seed")
with pytest.raises(ValueError):
fresh_db.revert_to_version(d2, version_id=v1_id)
def test_compact_error_path(monkeypatch, tmp_db_cfg):
db = DBManager(tmp_db_cfg)
assert db.connect()
# Replace cursor.execute to raise to hit except branch
class BadCur:
def execute(self, *a, **k):
raise RuntimeError("boom")
class BadConn:
def cursor(self):
return BadCur()
db.conn = BadConn()
# Should not raise; just print error
db.compact()

View file

@ -5,6 +5,7 @@ from bouquin.markdown_editor import MarkdownEditor
from bouquin.theme import ThemeManager, ThemeConfig, Theme from bouquin.theme import ThemeManager, ThemeConfig, Theme
from bouquin.find_bar import FindBar from bouquin.find_bar import FindBar
@pytest.fixture @pytest.fixture
def editor(app, qtbot): def editor(app, qtbot):
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
@ -51,3 +52,84 @@ def test_show_bar_seeds_selection(qtbot, editor):
fb.show_bar() fb.show_bar()
assert fb.edit.text().lower() == "alpha" assert fb.edit.text().lower() == "alpha"
fb.hide_bar() fb.hide_bar()
def test_show_bar_no_editor(qtbot, app):
fb = FindBar(lambda: None)
qtbot.addWidget(fb)
fb.show_bar() # should early return without crashing and not become visible
assert not fb.isVisible()
def test_show_bar_ignores_multi_paragraph_selection(qtbot, editor):
editor.from_markdown("alpha\n\nbeta")
c = editor.textCursor()
c.movePosition(QTextCursor.Start)
# Select across the paragraph separator U+2029 equivalent select more than one block
c.movePosition(QTextCursor.End, QTextCursor.KeepAnchor)
editor.setTextCursor(c)
fb = FindBar(lambda: editor, parent=editor)
qtbot.addWidget(fb)
fb.show_bar()
assert fb.edit.text() == "" # should not seed with multi-paragraph
fb.hide_bar()
def test_find_wraps_and_bumps_caret(qtbot, editor):
editor.from_markdown("alpha alpha alpha")
fb = FindBar(lambda: editor, parent=editor)
qtbot.addWidget(fb)
fb.edit.setText("alpha")
# Select the first occurrence so caret bumping path triggers
c = editor.textCursor()
c.movePosition(QTextCursor.Start)
c.movePosition(QTextCursor.NextWord, QTextCursor.KeepAnchor)
editor.setTextCursor(c)
fb.find_next() # should bump to after current selection then find next
sel = editor.textCursor().selectedText()
assert sel.lower() == "alpha"
# Force wrap to start by moving cursor to end then searching next
c = editor.textCursor()
c.movePosition(QTextCursor.End)
editor.setTextCursor(c)
fb.find_next() # triggers wrap-to-start path
assert editor.textCursor().hasSelection()
def test_update_highlight_clear_when_empty(qtbot, editor):
editor.from_markdown("find me find me")
fb = FindBar(lambda: editor, parent=editor)
qtbot.addWidget(fb)
fb.edit.setText("find")
fb._update_highlight()
assert editor.extraSelections() # some highlights present
fb.edit.setText("")
fb._update_highlight() # should clear
assert not editor.extraSelections()
@pytest.mark.gui
def test_maybe_hide_and_wrap_prev(qtbot, editor):
editor.setPlainText("a a a")
fb = FindBar(editor=editor, shortcut_parent=editor)
qtbot.addWidget(editor)
qtbot.addWidget(fb)
editor.show()
fb.show()
fb.edit.setText("a")
fb._update_highlight()
assert fb.isVisible()
fb._maybe_hide()
assert not fb.isVisible()
fb.show_bar()
c = editor.textCursor()
c.movePosition(QTextCursor.Start)
editor.setTextCursor(c)
fb.find_prev()

View file

@ -1,5 +1,5 @@
from PySide6.QtWidgets import QWidget from PySide6.QtWidgets import QWidget, QMessageBox
from PySide6.QtCore import Qt from PySide6.QtCore import Qt, QTimer
from bouquin.history_dialog import HistoryDialog from bouquin.history_dialog import HistoryDialog
@ -17,3 +17,69 @@ def test_history_dialog_lists_and_revert(qtbot, fresh_db):
dlg.list.setCurrentRow(1) dlg.list.setCurrentRow(1)
qtbot.mouseClick(dlg.btn_revert, Qt.LeftButton) qtbot.mouseClick(dlg.btn_revert, Qt.LeftButton)
assert fresh_db.get_entry(d) == "v1" assert fresh_db.get_entry(d) == "v1"
def test_history_dialog_no_selection_clears(qtbot, fresh_db):
d = "2001-01-01"
fresh_db.save_new_version(d, "v1", "first")
w = QWidget()
dlg = HistoryDialog(fresh_db, d, parent=w)
qtbot.addWidget(dlg)
dlg.show()
# Clear selection (no current item) and call slot
dlg.list.setCurrentItem(None)
dlg._on_select()
assert dlg.preview.toPlainText() == ""
assert dlg.diff.toPlainText() == ""
assert not dlg.btn_revert.isEnabled()
def test_history_dialog_revert_same_version_noop(qtbot, fresh_db):
d = "2001-01-01"
# Only one version; that's the current
vid, _ = fresh_db.save_new_version(d, "seed", "note")
w = QWidget()
dlg = HistoryDialog(fresh_db, d, parent=w)
qtbot.addWidget(dlg)
dlg.show()
# Pick the only item (current)
dlg.list.setCurrentRow(0)
# Clicking revert should simply return (no change)
before = fresh_db.get_entry(d)
dlg._revert()
after = fresh_db.get_entry(d)
assert before == after
def test_history_dialog_revert_error_shows_message(qtbot, fresh_db):
d = "2001-01-02"
fresh_db.save_new_version(d, "v1", "first")
w = QWidget()
dlg = HistoryDialog(fresh_db, d, parent=w)
qtbot.addWidget(dlg)
dlg.show()
# Select the row
dlg.list.setCurrentRow(0)
# Monkeypatch db to raise inside revert_to_version to hit except path
def boom(date_iso, version_id):
raise RuntimeError("nope")
dlg._db.revert_to_version = boom
# Auto-accept any QMessageBox that appears
def _pump():
for m in QMessageBox.instances():
m.accept()
t = QTimer()
t.setInterval(10)
t.timeout.connect(_pump)
t.start()
try:
dlg._revert()
finally:
t.stop()

View file

@ -1,4 +1,6 @@
import importlib import importlib
import runpy
import pytest
def test_main_module_has_main(): def test_main_module_has_main():
@ -9,3 +11,84 @@ def test_main_module_has_main():
def test_dunder_main_imports_main(): def test_dunder_main_imports_main():
m = importlib.import_module("bouquin.__main__") m = importlib.import_module("bouquin.__main__")
assert hasattr(m, "main") assert hasattr(m, "main")
def test_dunder_main_calls_main(monkeypatch):
called = {"ok": False}
def fake_main():
called["ok"] = True
# Replace real main with a stub to avoid launching Qt event loop
monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen")
# Ensure that when __main__ imports from .main it gets our stub
import bouquin.main as real_main
monkeypatch.setattr(real_main, "main", fake_main, raising=True)
# Execute the module as a script
runpy.run_module("bouquin.__main__", run_name="__main__")
assert called["ok"]
def test_main_creates_and_shows(monkeypatch):
# Create a fake QApplication with the minimal API
class FakeApp:
def __init__(self, argv):
self.ok = True
def setApplicationName(self, *_):
pass
def setOrganizationName(self, *_):
pass
def exec(self):
return 0
class FakeWin:
def __init__(self, themes=None):
self.shown = False
def show(self):
self.shown = True
class FakeSettings:
def value(self, k, default=None):
return "light" if k == "ui/theme" else default
# Patch imports inside bouquin.main
import bouquin.main as m
monkeypatch.setattr(m, "QApplication", FakeApp, raising=True)
monkeypatch.setattr(m, "MainWindow", FakeWin, raising=True)
# Theme classes
class FakeTM:
def __init__(self, app, cfg):
pass
def apply(self, theme):
pass
class FakeTheme:
def __init__(self, s):
pass
class FakeCfg:
def __init__(self, theme):
self.theme = theme
monkeypatch.setattr(m, "ThemeManager", FakeTM, raising=True)
monkeypatch.setattr(m, "Theme", FakeTheme, raising=True)
monkeypatch.setattr(m, "ThemeConfig", FakeCfg, raising=True)
# get_settings() used inside main()
def fake_get_settings():
return FakeSettings()
monkeypatch.setattr(m, "get_settings", fake_get_settings, raising=True)
# Run
with pytest.raises(SystemExit) as e:
m.main()
assert e.value.code == 0

View file

@ -1,12 +1,12 @@
import pytest import pytest
from PySide6.QtCore import QDate import bouquin.main_window as mwmod
from bouquin.main_window import MainWindow from bouquin.main_window import MainWindow
from bouquin.theme import Theme, ThemeConfig, ThemeManager from bouquin.theme import Theme, ThemeConfig, ThemeManager
from bouquin.settings import get_settings from bouquin.settings import get_settings
from bouquin.key_prompt import KeyPrompt from bouquin.key_prompt import KeyPrompt
from PySide6.QtCore import QTimer from PySide6.QtCore import QEvent, QDate, QTimer
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QTableView, QApplication
@pytest.mark.gui @pytest.mark.gui
def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db): def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db):
@ -71,3 +71,342 @@ def test_load_yesterday_todos_moves_items(qtbot, app, tmp_db_cfg, fresh_db):
assert "carry me" in w.editor.to_markdown() assert "carry me" in w.editor.to_markdown()
y_txt = fresh_db.get_entry(y) y_txt = fresh_db.get_entry(y)
assert "carry me" not in y_txt or "- [ ]" not in y_txt assert "carry me" not in y_txt or "- [ ]" not in y_txt
@pytest.mark.gui
def test_open_docs_and_bugs_warning(qtbot, app, fresh_db, tmp_path, monkeypatch):
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
w = MainWindow(themes=themes)
qtbot.addWidget(w)
w.show()
# Force QDesktopServices.openUrl to fail so the warning path executes
called = {"docs": False, "bugs": False}
def fake_open(url):
# return False to force warning path
return False
mwmod.QDesktopServices.openUrl = fake_open # minimal monkeypatch
class DummyMB:
@staticmethod
def warning(parent, title, text, *rest):
t = str(text)
if "wiki" in t:
called["docs"] = True
if "forms/mig5/contact" in t or "contact" in t:
called["bugs"] = True
return 0
monkeypatch.setattr(mwmod, "QMessageBox", DummyMB, raising=True) # capture warnings
# Trigger both actions
w._open_docs()
w._open_bugs()
assert called["docs"] and called["bugs"]
@pytest.mark.gui
def test_export_success_and_error(qtbot, app, fresh_db, tmp_path, monkeypatch):
# Seed some content
fresh_db.save_new_version("2001-01-01", "alpha", "n1")
fresh_db.save_new_version("2001-01-02", "beta", "n2")
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
from bouquin.settings import get_settings
s = get_settings()
s.setValue("db/path", str(fresh_db.cfg.path))
s.setValue("db/key", fresh_db.cfg.key)
s.setValue("ui/idle_minutes", 0)
s.setValue("ui/theme", "light")
w = MainWindow(themes=themes)
qtbot.addWidget(w)
w.show()
# Save as Markdown without extension -> should append .md and write file
dest1 = tmp_path / "export_one" # no suffix
def fake_save1(*a, **k):
return str(dest1), "Markdown (*.md)"
monkeypatch.setattr(mwmod.QFileDialog, "getSaveFileName", staticmethod(fake_save1))
info_log = {"ok": False}
# Auto-accept the warning dialog
monkeypatch.setattr(
mwmod.QMessageBox, "exec", lambda self: mwmod.QMessageBox.Yes, raising=False
)
info_log = {"ok": False}
monkeypatch.setattr(
mwmod.QMessageBox,
"information",
staticmethod(lambda *a, **k: info_log.__setitem__("ok", True) or 0),
raising=False,
)
monkeypatch.setattr(
mwmod.QMessageBox,
"critical",
staticmethod(
lambda *a, **k: (_ for _ in ()).throw(AssertionError("Unexpected critical"))
),
raising=False,
)
w._export()
assert dest1.with_suffix(".md").exists()
assert info_log["ok"]
# Now force an exception during export to hit error branch (patch the window's DB)
def boom():
raise RuntimeError("explode")
monkeypatch.setattr(w.db, "get_all_entries", boom, raising=False)
# Different filename to avoid overwriting
dest2 = tmp_path / "export_two"
def fake_save2(*a, **k):
return str(dest2), "CSV (*.csv)"
monkeypatch.setattr(mwmod.QFileDialog, "getSaveFileName", staticmethod(fake_save2))
errs = {"hit": False}
# Auto-accept the warning dialog and capture the error message
monkeypatch.setattr(
mwmod.QMessageBox, "exec", lambda self: mwmod.QMessageBox.Yes, raising=False
)
monkeypatch.setattr(
mwmod.QMessageBox, "information", staticmethod(lambda *a, **k: 0), raising=False
)
monkeypatch.setattr(
mwmod.QMessageBox,
"critical",
staticmethod(lambda *a, **k: errs.__setitem__("hit", True) or 0),
raising=False,
)
w._export()
assert errs["hit"]
@pytest.mark.gui
def test_backup_path(qtbot, app, fresh_db, tmp_path, monkeypatch):
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
# wire DB settings the window reads
from bouquin.settings import get_settings
s = get_settings()
s.setValue("db/path", str(fresh_db.cfg.path))
s.setValue("db/key", fresh_db.cfg.key)
s.setValue("ui/idle_minutes", 0)
s.setValue("ui/theme", "light")
w = MainWindow(themes=themes)
qtbot.addWidget(w)
w.show()
# Pretend user picked a filename with no suffix -> .db should be appended
dest = tmp_path / "backupfile"
monkeypatch.setattr(
mwmod.QFileDialog,
"getSaveFileName",
staticmethod(lambda *a, **k: (str(dest), "SQLCipher (*.db)")),
raising=False,
)
# Avoid any modal dialogs and record the success message
hit = {"info": False, "text": None}
monkeypatch.setattr(
mwmod.QMessageBox,
"information",
staticmethod(
lambda parent, title, text, *a, **k: (
hit.__setitem__("info", True),
hit.__setitem__("text", str(text)),
0,
)[-1]
),
raising=False,
)
monkeypatch.setattr(
mwmod.QMessageBox, "critical", staticmethod(lambda *a, **k: 0), raising=False
)
# Stub the *export* itself to be instant and non-blocking
called = {"path": None}
monkeypatch.setattr(
w.db, "export_sqlcipher", lambda p: called.__setitem__("path", p), raising=False
)
w._backup()
# Assertions: suffix added, export invoked, success toast shown
assert called["path"] == str(dest.with_suffix(".db"))
assert hit["info"]
assert str(dest.with_suffix(".db")) in hit["text"]
@pytest.mark.gui
def test_close_tab_edges_and_autosave(qtbot, app, fresh_db, monkeypatch):
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
from bouquin.settings import get_settings
s = get_settings()
s.setValue("db/path", str(fresh_db.cfg.path))
s.setValue("db/key", fresh_db.cfg.key)
s.setValue("ui/idle_minutes", 0)
s.setValue("ui/theme", "light")
w = MainWindow(themes=themes)
qtbot.addWidget(w)
w.show()
# Create exactly one extra tab (there is already one from __init__)
d1 = QDate(2020, 1, 1)
w._open_date_in_tab(d1)
assert w.tab_widget.count() == 2
# Close one tab: should call _save_editor_content on its editor
saved = {"called": False}
def fake_save_editor(editor):
saved["called"] = True
monkeypatch.setattr(w, "_save_editor_content", fake_save_editor, raising=True)
w._close_tab(0)
assert saved["called"]
# Now only one tab remains; closing should no-op
count_before = w.tab_widget.count()
w._close_tab(0)
assert w.tab_widget.count() == count_before
monkeypatch.delattr(w, "_save_editor_content", raising=False)
@pytest.mark.gui
def test_restore_window_position_variants(qtbot, app, fresh_db, monkeypatch):
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
w = MainWindow(themes=themes)
qtbot.addWidget(w)
w.show()
# Case A: no geometry stored -> should call _move_to_cursor_screen_center
moved = {"hit": False}
monkeypatch.setattr(
w,
"_move_to_cursor_screen_center",
lambda: moved.__setitem__("hit", True),
raising=True,
)
# clear any stored geometry
w.settings.remove("main/geometry")
w.settings.remove("main/windowState")
w.settings.remove("main/maximized")
w._restore_window_position()
assert moved["hit"]
# Case B: geometry present but off-screen -> fallback to move_to_cursor
moved["hit"] = False
# Save a valid geometry then lie that it's offscreen
geom = w.saveGeometry()
w.settings.setValue("main/geometry", geom)
w.settings.setValue("main/windowState", w.saveState())
w.settings.setValue("main/maximized", False)
monkeypatch.setattr(w, "_rect_on_any_screen", lambda r: False, raising=True)
w._restore_window_position()
assert moved["hit"]
# Case C: was_max True triggers showMaximized via QTimer.singleShot
called = {"max": False}
monkeypatch.setattr(
w, "showMaximized", lambda: called.__setitem__("max", True), raising=True
)
monkeypatch.setattr(
mwmod.QTimer, "singleShot", staticmethod(lambda _ms, f: f()), raising=False
)
w.settings.setValue("main/maximized", True)
w._restore_window_position()
assert called["max"]
@pytest.mark.gui
def test_calendar_pos_mapping_and_context_menu(qtbot, app, fresh_db, monkeypatch):
# Seed DB so refresh marks does something
fresh_db.save_new_version("2021-08-15", "note", "")
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
w = MainWindow(themes=themes)
qtbot.addWidget(w)
w.show()
# Show a month with leading days
qd = QDate(2021, 8, 15)
w.calendar.setSelectedDate(qd)
# Grab the internal table view and pick a couple of indices
view = w.calendar.findChild(QTableView, "qt_calendar_calendarview")
model = view.model()
# Find the first index belonging to current month (day == 1)
first_idx = None
for r in range(model.rowCount()):
for c in range(model.columnCount()):
if model.index(r, c).data() == 1:
first_idx = model.index(r, c)
break
if first_idx:
break
assert first_idx is not None
# A cell before 'first_idx' should map to previous month
col0 = 0 if first_idx.column() > 0 else 1
idx_prev = model.index(first_idx.row(), col0)
vp_pos = view.visualRect(idx_prev).center()
global_pos = view.viewport().mapToGlobal(vp_pos)
cal_pos = w.calendar.mapFromGlobal(global_pos)
date_prev = w._date_from_calendar_pos(cal_pos)
assert isinstance(date_prev, QDate) and date_prev.isValid()
# A cell after the last day should map to next month
last_day = QDate(qd.year(), qd.month(), 1).addMonths(1).addDays(-1).day()
last_idx = None
for r in range(model.rowCount() - 1, -1, -1):
for c in range(model.columnCount() - 1, -1, -1):
if model.index(r, c).data() == last_day:
last_idx = model.index(r, c)
break
if last_idx:
break
assert last_idx is not None
c_next = min(model.columnCount() - 1, last_idx.column() + 1)
idx_next = model.index(last_idx.row(), c_next)
vp_pos2 = view.visualRect(idx_next).center()
global_pos2 = view.viewport().mapToGlobal(vp_pos2)
cal_pos2 = w.calendar.mapFromGlobal(global_pos2)
date_next = w._date_from_calendar_pos(cal_pos2)
assert isinstance(date_next, QDate) and date_next.isValid()
# Context menu path: return the "Open in New Tab" action
class DummyMenu:
def __init__(self, parent=None):
self._action = object()
def addAction(self, text):
return self._action
def exec_(self, *args, **kwargs):
return self._action
monkeypatch.setattr(mwmod, "QMenu", DummyMenu, raising=True)
w._show_calendar_context_menu(cal_pos)
@pytest.mark.gui
def test_event_filter_keypress_starts_idle_timer(qtbot, app):
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
w = MainWindow(themes=themes)
qtbot.addWidget(w)
w.show()
ev = QEvent(QEvent.KeyPress)
w.eventFilter(w, ev)

View file

@ -1,6 +1,7 @@
import pytest import pytest
from PySide6.QtGui import QImage, QColor from PySide6.QtCore import Qt, QPoint
from PySide6.QtGui import QImage, QColor, QKeyEvent, QTextCursor
from bouquin.markdown_editor import MarkdownEditor from bouquin.markdown_editor import MarkdownEditor
from bouquin.theme import ThemeManager, ThemeConfig, Theme from bouquin.theme import ThemeManager, ThemeConfig, Theme
@ -61,3 +62,84 @@ def test_apply_code_inline(editor):
editor.apply_code() editor.apply_code()
md = editor.to_markdown() md = editor.to_markdown()
assert ("`" in md) or ("```" in md) assert ("`" in md) or ("```" in md)
@pytest.mark.gui
def test_auto_close_code_fence(editor, qtbot):
# Place caret at start and type exactly `` then ` to trigger expansion
editor.setPlainText("")
qtbot.keyClicks(editor, "``")
qtbot.keyClicks(editor, "`") # third backtick triggers fence insertion
txt = editor.toPlainText()
assert "```" in txt and txt.count("```") >= 2
@pytest.mark.gui
def test_checkbox_toggle_by_click(editor, qtbot):
# Load a markdown checkbox
editor.from_markdown("- [ ] task here")
# Verify display token present
display = editor.toPlainText()
assert "" in display
# Click on the first character region to toggle
c = editor.textCursor()
from PySide6.QtGui import QTextCursor
c.movePosition(QTextCursor.StartOfBlock)
editor.setTextCursor(c)
r = editor.cursorRect()
center = r.center()
# Send click slightly right to land within checkbox icon region
pos = QPoint(r.left() + 2, center.y())
qtbot.mouseClick(editor.viewport(), Qt.LeftButton, pos=pos)
# Should have toggled to checked icon
display2 = editor.toPlainText()
assert "" in display2
@pytest.mark.gui
def test_apply_heading_levels(editor, qtbot):
editor.setPlainText("hello")
editor.selectAll()
# H2
editor.apply_heading(18)
assert editor.toPlainText().startswith("## ")
# H3
editor.selectAll()
editor.apply_heading(14)
assert editor.toPlainText().startswith("### ")
# Normal (no heading)
editor.selectAll()
editor.apply_heading(12)
assert not editor.toPlainText().startswith("#")
@pytest.mark.gui
def test_enter_on_nonempty_list_continues(qtbot, editor):
qtbot.addWidget(editor)
editor.show()
editor.from_markdown("- item")
c = editor.textCursor()
c.movePosition(QTextCursor.End)
editor.setTextCursor(c)
ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, "\n")
editor.keyPressEvent(ev)
txt = editor.toPlainText()
assert "\n- " in txt
@pytest.mark.gui
def test_enter_on_empty_list_marks_empty(qtbot, editor):
qtbot.addWidget(editor)
editor.show()
editor.from_markdown("- ")
c = editor.textCursor()
c.movePosition(QTextCursor.End)
editor.setTextCursor(c)
ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier, "\n")
editor.keyPressEvent(ev)
assert editor.toPlainText().startswith("- \n")

View file

@ -1,4 +1,6 @@
import pytest
from bouquin.search import Search from bouquin.search import Search
from PySide6.QtWidgets import QListWidgetItem
def test_search_widget_populates_results(qtbot, fresh_db): def test_search_widget_populates_results(qtbot, fresh_db):
@ -20,3 +22,82 @@ def test_search_widget_populates_results(qtbot, fresh_db):
s.search.setText("") s.search.setText("")
qtbot.wait(50) qtbot.wait(50)
assert s.results.isHidden() assert s.results.isHidden()
def test_open_selected_with_data(qtbot, fresh_db):
s = Search(fresh_db)
qtbot.addWidget(s)
s.show()
seen = []
s.openDateRequested.connect(lambda d: seen.append(d))
it = QListWidgetItem("dummy")
from PySide6.QtCore import Qt
it.setData(Qt.ItemDataRole.UserRole, "1999-12-31")
s.results.addItem(it)
s._open_selected(it)
assert seen == ["1999-12-31"]
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")
def test_open_selected_ignores_no_data(qtbot, fresh_db):
s = Search(fresh_db)
qtbot.addWidget(s)
s.show()
seen = []
s.openDateRequested.connect(lambda d: seen.append(d))
it = QListWidgetItem("dummy")
# No UserRole data set -> should not emit
s._open_selected(it)
assert not seen
def test_make_html_snippet_variants(qtbot, fresh_db):
s = Search(fresh_db)
qtbot.addWidget(s)
s.show()
# Case: query tokens not found -> idx < 0 path; expect right ellipsis when longer than maxlen
src = " ".join(["word"] * 200)
frag, left, right = s._make_html_snippet(src, "nomatch", radius=3, maxlen=30)
assert frag and not left and right
# Case: multiple tokens highlighted
src = "Alpha bravo charlie delta echo"
frag, left, right = s._make_html_snippet(src, "alpha delta", radius=2, maxlen=50)
assert "<b>Alpha</b>" in frag or "<b>alpha</b>" in frag
assert "<b>delta</b>" in frag
@pytest.mark.gui
def test_search_error_path_and_empty_snippet(qtbot, fresh_db, monkeypatch):
s = Search(fresh_db)
qtbot.addWidget(s)
s.show()
s.search.setText("alpha")
frag, left, right = s._make_html_snippet("", "alpha", radius=10, maxlen=40)
assert frag == "" and not left and not right
@pytest.mark.gui
def test_populate_results_shows_both_ellipses(qtbot, fresh_db):
s = Search(fresh_db)
qtbot.addWidget(s)
s.show()
long = "X" * 40 + "alpha" + "Y" * 40
rows = [("2000-01-01", long)]
s._populate_results("alpha", rows)
assert s.results.count() >= 1

View file

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

View file

@ -1,4 +1,3 @@
from pathlib import Path
from bouquin.settings import ( from bouquin.settings import (
get_settings, get_settings,
load_db_config, load_db_config,

View file

@ -1,4 +1,6 @@
import pytest import pytest
import bouquin.settings_dialog as sd
from bouquin.db import DBManager, DBConfig from bouquin.db import DBManager, DBConfig
from bouquin.key_prompt import KeyPrompt from bouquin.key_prompt import KeyPrompt
from bouquin.settings_dialog import SettingsDialog from bouquin.settings_dialog import SettingsDialog
@ -6,6 +8,7 @@ from bouquin.theme import ThemeManager, ThemeConfig, Theme
from PySide6.QtCore import QTimer from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication, QMessageBox, QWidget from PySide6.QtWidgets import QApplication, QMessageBox, QWidget
@pytest.mark.gui @pytest.mark.gui
def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db, tmp_path): 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(...)) # Provide a parent that exposes a real ThemeManager (dialog calls parent().themes.set(...))
@ -161,3 +164,64 @@ def test_change_key_success(qtbot, tmp_path, app):
assert db2.connect() assert db2.connect()
assert "seed" in db2.get_entry("2001-01-01") assert "seed" in db2.get_entry("2001-01-01")
db2.close() db2.close()
def test_settings_compact_error(qtbot, app, monkeypatch, tmp_db_cfg, fresh_db):
# Parent with ThemeManager (dialog uses parent().themes.set(...))
parent = QWidget()
parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
dlg = SettingsDialog(tmp_db_cfg, fresh_db, parent=parent)
qtbot.addWidget(dlg)
dlg.show()
# Monkeypatch db.compact to raise
def boom():
raise RuntimeError("nope")
dlg._db.compact = boom # type: ignore
called = {"critical": False, "title": None, "text": None}
class DummyMB:
@staticmethod
def information(*args, **kwargs):
return 0
@staticmethod
def critical(parent, title, text, *rest):
called["critical"] = True
called["title"] = title
called["text"] = str(text)
return 0
# Swap QMessageBox used inside the dialog module so signature mismatch can't occur
monkeypatch.setattr(sd, "QMessageBox", DummyMB, raising=True)
# Invoke
dlg._compact_btn_clicked()
assert called["critical"]
assert called["title"]
assert called["text"]
@pytest.mark.gui
def test_settings_browse_sets_path(qtbot, app, tmp_path, fresh_db, monkeypatch):
parent = QWidget()
parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
cfg = DBConfig(
path=tmp_path / "x.db", key="k", idle_minutes=0, theme="light", move_todos=True
)
dlg = SettingsDialog(cfg, fresh_db, parent=parent)
qtbot.addWidget(dlg)
dlg.show()
p = tmp_path / "new_file.db"
monkeypatch.setattr(
sd.QFileDialog,
"getSaveFileName",
staticmethod(lambda *a, **k: (str(p), "DB Files (*.db)")),
raising=False,
)
dlg._browse()
assert dlg.path_edit.text().endswith("new_file.db")

View file

@ -13,6 +13,7 @@ def editor(app, qtbot):
ed.show() ed.show()
return ed return ed
@pytest.mark.gui @pytest.mark.gui
def test_toolbar_signals_and_styling(qtbot, editor): def test_toolbar_signals_and_styling(qtbot, editor):
host = QWidget() host = QWidget()
@ -39,3 +40,29 @@ def test_toolbar_signals_and_styling(qtbot, editor):
tb.strikeRequested.emit() tb.strikeRequested.emit()
tb.headingRequested.emit(24) tb.headingRequested.emit(24)
assert editor.to_markdown() assert editor.to_markdown()
def test_style_letter_button_paths(app, qtbot):
parent = QWidget()
qtbot.addWidget(parent)
# Create toolbar
from bouquin.markdown_editor import MarkdownEditor
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
ed = MarkdownEditor(themes)
qtbot.addWidget(ed)
tb = ToolBar(parent)
qtbot.addWidget(tb)
# Action not added to toolbar -> no widget, early return
from PySide6.QtGui import QAction
stray = QAction("Stray", tb)
tb._style_letter_button(stray, "Z") # should not raise
# Now add an action to toolbar and style with tooltip
act = tb.addAction("Temp")
tb._style_letter_button(act, "T", tooltip="Tip here")
btn = tb.widgetForAction(act)
assert btn.toolTip() == "Tip here"
assert btn.accessibleName() == "Tip here"