diff --git a/bouquin/db.py b/bouquin/db.py index 54c811c..7295b0a 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -296,7 +296,6 @@ class DBManager: ) -> None: """ Point the page head (pages.current_version_id) to an existing version. - Fast revert: no content is rewritten. """ if self.conn is None: raise RuntimeError("Database is not connected") @@ -356,6 +355,9 @@ class DBManager: json.dump(data, f, ensure_ascii=False, separators=(",", ":")) def export_csv(self, entries: Sequence[Entry], file_path: str) -> None: + """ + Export pages to CSV. + """ # utf-8-sig adds a BOM so Excel opens as UTF-8 by default. with open(file_path, "w", encoding="utf-8-sig", newline="") as f: writer = csv.writer(f) @@ -369,6 +371,10 @@ class DBManager: separator: str = "\n\n— — — — —\n\n", strip_html: bool = True, ) -> None: + """ + Strip the HTML from the latest version of the pages + and save to a text file. + """ import re, html as _html # Precompiled patterns @@ -407,6 +413,9 @@ class DBManager: def export_html( self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export" ) -> None: + """ + Export to HTML with a heading. + """ parts = [ "", '', @@ -429,6 +438,10 @@ class DBManager: def export_markdown( self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export" ) -> None: + """ + Export to HTML, similar to export_html, but then convert to Markdown + using markdownify, and finally save to file. + """ parts = [ "", '', @@ -469,6 +482,10 @@ class DBManager: cur.execute("DETACH DATABASE backup") def export_by_extension(self, file_path: str) -> None: + """ + Fallback catch-all that runs one of the above functions based on + the extension of the file name that was chosen by the user. + """ entries = self.get_all_entries() ext = os.path.splitext(file_path)[1].lower() diff --git a/bouquin/history_dialog.py b/bouquin/history_dialog.py index 98399b9..0113ba1 100644 --- a/bouquin/history_dialog.py +++ b/bouquin/history_dialog.py @@ -156,7 +156,7 @@ class HistoryDialog(QDialog): # Diff vs current (textual diff) cur = self._db.get_version(version_id=self._current_id) self.diff.setHtml(_colored_unified_diff_html(cur["content"], sel["content"])) - # Enable revert only if selecting a non-current + # Enable revert only if selecting a non-current version self.btn_revert.setEnabled(sel_id != self._current_id) @Slot() @@ -167,7 +167,7 @@ class HistoryDialog(QDialog): sel_id = item.data(Qt.UserRole) if sel_id == self._current_id: return - # Flip head pointer + # Flip head pointer to the older version try: self._db.revert_to_version(self._date, version_id=sel_id) except Exception as e: diff --git a/bouquin/key_prompt.py b/bouquin/key_prompt.py index 095093c..bef0571 100644 --- a/bouquin/key_prompt.py +++ b/bouquin/key_prompt.py @@ -17,6 +17,12 @@ class KeyPrompt(QDialog): title: str = "Enter key", message: str = "Enter key", ): + """ + Prompt the user for the key required to decrypt the database. + + Used when opening the app, unlocking the idle locked screen, + or when rekeying. + """ super().__init__(parent) self.setWindowTitle(title) v = QVBoxLayout(self) diff --git a/bouquin/lock_overlay.py b/bouquin/lock_overlay.py index d019f3b..5d7d40a 100644 --- a/bouquin/lock_overlay.py +++ b/bouquin/lock_overlay.py @@ -7,6 +7,9 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton class LockOverlay(QWidget): def __init__(self, parent: QWidget, on_unlock: callable): + """ + Widget that 'locks' the screen after a configured idle time. + """ super().__init__(parent) self.setObjectName("LockOverlay") self.setAttribute(Qt.WA_StyledBackground, True) @@ -39,6 +42,9 @@ class LockOverlay(QWidget): self.hide() def _is_dark(self, pal: QPalette) -> bool: + """ + Detect if dark mode is in use. + """ c = pal.color(QPalette.Window) luma = 0.2126 * c.redF() + 0.7152 * c.greenF() + 0.0722 * c.blueF() return luma < 0.5 @@ -58,7 +64,7 @@ class LockOverlay(QWidget): self.setStyleSheet( f""" -#LockOverlay {{ background-color: rgb(0,0,0); }} /* opaque, no transparency */ +#LockOverlay {{ background-color: rgb(0,0,0); }} #LockOverlay QLabel#lockLabel {{ color: {accent_hex}; font-weight: 600; }} #LockOverlay QPushButton#unlockButton {{ @@ -113,7 +119,7 @@ class LockOverlay(QWidget): def changeEvent(self, ev): super().changeEvent(ev) - # Only re-style on palette flips + # Only re-style on palette flips (user changed theme) if ev.type() in (QEvent.PaletteChange, QEvent.ApplicationPaletteChange): self._apply_overlay_style() diff --git a/bouquin/main_window.py b/bouquin/main_window.py index f3f1ef5..babb7e4 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -121,7 +121,7 @@ class MainWindow(QMainWindow): split = QSplitter() split.addWidget(left_panel) split.addWidget(self.editor) - split.setStretchFactor(1, 1) # editor grows + split.setStretchFactor(1, 1) container = QWidget() lay = QVBoxLayout(container) @@ -281,7 +281,7 @@ class MainWindow(QMainWindow): if hasattr(self, "_lock_overlay"): self._lock_overlay._apply_overlay_style() self._apply_calendar_text_colors() - self._apply_link_css() # Reapply link styles based on the current theme + self._apply_link_css() self._apply_search_highlights(getattr(self, "_search_highlighted_dates", set())) self.calendar.update() self.editor.viewport().update() @@ -298,7 +298,6 @@ class MainWindow(QMainWindow): css = "" # Default to no custom styling for links (system or light theme) try: - # Apply to the editor (QTextEdit or any other relevant widgets) self.editor.document().setDefaultStyleSheet(css) except Exception: pass @@ -347,7 +346,6 @@ class MainWindow(QMainWindow): self.calendar.setPalette(app_pal) self.calendar.setStyleSheet("") - # Keep weekend text color in sync with the current palette self._apply_calendar_text_colors() self.calendar.update() @@ -855,6 +853,9 @@ If you want an encrypted backup, choose Backup instead of Export. return super().eventFilter(obj, event) def _enter_lock(self): + """ + Trigger the lock overlay and disable widgets + """ if self._locked: return self._locked = True @@ -870,6 +871,10 @@ If you want an encrypted backup, choose Backup instead of Export. @Slot() def _on_unlock_clicked(self): + """ + Prompt for key to unlock screen + If successful, re-enable widgets + """ try: ok = self._prompt_for_key_until_valid(first_time=False) except Exception as e: diff --git a/bouquin/save_dialog.py b/bouquin/save_dialog.py index 5e4095e..27feeaf 100644 --- a/bouquin/save_dialog.py +++ b/bouquin/save_dialog.py @@ -18,6 +18,9 @@ class SaveDialog(QDialog): title: str = "Enter a name for this version", message: str = "Enter a name for this version?", ): + """ + Used for explicitly saving a new version of a page. + """ super().__init__(parent) self.setWindowTitle(title) v = QVBoxLayout(self) diff --git a/bouquin/search.py b/bouquin/search.py index 2805e4c..bbe5a53 100644 --- a/bouquin/search.py +++ b/bouquin/search.py @@ -70,7 +70,6 @@ class Search(QWidget): try: rows: Iterable[Row] = self._db.search_entries(q) except Exception: - # be quiet on DB errors here; caller can surface if desired rows = [] self._populate_results(q, rows) diff --git a/bouquin/theme.py b/bouquin/theme.py index 341466e..ddd9fa5 100644 --- a/bouquin/theme.py +++ b/bouquin/theme.py @@ -49,7 +49,7 @@ class ThemeManager(QObject): scheme = getattr(hints, "colorScheme", None) if callable(scheme): scheme = hints.colorScheme() - # 0=Light, 1=Dark in newer Qt; fall back to Light + # 0=Light, 1=Dark; fall back to Light theme = Theme.DARK if scheme == 1 else Theme.LIGHT # Always use Fusion so palette applies consistently cross-platform @@ -58,7 +58,6 @@ class ThemeManager(QObject): if theme == Theme.DARK: pal = self._dark_palette() self._app.setPalette(pal) - # keep stylesheet empty unless you need widget-specific tweaks self._app.setStyleSheet("") else: pal = self._light_palette() diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py index 78c737e..7b0f248 100644 --- a/bouquin/toolbar.py +++ b/bouquin/toolbar.py @@ -140,6 +140,11 @@ class ToolBar(QToolBar): for a in (self.actAlignL, self.actAlignC, self.actAlignR): a.setActionGroup(self.grpAlign) + self.grpLists = QActionGroup(self) + self.grpLists.setExclusive(True) + for a in (self.actBullets, self.actNumbers, self.actCheckboxes): + a.setActionGroup(self.grpLists) + # Add actions self.addActions( [ diff --git a/tests/conftest.py b/tests/conftest.py index e949b1a..d9ecc99 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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, + ) diff --git a/tests/test_db_migrations_and_versions.py b/tests/test_db_migrations_and_versions.py new file mode 100644 index 0000000..3ac325d --- /dev/null +++ b/tests/test_db_migrations_and_versions.py @@ -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", "

Hello

"), + ) + 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", "

x

") + + +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", "

v1

", note="init") + ver2_id, ver2_no = mgr.save_new_version("2025-01-04", "

v2

", 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", "

other

") + 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", "

A

") + mgr.save_new_version("2025-01-07", "

B

") + + # 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 diff --git a/tests/test_editor_features_more.py b/tests/test_editor_features_more.py index aa63ecf..529019b 100644 --- a/tests/test_editor_features_more.py +++ b/tests/test_editor_features_more.py @@ -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'' @@ -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") diff --git a/tests/test_editor_images_text_states.py b/tests/test_editor_images_text_states.py new file mode 100644 index 0000000..186572a --- /dev/null +++ b/tests/test_editor_images_text_states.py @@ -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 tag + html = ed.toHtml() + assert " 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'

link

' + ) + + 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 diff --git a/tests/test_history_dialog_revert_edges.py b/tests/test_history_dialog_revert_edges.py index d120e13..f54e4d8 100644 --- a/tests/test_history_dialog_revert_edges.py +++ b/tests/test_history_dialog_revert_edges.py @@ -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", "

v2

", 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) diff --git a/tests/test_main_module.py b/tests/test_main_module.py index abf4a50..b2a63ae 100644 --- a/tests/test_main_module.py +++ b/tests/test_main_module.py @@ -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} diff --git a/tests/test_main_window_actions.py b/tests/test_main_window_actions.py new file mode 100644 index 0000000..2356abf --- /dev/null +++ b/tests/test_main_window_actions.py @@ -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 = ( + "

Unchecked 1

" + "

Checked 1

" + "

Unchecked 2

" + ) + win.db.save_new_version(y, html) + + # Ensure today starts blank + today_iso = QDate.currentDate().toString("yyyy-MM-dd") + win.editor.setHtml("

") + _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("

content

") + 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 diff --git a/tests/test_search_edges.py b/tests/test_search_edges.py index 406ff4c..1a4caaa 100644 --- a/tests/test_search_edges.py +++ b/tests/test_search_edges.py @@ -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", "

Hello world first day

") - db.save_new_version("2025-01-02", "

Lorem ipsum dolor sit amet, consectetur adipiscing elit.

") - db.save_new_version("2025-01-03", "

Long content begins " + ("x"*200) + " middle token here " + ("y"*200) + " ends.

") + db.save_new_version( + "2025-01-02", "

Lorem ipsum dolor sit amet, consectetur adipiscing elit.

" + ) + db.save_new_version( + "2025-01-03", + "

Long content begins " + + ("x" * 200) + + " middle token here " + + ("y" * 200) + + " ends.

", + ) 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, diff --git a/tests/test_search_windows.py b/tests/test_search_windows.py new file mode 100644 index 0000000..87ddfe8 --- /dev/null +++ b/tests/test_search_windows.py @@ -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 = "

Alpha beta gamma delta

" + 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 = "

One two three four five six seven eight nine ten eleven twelve

" + # 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 diff --git a/tests/test_settings_dialog_cancel_paths.py b/tests/test_settings_dialog_cancel_paths.py index 8cf698b..0362f42 100644 --- a/tests/test_settings_dialog_cancel_paths.py +++ b/tests/test_settings_dialog_cancel_paths.py @@ -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()) -