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

@ -1,9 +1,10 @@
import pytest
import json, csv
import datetime as dt
from sqlcipher3 import dbapi2 as sqlite
from bouquin.db import DBManager
def _today():
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.close()
tmp_db_cfg.key = "new-key-123"
db2 = DBManager(tmp_db_cfg)
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):
fresh_db.compact()
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.find_bar import FindBar
@pytest.fixture
def editor(app, qtbot):
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
@ -51,3 +52,84 @@ def test_show_bar_seeds_selection(qtbot, editor):
fb.show_bar()
assert fb.edit.text().lower() == "alpha"
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.QtCore import Qt
from PySide6.QtWidgets import QWidget, QMessageBox
from PySide6.QtCore import Qt, QTimer
from bouquin.history_dialog import HistoryDialog
@ -17,3 +17,69 @@ def test_history_dialog_lists_and_revert(qtbot, fresh_db):
dlg.list.setCurrentRow(1)
qtbot.mouseClick(dlg.btn_revert, Qt.LeftButton)
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 runpy
import pytest
def test_main_module_has_main():
@ -9,3 +11,84 @@ def test_main_module_has_main():
def test_dunder_main_imports_main():
m = importlib.import_module("bouquin.__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
from PySide6.QtCore import QDate
import bouquin.main_window as mwmod
from bouquin.main_window import MainWindow
from bouquin.theme import Theme, ThemeConfig, ThemeManager
from bouquin.settings import get_settings
from bouquin.key_prompt import KeyPrompt
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication
from PySide6.QtCore import QEvent, QDate, QTimer
from PySide6.QtWidgets import QTableView, QApplication
@pytest.mark.gui
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()
y_txt = fresh_db.get_entry(y)
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
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.theme import ThemeManager, ThemeConfig, Theme
@ -61,3 +62,84 @@ def test_apply_code_inline(editor):
editor.apply_code()
md = editor.to_markdown()
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 PySide6.QtWidgets import QListWidgetItem
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("")
qtbot.wait(50)
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 (
get_settings,
load_db_config,

View file

@ -1,4 +1,6 @@
import pytest
import bouquin.settings_dialog as sd
from bouquin.db import DBManager, DBConfig
from bouquin.key_prompt import KeyPrompt
from bouquin.settings_dialog import SettingsDialog
@ -6,6 +8,7 @@ from bouquin.theme import ThemeManager, ThemeConfig, Theme
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication, QMessageBox, QWidget
@pytest.mark.gui
def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db, tmp_path):
# Provide a parent that exposes a real ThemeManager (dialog calls parent().themes.set(...))
@ -161,3 +164,64 @@ def test_change_key_success(qtbot, tmp_path, app):
assert db2.connect()
assert "seed" in db2.get_entry("2001-01-01")
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()
return ed
@pytest.mark.gui
def test_toolbar_signals_and_styling(qtbot, editor):
host = QWidget()
@ -39,3 +40,29 @@ def test_toolbar_signals_and_styling(qtbot, editor):
tb.strikeRequested.emit()
tb.headingRequested.emit(24)
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"