Code cleanup/comments, more test coverage (92%)

This commit is contained in:
Miguel Jacq 2025-11-07 11:42:29 +11:00
parent 66950eeff5
commit 74177f2cd3
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
19 changed files with 463 additions and 22 deletions

View file

@ -102,3 +102,32 @@ def theme_parent_widget(qtbot):
parent = _Parent()
qtbot.addWidget(parent)
return parent
@pytest.fixture(scope="session")
def qapp():
from PySide6.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
yield app
# do not quit; pytest might still need it
# app.quit()
@pytest.fixture
def temp_db_path(tmp_path):
return tmp_path / "notebook.db"
@pytest.fixture
def cfg(temp_db_path):
# Use the real DBConfig from the app (SQLCipher-backed)
from bouquin.db import DBConfig
return DBConfig(
path=Path(temp_db_path),
key="testkey",
idle_minutes=0,
theme="system",
move_todos=True,
)

View file

@ -0,0 +1,129 @@
from __future__ import annotations
import os
from pathlib import Path
import pytest
from bouquin.db import DBManager, DBConfig
# Use the same sqlite driver as the app (sqlcipher3) to prepare pre-0.1.5 "entries" DBs
from sqlcipher3 import dbapi2 as sqlite
def connect_raw_sqlcipher(db_path: Path, key: str):
conn = sqlite.connect(str(db_path))
conn.row_factory = sqlite.Row
cur = conn.cursor()
cur.execute(f"PRAGMA key = '{key}';")
cur.execute("PRAGMA foreign_keys = ON;")
cur.execute("PRAGMA journal_mode = WAL;").fetchone()
return conn
def test_migration_from_legacy_entries_table(cfg: DBConfig, tmp_path: Path):
# Prepare a "legacy" DB that has only entries(date, content) and no pages/versions
db_path = cfg.path
conn = connect_raw_sqlcipher(db_path, cfg.key)
cur = conn.cursor()
cur.execute("CREATE TABLE entries(date TEXT PRIMARY KEY, content TEXT NOT NULL);")
cur.execute(
"INSERT INTO entries(date, content) VALUES(?, ?);",
("2025-01-02", "<p>Hello</p>"),
)
conn.commit()
conn.close()
# Now use the real DBManager, which will run _ensure_schema and migrate
mgr = DBManager(cfg)
assert mgr.connect() is True
# After migration, legacy table should be gone and content reachable via get_entry
text = mgr.get_entry("2025-01-02")
assert "Hello" in text
cur = mgr.conn.cursor()
# entries table should be dropped
with pytest.raises(sqlite.OperationalError):
cur.execute("SELECT count(*) FROM entries;").fetchone()
# pages & versions exist and head points to v1
rows = cur.execute(
"SELECT current_version_id FROM pages WHERE date='2025-01-02'"
).fetchone()
assert rows is not None and rows["current_version_id"] is not None
vers = mgr.list_versions("2025-01-02")
assert vers and vers[0]["version_no"] == 1 and vers[0]["is_current"] == 1
def test_save_new_version_requires_connection_raises(cfg: DBConfig):
mgr = DBManager(cfg)
with pytest.raises(RuntimeError):
mgr.save_new_version("2025-01-03", "<p>x</p>")
def _bootstrap_db(cfg: DBConfig) -> DBManager:
mgr = DBManager(cfg)
assert mgr.connect() is True
return mgr
def test_revert_to_version_by_number_and_id_and_errors(cfg: DBConfig):
mgr = _bootstrap_db(cfg)
# Create two versions for the same date
ver1_id, ver1_no = mgr.save_new_version("2025-01-04", "<p>v1</p>", note="init")
ver2_id, ver2_no = mgr.save_new_version("2025-01-04", "<p>v2</p>", note="edit")
assert ver1_no == 1 and ver2_no == 2
# Revert using version_no (exercises branch where version_id is None)
mgr.revert_to_version(date_iso="2025-01-04", version_no=1, version_id=None)
cur = mgr.conn.cursor()
head = cur.execute(
"SELECT current_version_id FROM pages WHERE date=?", ("2025-01-04",)
).fetchone()[0]
assert head == ver1_id
# Revert using version_id directly should also work
mgr.revert_to_version(date_iso="2025-01-04", version_id=ver2_id)
head2 = cur.execute(
"SELECT current_version_id FROM pages WHERE date=?", ("2025-01-04",)
).fetchone()[0]
assert head2 == ver2_id
# Error: version not found for date (non-existent version_no)
with pytest.raises(ValueError):
mgr.revert_to_version(date_iso="2025-01-04", version_no=99)
# Error: version_id belongs to a different date
other_id, _ = mgr.save_new_version("2025-01-05", "<p>other</p>")
with pytest.raises(ValueError):
mgr.revert_to_version(date_iso="2025-01-04", version_id=other_id)
def test_export_by_extension_and_compact(cfg: DBConfig, tmp_path: Path):
mgr = _bootstrap_db(cfg)
# Seed a couple of entries
mgr.save_new_version("2025-01-06", "<p>A</p>")
mgr.save_new_version("2025-01-07", "<p>B</p>")
# Prepare output files
out = tmp_path
exts = [
".json",
".csv",
".txt",
".html",
".sql",
] # exclude .md due to different signature
for ext in exts:
path = out / f"export{ext}"
mgr.export_by_extension(str(path))
assert path.exists() and path.stat().st_size > 0
# Markdown export uses a different signature (entries + path)
entries = mgr.get_all_entries()
md_path = out / "export.md"
mgr.export_markdown(entries, str(md_path))
assert md_path.exists() and md_path.stat().st_size > 0
# Run VACUUM path
mgr.compact() # should not raise

View file

@ -10,6 +10,7 @@ from PySide6.QtTest import QTest
from bouquin.editor import Editor
from bouquin.theme import ThemeManager, ThemeConfig
@pytest.fixture(scope="module")
def app():
a = QApplication.instance()
@ -17,6 +18,7 @@ def app():
a = QApplication([])
return a
@pytest.fixture
def editor(app, qtbot):
themes = ThemeManager(app, ThemeConfig())
@ -25,6 +27,7 @@ def editor(app, qtbot):
e.show()
return e
def test_todo_prefix_converts_to_checkbox_on_space(editor):
editor.clear()
editor.setPlainText("TODO")
@ -35,6 +38,7 @@ def test_todo_prefix_converts_to_checkbox_on_space(editor):
# Now the line should start with the checkbox glyph and a space
assert editor.toPlainText().startswith("")
def test_enter_inside_empty_code_frame_jumps_out(editor):
editor.clear()
editor.setPlainText("") # single empty block
@ -46,13 +50,17 @@ def test_enter_inside_empty_code_frame_jumps_out(editor):
txt = editor.toPlainText()
assert "\n" in txt # a normal paragraph created after exiting the frame
def test_insertFromMimeData_with_data_image(editor):
# Build an in-memory PNG and embed as data URL inside HTML
img = QImage(8, 8, QImage.Format_ARGB32)
img.fill(0xff00ff00) # green
img.fill(0xFF00FF00) # green
ba = QByteArray()
from PySide6.QtCore import QBuffer, QIODevice
buf = QBuffer(ba); buf.open(QIODevice.WriteOnly); img.save(buf, "PNG")
buf = QBuffer(ba)
buf.open(QIODevice.WriteOnly)
img.save(buf, "PNG")
data_b64 = base64.b64encode(bytes(ba)).decode("ascii")
html = f'<img src="data:image/png;base64,{data_b64}"/>'
@ -64,6 +72,7 @@ def test_insertFromMimeData_with_data_image(editor):
h = editor.to_html_with_embedded_images()
assert "data:image/png;base64," in h
def test_toggle_checkboxes_selection(editor):
editor.clear()
editor.setPlainText("item 1\nitem 2")
@ -79,6 +88,7 @@ def test_toggle_checkboxes_selection(editor):
editor.toggle_checkboxes()
assert not editor.toPlainText().startswith("")
def test_heading_then_enter_reverts_to_normal(editor):
editor.clear()
editor.setPlainText("A heading")

View file

@ -0,0 +1,77 @@
import base64
from pathlib import Path
from PySide6.QtCore import QUrl, QByteArray
from PySide6.QtGui import QImage, QTextCursor, QTextImageFormat, QColor
from bouquin.theme import ThemeManager
from bouquin.editor import Editor
def _mk_editor(qapp, cfg):
themes = ThemeManager(qapp, cfg)
ed = Editor(themes)
ed.resize(400, 300)
return ed
def test_image_scale_and_reset(qapp, cfg):
ed = _mk_editor(qapp, cfg)
# Register an image resource and insert it at the cursor
img = QImage(20, 10, QImage.Format_ARGB32)
img.fill(QColor(200, 0, 0))
url = QUrl("test://img")
from PySide6.QtGui import QTextDocument
ed.document().addResource(QTextDocument.ResourceType.ImageResource, url, img)
fmt = QTextImageFormat()
fmt.setName(url.toString())
# No explicit width -> code should use original width
tc = ed.textCursor()
tc.insertImage(fmt)
# Place cursor at start (on the image) and scale
tc = ed.textCursor()
tc.movePosition(QTextCursor.Start)
ed.setTextCursor(tc)
ed._scale_image_at_cursor(1.5) # increases width
ed._reset_image_size() # restores to original width
# Ensure resulting HTML contains an <img> tag
html = ed.toHtml()
assert "<img" in html
def test_apply_image_size_fallbacks(qapp, cfg):
ed = _mk_editor(qapp, cfg)
# Create a dummy image format with no width/height -> fallback branch inside _apply_image_size
fmt = QTextImageFormat()
fmt.setName("") # no resource available
tc = ed.textCursor()
# Insert a single character to have a valid cursor
tc.insertText("x")
tc.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, 1)
ed._apply_image_size(tc, fmt, new_w=100.0, orig_img=None) # should not raise
def test_to_html_with_embedded_images_and_link_tint(qapp, cfg):
ed = _mk_editor(qapp, cfg)
# Insert an anchor + image and ensure HTML embedding + retint pass runs
img = QImage(8, 8, QImage.Format_ARGB32)
img.fill(QColor(0, 200, 0))
url = QUrl("test://img2")
from PySide6.QtGui import QTextDocument
ed.document().addResource(QTextDocument.ResourceType.ImageResource, url, img)
# Compose HTML with a link and an image referencing our resource
ed.setHtml(
f'<p><a href="http://example.com">link</a></p><p><img src="{url.toString()}"></p>'
)
html = ed.to_html_with_embedded_images()
# Embedded data URL should appear for the image
assert "data:image" in html
# The link should still be present (retinted internally) without crashing
assert "example.com" in html

View file

@ -5,6 +5,7 @@ from PySide6.QtCore import Qt
from bouquin.db import DBConfig, DBManager
from bouquin.history_dialog import HistoryDialog
@pytest.fixture(scope="module")
def app():
a = QApplication.instance()
@ -12,6 +13,7 @@ def app():
a = QApplication([])
return a
@pytest.fixture
def db(tmp_path):
cfg = DBConfig(path=tmp_path / "h.db", key="k")
@ -22,6 +24,7 @@ def db(tmp_path):
db.save_new_version("2025-02-10", "<p>v2</p>", note="v2", set_current=True)
return db
def test_revert_early_returns(app, db, qtbot):
dlg = HistoryDialog(db, date_iso="2025-02-10")
qtbot.addWidget(dlg)

View file

@ -3,6 +3,7 @@ import types
import sys
import builtins
def test_dunder_main_executes_without_launching_qt(monkeypatch):
# Replace bouquin.main with a stub that records invocation and returns immediately
calls = {"called": False}

View file

@ -0,0 +1,93 @@
import os
from datetime import date, timedelta
from pathlib import Path
from PySide6.QtCore import QDate, QByteArray
from bouquin.theme import ThemeManager
from bouquin.main_window import MainWindow
from bouquin.settings import save_db_config
from bouquin.db import DBManager
def _bootstrap_window(qapp, cfg):
# Ensure DB exists and key is valid in settings
mgr = DBManager(cfg)
assert mgr.connect() is True
save_db_config(cfg)
themes = ThemeManager(qapp, cfg)
win = MainWindow(themes)
# Force an initial selected date
win.calendar.setSelectedDate(QDate.currentDate())
return win
def test_move_todos_copies_unchecked(qapp, cfg, tmp_path):
cfg.move_todos = True
win = _bootstrap_window(qapp, cfg)
# Seed yesterday with both checked and unchecked items using the same HTML pattern the app expects
y = QDate.currentDate().addDays(-1).toString("yyyy-MM-dd")
html = (
"<p><span>☐</span> Unchecked 1</p>"
"<p><span>☑</span> Checked 1</p>"
"<p><span>☐</span> Unchecked 2</p>"
)
win.db.save_new_version(y, html)
# Ensure today starts blank
today_iso = QDate.currentDate().toString("yyyy-MM-dd")
win.editor.setHtml("<p></p>")
_html = win.editor.toHtml()
win.db.save_new_version(today_iso, _html)
# Invoke the move-todos logic
win._load_yesterday_todos()
# Verify today's entry now contains only the unchecked items
txt = win.db.get_entry(today_iso)
assert "Unchecked 1" in txt and "Unchecked 2" in txt and "Checked 1" not in txt
def test_adjust_and_save_paths(qapp, cfg):
win = _bootstrap_window(qapp, cfg)
# Move date selection and jump to today
before = win.calendar.selectedDate()
win._adjust_day(-1)
assert win.calendar.selectedDate().toString("yyyy-MM-dd") != before.toString(
"yyyy-MM-dd"
)
win._adjust_today()
assert win.calendar.selectedDate() == QDate.currentDate()
# Save path exercises success feedback + dirty flag reset
win.editor.setHtml("<p>content</p>")
win._dirty = True
win._save_date(QDate.currentDate().toString("yyyy-MM-dd"), explicit=True)
assert win._dirty is False
def test_restore_window_position(qapp, cfg, tmp_path):
win = _bootstrap_window(qapp, cfg)
# Save geometry/state into settings and restore it (covers maximize singleShot branch too)
geom = win.saveGeometry()
state = win.saveState()
s = win.settings
s.setValue("ui/geometry", geom)
s.setValue("ui/window_state", state)
s.sync()
win._restore_window_position() # should restore without error
def test_idle_lock_unlock_flow(qapp, cfg):
win = _bootstrap_window(qapp, cfg)
# Enter lock
win._enter_lock()
assert getattr(win, "_locked", False) is True
# Disabling idle minutes should unlock and hide overlay
win._apply_idle_minutes(0)
assert getattr(win, "_locked", False) is False

View file

@ -6,6 +6,7 @@ import pytest
from bouquin.db import DBConfig, DBManager
from bouquin.search import Search
@pytest.fixture(scope="module")
def app():
# Ensure a single QApplication exists
@ -14,6 +15,7 @@ def app():
a = QApplication([])
yield a
@pytest.fixture
def fresh_db(tmp_path):
cfg = DBConfig(path=tmp_path / "test.db", key="testkey")
@ -21,10 +23,20 @@ def fresh_db(tmp_path):
assert db.connect() is True
# Seed a couple of entries
db.save_new_version("2025-01-01", "<p>Hello world first day</p>")
db.save_new_version("2025-01-02", "<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>")
db.save_new_version("2025-01-03", "<p>Long content begins " + ("x"*200) + " middle token here " + ("y"*200) + " ends.</p>")
db.save_new_version(
"2025-01-02", "<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>"
)
db.save_new_version(
"2025-01-03",
"<p>Long content begins "
+ ("x" * 200)
+ " middle token here "
+ ("y" * 200)
+ " ends.</p>",
)
return db
def test_search_exception_path_closed_db_triggers_quiet_handling(app, fresh_db, qtbot):
# Close the DB to provoke an exception inside Search._search
fresh_db.close()
@ -37,6 +49,7 @@ def test_search_exception_path_closed_db_triggers_quiet_handling(app, fresh_db,
assert w.results.isHidden() # remains hidden because there are no rows
# Also, the "resultDatesChanged" signal should emit an empty list (coverage on that branch)
def test_make_html_snippet_ellipses_both_sides(app, fresh_db):
w = Search(fresh_db)
# Choose a query so that the first match sits well inside a long string,

View file

@ -0,0 +1,38 @@
import pytest
from PySide6.QtWidgets import QWidget
from bouquin.search import Search
@pytest.fixture
def search_widget(qapp):
# We don't need a real DB for snippet generation pass None
return Search(db=None)
def test_make_html_snippet_empty(search_widget: Search):
html = ""
frag, has_prev, has_next = search_widget._make_html_snippet(
html, "", radius=10, maxlen=20
)
assert frag == "" and has_prev is False and has_next is False
def test_make_html_snippet_phrase_preferred(search_widget: Search):
html = "<p>Alpha beta gamma delta</p>"
frag, has_prev, has_next = search_widget._make_html_snippet(
html, "beta gamma", radius=1, maxlen=10
)
# We expect a window that includes the phrase and has previous text
assert "beta" in frag and "gamma" in frag
assert has_prev is True
def test_make_html_snippet_token_fallback_and_window_flags(search_widget: Search):
html = "<p>One two three four five six seven eight nine ten eleven twelve</p>"
# Use tokens such that the phrase doesn't exist, but individual tokens do
frag, has_prev, has_next = search_widget._make_html_snippet(
html, "eleven two", radius=3, maxlen=20
)
assert "two" in frag
# The snippet should be a slice within the text (has more following content)
assert has_next is True

View file

@ -9,6 +9,7 @@ from bouquin.settings import APP_NAME, APP_ORG
from bouquin.key_prompt import KeyPrompt
from bouquin.theme import Theme, ThemeManager, ThemeConfig
@pytest.fixture(scope="module")
def app():
a = QApplication.instance()
@ -18,6 +19,7 @@ def app():
a.setOrganizationName(APP_ORG)
return a
@pytest.fixture
def db(tmp_path):
cfg = DBConfig(path=tmp_path / "s.db", key="abc")
@ -25,6 +27,7 @@ def db(tmp_path):
assert m.connect()
return m
def test_theme_radio_initialisation_dark_and_light(app, db, monkeypatch, qtbot):
# Dark preselection
parent = _ParentWithThemes(app)
@ -44,6 +47,7 @@ def test_theme_radio_initialisation_dark_and_light(app, db, monkeypatch, qtbot):
dlg2._save()
assert dlg2.config.theme == Theme.LIGHT.value
def test_change_key_cancel_branches(app, db, monkeypatch, qtbot):
parent = _ParentWithThemes(app)
qtbot.addWidget(parent)
@ -56,17 +60,20 @@ def test_change_key_cancel_branches(app, db, monkeypatch, qtbot):
assert dlg.key == ""
# First OK, second cancelled -> early return at the second branch
state = {'calls': 0}
state = {"calls": 0}
def _exec(self):
state['calls'] += 1
return QDialog.Accepted if state['calls'] == 1 else QDialog.Rejected
monkeypatch.setattr(KeyPrompt, 'exec', _exec)
state["calls"] += 1
return QDialog.Accepted if state["calls"] == 1 else QDialog.Rejected
monkeypatch.setattr(KeyPrompt, "exec", _exec)
# Also monkeypatch to control key() values
monkeypatch.setattr(KeyPrompt, "key", lambda self: "new-secret")
dlg._change_key()
# Because the second prompt was rejected, key should remain unchanged
assert dlg.key == ""
def test_save_key_checkbox_cancel_restores_checkbox(app, db, monkeypatch, qtbot):
parent = _ParentWithThemes(app)
qtbot.addWidget(parent)
@ -92,7 +99,9 @@ def test_change_key_exception_path(app, db, monkeypatch, qtbot):
monkeypatch.setattr(KeyPrompt, "key", lambda self: "boom")
# Force DB rekey to raise to exercise the except-branch
monkeypatch.setattr(db, "rekey", lambda new_key: (_ for _ in ()).throw(RuntimeError("fail")))
monkeypatch.setattr(
db, "rekey", lambda new_key: (_ for _ in ()).throw(RuntimeError("fail"))
)
# Should not raise; error is handled internally
dlg._change_key()
@ -102,4 +111,3 @@ class _ParentWithThemes(QWidget):
def __init__(self, app):
super().__init__()
self.themes = ThemeManager(app, ThemeConfig())