diff --git a/CHANGELOG.md b/CHANGELOG.md index 7243f00..acf8dd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # 0.1.11 * Add missing export extensions to export_by_extension + * Fix focusing on editor after leaving the app and returning + * More code coverage and removing obsolete bits of code # 0.1.10.2 diff --git a/bouquin/db.py b/bouquin/db.py index 54c811c..b6c937b 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -257,68 +257,31 @@ class DBManager: ).fetchall() return [dict(r) for r in rows] - def get_version( - self, - *, - date_iso: str | None = None, - version_no: int | None = None, - version_id: int | None = None, - ) -> dict | None: + def get_version(self, *, version_id: int) -> dict | None: """ - Fetch a specific version by (date, version_no) OR by version_id. + Fetch a specific version by version_id. Returns a dict with keys: id, date, version_no, created_at, note, content. """ cur = self.conn.cursor() - if version_id is not None: - row = cur.execute( - "SELECT id, date, version_no, created_at, note, content " - "FROM versions WHERE id=?;", - (version_id,), - ).fetchone() - else: - if date_iso is None or version_no is None: - raise ValueError( - "Provide either version_id OR (date_iso and version_no)" - ) - row = cur.execute( - "SELECT id, date, version_no, created_at, note, content " - "FROM versions WHERE date=? AND version_no=?;", - (date_iso, version_no), - ).fetchone() + row = cur.execute( + "SELECT id, date, version_no, created_at, note, content " + "FROM versions WHERE id=?;", + (version_id,), + ).fetchone() return dict(row) if row else None - def revert_to_version( - self, - date_iso: str, - *, - version_no: int | None = None, - version_id: int | None = None, - ) -> None: + def revert_to_version(self, date_iso: str, version_id: int) -> 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") cur = self.conn.cursor() - if version_id is None: - if version_no is None: - raise ValueError("Provide version_no or version_id") - row = cur.execute( - "SELECT id FROM versions WHERE date=? AND version_no=?;", - (date_iso, version_no), - ).fetchone() - if row is None: - raise ValueError("Version not found for this date") - version_id = int(row["id"]) - else: - # Ensure that version_id belongs to the given date - row = cur.execute( - "SELECT date FROM versions WHERE id=?;", (version_id,) - ).fetchone() - if row is None or row["date"] != date_iso: - raise ValueError("version_id does not belong to the given date") + # Ensure that version_id belongs to the given date + row = cur.execute( + "SELECT date FROM versions WHERE id=?;", (version_id,) + ).fetchone() + if row is None or row["date"] != date_iso: + raise ValueError("version_id does not belong to the given date") with self.conn: cur.execute( @@ -342,20 +305,18 @@ class DBManager: ).fetchall() return [(r[0], r[1]) for r in rows] - def export_json( - self, entries: Sequence[Entry], file_path: str, pretty: bool = True - ) -> None: + def export_json(self, entries: Sequence[Entry], file_path: str) -> None: """ Export to json. """ data = [{"date": d, "content": c} for d, c in entries] with open(file_path, "w", encoding="utf-8") as f: - if pretty: - json.dump(data, f, ensure_ascii=False, indent=2) - else: - json.dump(data, f, ensure_ascii=False, separators=(",", ":")) + json.dump(data, f, ensure_ascii=False, indent=2) 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 +330,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 +372,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 +397,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 +441,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() @@ -483,7 +459,7 @@ class DBManager: elif ext in {".sql", ".sqlite"}: self.export_sql(file_path) elif ext == ".md": - self.export_markdown(file_path) + self.export_markdown(entries, file_path) else: raise ValueError(f"Unsupported extension: {ext}") diff --git a/bouquin/editor.py b/bouquin/editor.py index cb18755..e3f7133 100644 --- a/bouquin/editor.py +++ b/bouquin/editor.py @@ -140,10 +140,8 @@ class Editor(QTextEdit): bc.setPosition(b.position() + b.length()) return blocks > 0 and (codeish / blocks) >= 0.6 - def _nearest_code_frame(self, cursor=None, tolerant: bool = False): + def _nearest_code_frame(self, cursor, tolerant: bool = False): """Walk up parents from the cursor and return the first code frame.""" - if cursor is None: - cursor = self.textCursor() f = cursor.currentFrame() while f: if self._is_code_frame(f, tolerant=tolerant): @@ -271,16 +269,6 @@ class Editor(QTextEdit): cur.endEditBlock() self.viewport().update() - def _safe_select(self, cur: QTextCursor, start: int, end: int): - """Select [start, end] inclusive without exceeding document bounds.""" - doc_max = max(0, self.document().characterCount() - 1) - s = max(0, min(start, doc_max)) - e = max(0, min(end, doc_max)) - if e < s: - s, e = e, s - cur.setPosition(s) - cur.setPosition(e, QTextCursor.KeepAnchor) - def _trim_url_end(self, url: str) -> str: # strip common trailing punctuation not part of the URL trimmed = url.rstrip(".,;:!?\"'") @@ -854,14 +842,6 @@ class Editor(QTextEdit): break b = b.next() - def toggle_current_checkbox_state(self): - """Tick/untick the current line if it starts with a checkbox.""" - b = self.textCursor().block() - state, _ = self._checkbox_info_for_block(b) - if state is None: - return - self._set_block_checkbox_state(b, not state) - @Slot() def apply_weight(self): cur = self.currentCharFormat() 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..d00e013 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -26,6 +26,7 @@ from PySide6.QtGui import ( QGuiApplication, QPalette, QTextCharFormat, + QTextCursor, QTextListFormat, ) from PySide6.QtWidgets import ( @@ -121,7 +122,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) @@ -146,6 +147,16 @@ class MainWindow(QMainWindow): QApplication.instance().installEventFilter(self) + # Focus on the editor + self.setFocusPolicy(Qt.StrongFocus) + self.editor.setFocusPolicy(Qt.StrongFocus) + self.toolBar.setFocusPolicy(Qt.NoFocus) + for w in self.toolBar.findChildren(QWidget): + w.setFocusPolicy(Qt.NoFocus) + QGuiApplication.instance().applicationStateChanged.connect( + self._on_app_state_changed + ) + # Status bar for feedback self.statusBar().showMessage("Ready", 800) @@ -281,7 +292,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 +309,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 +357,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() @@ -483,7 +492,8 @@ class MainWindow(QMainWindow): # Inject the extra_data before the closing modified = re.sub(r"(<\/body><\/html>)", extra_data_html + r"\1", text) text = modified - self.editor.setHtml(text) + # Force a save now so we don't lose it. + self._set_editor_html_preserve_view(text) self._dirty = True self._save_date(date_iso, True) @@ -491,9 +501,7 @@ class MainWindow(QMainWindow): QMessageBox.critical(self, "Read Error", str(e)) return - self.editor.blockSignals(True) - self.editor.setHtml(text) - self.editor.blockSignals(False) + self._set_editor_html_preserve_view(text) self._dirty = False # track which date the editor currently represents @@ -852,9 +860,14 @@ If you want an encrypted backup, choose Backup instead of Export. def eventFilter(self, obj, event): if event.type() == QEvent.KeyPress and not self._locked: self._idle_timer.start() + if event.type() in (QEvent.ApplicationActivate, QEvent.WindowActivate): + QTimer.singleShot(0, self._focus_editor_now) 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 +883,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: @@ -886,6 +903,7 @@ If you want an encrypted backup, choose Backup instead of Export. if tb: tb.setEnabled(True) self._idle_timer.start() + QTimer.singleShot(0, self._focus_editor_now) # ----------------- Close handlers ----------------- # def closeEvent(self, event): @@ -901,3 +919,61 @@ If you want an encrypted backup, choose Backup instead of Export. except Exception: pass super().closeEvent(event) + + # ----------------- Below logic helps focus the editor ----------------- # + + def _focus_editor_now(self): + """Give focus to the editor and ensure the caret is visible.""" + if getattr(self, "_locked", False): + return + if not self.isActiveWindow(): + return + # Belt-and-suspenders: do it now and once more on the next tick + self.editor.setFocus(Qt.ActiveWindowFocusReason) + self.editor.ensureCursorVisible() + QTimer.singleShot( + 0, + lambda: ( + self.editor.setFocus(Qt.ActiveWindowFocusReason), + self.editor.ensureCursorVisible(), + ), + ) + + def _on_app_state_changed(self, state): + # Called on macOS/Wayland/Windows when the whole app re-activates + if state == Qt.ApplicationActive and self.isActiveWindow(): + QTimer.singleShot(0, self._focus_editor_now) + + def changeEvent(self, ev): + # Called on some platforms when the window's activation state flips + super().changeEvent(ev) + if ev.type() == QEvent.ActivationChange and self.isActiveWindow(): + QTimer.singleShot(0, self._focus_editor_now) + + def _set_editor_html_preserve_view(self, html: str): + ed = self.editor + + # Save caret/selection and scroll + cur = ed.textCursor() + old_pos, old_anchor = cur.position(), cur.anchor() + v = ed.verticalScrollBar().value() + h = ed.horizontalScrollBar().value() + + # Only touch the doc if it actually changed + ed.blockSignals(True) + if ed.toHtml() != html: + ed.setHtml(html) + ed.blockSignals(False) + + # Restore scroll first + ed.verticalScrollBar().setValue(v) + ed.horizontalScrollBar().setValue(h) + + # Restore caret/selection + cur = ed.textCursor() + cur.setPosition(old_anchor) + mode = ( + QTextCursor.KeepAnchor if old_anchor != old_pos else QTextCursor.MoveAnchor + ) + cur.setPosition(old_pos, mode) + ed.setTextCursor(cur) 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..8fd1166 --- /dev/null +++ b/tests/test_db_migrations_and_versions.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from pathlib import Path +import pytest + +from bouquin.db import DBManager, DBConfig + +# Use the same sqlite driver as the app (sqlcipher3) to prepare pre-0.1.5 "entries" DBs +from sqlcipher3 import dbapi2 as sqlite + + +def connect_raw_sqlcipher(db_path: Path, key: str): + conn = sqlite.connect(str(db_path)) + conn.row_factory = sqlite.Row + cur = conn.cursor() + cur.execute(f"PRAGMA key = '{key}';") + cur.execute("PRAGMA foreign_keys = ON;") + cur.execute("PRAGMA journal_mode = WAL;").fetchone() + return conn + + +def test_migration_from_legacy_entries_table(cfg: DBConfig, tmp_path: Path): + # Prepare a "legacy" DB that has only entries(date, content) and no pages/versions + db_path = cfg.path + conn = connect_raw_sqlcipher(db_path, cfg.key) + cur = conn.cursor() + cur.execute("CREATE TABLE entries(date TEXT PRIMARY KEY, content TEXT NOT NULL);") + cur.execute( + "INSERT INTO entries(date, content) VALUES(?, ?);", + ("2025-01-02", "

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_id + mgr.revert_to_version(date_iso="2025-01-04", version_id=ver2_id) + cur = mgr.conn.cursor() + head2 = cur.execute( + "SELECT current_version_id FROM pages WHERE date=?", ("2025-01-04",) + ).fetchone()[0] + assert head2 == ver2_id + + # Error: version_id belongs to a different date + other_id, _ = mgr.save_new_version("2025-01-05", "

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_db_unit.py b/tests/test_db_unit.py index d369abf..8c80160 100644 --- a/tests/test_db_unit.py +++ b/tests/test_db_unit.py @@ -114,7 +114,7 @@ def test_export_by_extension_and_unknown(tmp_path): import types mgr.get_all_entries = types.MethodType(lambda self: entries, mgr) - for ext in [".json", ".csv", ".txt", ".html"]: + for ext in [".json", ".csv", ".txt", ".html", ".md"]: path = tmp_path / f"route{ext}" mgr.export_by_extension(str(path)) assert path.exists() diff --git a/tests/test_editor.py b/tests/test_editor.py index 520e941..e0951b8 100644 --- a/tests/test_editor.py +++ b/tests/test_editor.py @@ -145,10 +145,6 @@ def test_linkify_trims_trailing_punctuation(qtbot): def test_code_block_enter_exits_on_empty_line(qtbot): - from PySide6.QtCore import Qt - from PySide6.QtGui import QTextCursor - from PySide6.QtTest import QTest - from bouquin.editor import Editor e = _mk_editor() qtbot.addWidget(e) diff --git a/tests/test_editor_features_more.py b/tests/test_editor_features_more.py new file mode 100644 index 0000000..4d905f7 --- /dev/null +++ b/tests/test_editor_features_more.py @@ -0,0 +1,103 @@ +import base64 + +import pytest +from PySide6.QtCore import Qt, QMimeData, QByteArray +from PySide6.QtGui import QImage, QTextCursor +from PySide6.QtWidgets import QApplication +from PySide6.QtTest import QTest + +from bouquin.editor import Editor +from bouquin.theme import ThemeManager, ThemeConfig + + +@pytest.fixture(scope="module") +def app(): + a = QApplication.instance() + if a is None: + a = QApplication([]) + return a + + +@pytest.fixture +def editor(app, qtbot): + themes = ThemeManager(app, ThemeConfig()) + e = Editor(themes) + qtbot.addWidget(e) + e.show() + return e + + +def test_todo_prefix_converts_to_checkbox_on_space(editor): + editor.clear() + editor.setPlainText("TODO") + c = editor.textCursor() + c.movePosition(QTextCursor.End) + editor.setTextCursor(c) + QTest.keyClick(editor, Qt.Key_Space) + # Now the line should start with the checkbox glyph and a space + assert editor.toPlainText().startswith("☐ ") + + +def test_enter_inside_empty_code_frame_jumps_out(editor): + editor.clear() + editor.setPlainText("") # single empty block + # Apply code block to current line + editor.apply_code() + # Cursor is inside the code frame. Press Enter on empty block should jump out. + QTest.keyClick(editor, Qt.Key_Return) + # We expect two blocks: one code block (with a newline inserted) and then a normal block + txt = editor.toPlainText() + assert "\n" in txt # a normal paragraph created after exiting the frame + + +def test_insertFromMimeData_with_data_image(editor): + # Build an in-memory PNG and embed as data URL inside HTML + img = QImage(8, 8, QImage.Format_ARGB32) + img.fill(0xFF00FF00) # green + ba = QByteArray() + from PySide6.QtCore import QBuffer, QIODevice + + buf = QBuffer(ba) + buf.open(QIODevice.WriteOnly) + img.save(buf, "PNG") + data_b64 = base64.b64encode(bytes(ba)).decode("ascii") + html = f'' + + md = QMimeData() + md.setHtml(html) + editor.insertFromMimeData(md) + + # HTML export with embedded images should contain a data: URL + h = editor.to_html_with_embedded_images() + assert "data:image/png;base64," in h + + +def test_toggle_checkboxes_selection(editor): + editor.clear() + editor.setPlainText("item 1\nitem 2") + # Select both lines + c = editor.textCursor() + c.movePosition(QTextCursor.Start) + c.movePosition(QTextCursor.End, QTextCursor.KeepAnchor) + editor.setTextCursor(c) + # Toggle on -> inserts ☐ + editor.toggle_checkboxes() + assert editor.toPlainText().startswith("☐ ") + # Toggle again -> remove ☐ + editor.toggle_checkboxes() + assert not editor.toPlainText().startswith("☐ ") + + +def test_heading_then_enter_reverts_to_normal(editor): + editor.clear() + editor.setPlainText("A heading") + # Apply H2 via apply_heading(size=18) + editor.apply_heading(18) + c = editor.textCursor() + c.movePosition(QTextCursor.End) + editor.setTextCursor(c) + # Press Enter -> new block should be Normal (not bold/large) + QTest.keyClick(editor, Qt.Key_Return) + # The new block exists + txt = editor.toPlainText() + assert "\n" in txt diff --git a/tests/test_editor_images_text_states.py b/tests/test_editor_images_text_states.py new file mode 100644 index 0000000..8cb81d9 --- /dev/null +++ b/tests/test_editor_images_text_states.py @@ -0,0 +1,75 @@ +from PySide6.QtCore import QUrl +from PySide6.QtGui import QImage, QTextCursor, QTextImageFormat, QColor +from bouquin.theme import ThemeManager +from bouquin.editor import Editor + + +def _mk_editor(qapp, cfg): + themes = ThemeManager(qapp, cfg) + ed = Editor(themes) + ed.resize(400, 300) + return ed + + +def test_image_scale_and_reset(qapp, cfg): + ed = _mk_editor(qapp, cfg) + + # Register an image resource and insert it at the cursor + img = QImage(20, 10, QImage.Format_ARGB32) + img.fill(QColor(200, 0, 0)) + url = QUrl("test://img") + from PySide6.QtGui import QTextDocument + + ed.document().addResource(QTextDocument.ResourceType.ImageResource, url, img) + + fmt = QTextImageFormat() + fmt.setName(url.toString()) + # No explicit width -> code should use original width + tc = ed.textCursor() + tc.insertImage(fmt) + + # Place cursor at start (on the image) and scale + tc = ed.textCursor() + tc.movePosition(QTextCursor.Start) + ed.setTextCursor(tc) + ed._scale_image_at_cursor(1.5) # increases width + ed._reset_image_size() # restores to original width + + # Ensure resulting HTML contains an 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_editor_more.py b/tests/test_editor_more.py new file mode 100644 index 0000000..fd015a9 --- /dev/null +++ b/tests/test_editor_more.py @@ -0,0 +1,136 @@ +from PySide6.QtCore import Qt, QEvent, QUrl, QObject, Slot +from PySide6.QtGui import QImage, QMouseEvent, QTextCursor +from PySide6.QtTest import QTest +from PySide6.QtWidgets import QApplication + +from bouquin.editor import Editor +from bouquin.theme import ThemeManager, ThemeConfig + + +def _mk_editor() -> Editor: + app = QApplication.instance() + tm = ThemeManager(app, ThemeConfig()) + e = Editor(tm) + e.resize(700, 400) + e.show() + return e + + +def _point_for_char(e: Editor, pos: int): + c = e.textCursor() + c.setPosition(pos) + r = e.cursorRect(c) + return r.center() + + +def test_trim_url_and_linkify_and_ctrl_mouse(qtbot): + e = _mk_editor() + qtbot.addWidget(e) + assert e._trim_url_end("https://ex.com)") == "https://ex.com" + assert e._trim_url_end("www.mysite.org]") == "www.mysite.org" + + url = "https://example.org/path" + QTest.keyClicks(e, url) + qtbot.waitUntil(lambda: url in e.toPlainText()) + + p = _point_for_char(e, 0) + move = QMouseEvent( + QEvent.MouseMove, p, Qt.NoButton, Qt.NoButton, Qt.ControlModifier + ) + e.mouseMoveEvent(move) + assert e.viewport().cursor().shape() == Qt.PointingHandCursor + + opened = {} + + class Catcher(QObject): + @Slot(QUrl) + def handle(self, u: QUrl): + opened["u"] = u.toString() + + from PySide6.QtGui import QDesktopServices + + catcher = Catcher() + QDesktopServices.setUrlHandler("https", catcher, "handle") + try: + rel = QMouseEvent( + QEvent.MouseButtonRelease, + p, + Qt.LeftButton, + Qt.LeftButton, + Qt.ControlModifier, + ) + e.mouseReleaseEvent(rel) + got_signal = [] + e.linkActivated.connect(lambda href: got_signal.append(href)) + e.mouseReleaseEvent(rel) + assert opened or got_signal + finally: + QDesktopServices.unsetUrlHandler("https") + + +def test_insert_images_and_image_helpers(qtbot, tmp_path): + e = _mk_editor() + qtbot.addWidget(e) + + # No image under cursor yet (412 guard) + tc, fmt, orig = e._image_info_at_cursor() + assert tc is None and fmt is None and orig is None + + # Insert a real image file (574–584 path) + img_path = tmp_path / "tiny.png" + img = QImage(4, 4, QImage.Format_ARGB32) + img.fill(0xFF336699) + assert img.save(str(img_path), "PNG") + e.insert_images([str(img_path)], autoscale=False) + assert " new line with fresh checkbox (680–684) + c = e.textCursor() + c.movePosition(QTextCursor.End) + e.setTextCursor(c) + QTest.keyClick(e, Qt.Key_Return) + lines = e.toPlainText().splitlines() + assert len(lines) >= 2 and lines[1].startswith("☐ ") + + +def test_heading_and_lists_toggle_remove(qtbot): + e = _mk_editor() + qtbot.addWidget(e) + e.setPlainText("para") + + # "Normal" path is size=0 (904…) + e.apply_heading(0) + + # bullets twice -> second call removes (945–946) + e.toggle_bullets() + e.toggle_bullets() + # numbers twice -> second call removes (955–956) + e.toggle_numbers() + e.toggle_numbers() diff --git a/tests/test_history_dialog_revert_edges.py b/tests/test_history_dialog_revert_edges.py new file mode 100644 index 0000000..f54e4d8 --- /dev/null +++ b/tests/test_history_dialog_revert_edges.py @@ -0,0 +1,43 @@ +import pytest +from PySide6.QtWidgets import QApplication, QListWidgetItem +from PySide6.QtCore import Qt + +from bouquin.db import DBConfig, DBManager +from bouquin.history_dialog import HistoryDialog + + +@pytest.fixture(scope="module") +def app(): + a = QApplication.instance() + if a is None: + a = QApplication([]) + return a + + +@pytest.fixture +def db(tmp_path): + cfg = DBConfig(path=tmp_path / "h.db", key="k") + db = DBManager(cfg) + assert db.connect() + # Seed two versions for a date + db.save_new_version("2025-02-10", "

v1

", note="v1", set_current=True) + 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) + + # (1) No current item -> returns immediately + dlg.list.setCurrentItem(None) + dlg._revert() # should not crash and should not accept + + # (2) Selecting the current item -> still returns early + # Build an item with the *current* id as payload + cur_id = next(v["id"] for v in db.list_versions("2025-02-10") if v["is_current"]) + it = QListWidgetItem("current") + it.setData(Qt.UserRole, cur_id) + dlg.list.addItem(it) + dlg.list.setCurrentItem(it) + dlg._revert() # should return early (no accept called) diff --git a/tests/test_main_module.py b/tests/test_main_module.py new file mode 100644 index 0000000..6d596b3 --- /dev/null +++ b/tests/test_main_module.py @@ -0,0 +1,14 @@ +import runpy +import types +import sys + + +def test_dunder_main_executes_without_launching_qt(monkeypatch): + # Replace bouquin.main with a stub that records invocation and returns immediately + calls = {"called": False} + mod = types.SimpleNamespace(main=lambda: calls.__setitem__("called", True)) + monkeypatch.setitem(sys.modules, "bouquin.main", mod) + + # Running the module as __main__ should call mod.main() but not start a Qt loop + runpy.run_module("bouquin.__main__", run_name="__main__") + assert calls["called"] is True diff --git a/tests/test_main_window_actions.py b/tests/test_main_window_actions.py new file mode 100644 index 0000000..2630830 --- /dev/null +++ b/tests/test_main_window_actions.py @@ -0,0 +1,90 @@ +from PySide6.QtCore import QDate +from bouquin.theme import ThemeManager +from bouquin.main_window import MainWindow +from bouquin.settings import save_db_config +from bouquin.db import DBManager + + +def _bootstrap_window(qapp, cfg): + # Ensure DB exists and key is valid in settings + mgr = DBManager(cfg) + assert mgr.connect() is True + save_db_config(cfg) + + themes = ThemeManager(qapp, cfg) + win = MainWindow(themes) + # Force an initial selected date + win.calendar.setSelectedDate(QDate.currentDate()) + return win + + +def test_move_todos_copies_unchecked(qapp, cfg, tmp_path): + cfg.move_todos = True + win = _bootstrap_window(qapp, cfg) + + # Seed yesterday with both checked and unchecked items using the same HTML pattern the app expects + y = QDate.currentDate().addDays(-1).toString("yyyy-MM-dd") + html = ( + "

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_edgecase.py b/tests/test_search_edgecase.py new file mode 100644 index 0000000..712f7e3 --- /dev/null +++ b/tests/test_search_edgecase.py @@ -0,0 +1,15 @@ +from bouquin.search import Search as SearchWidget + + +class DummyDB: + def search_entries(self, q): + return [] + + +def test_make_html_snippet_no_match_triggers_start_window(qtbot): + w = SearchWidget(db=DummyDB()) + qtbot.addWidget(w) + html = "

" + ("x" * 300) + "

" # long text, no token present + frag, left, right = w._make_html_snippet(html, "notfound", radius=10, maxlen=80) + assert frag != "" + assert left is False and right is True diff --git a/tests/test_search_edges.py b/tests/test_search_edges.py new file mode 100644 index 0000000..b3a6751 --- /dev/null +++ b/tests/test_search_edges.py @@ -0,0 +1,70 @@ +from PySide6.QtWidgets import QApplication +import pytest + +from bouquin.db import DBConfig, DBManager +from bouquin.search import Search + + +@pytest.fixture(scope="module") +def app(): + # Ensure a single QApplication exists + a = QApplication.instance() + if a is None: + a = QApplication([]) + yield a + + +@pytest.fixture +def fresh_db(tmp_path): + cfg = DBConfig(path=tmp_path / "test.db", key="testkey") + db = DBManager(cfg) + assert db.connect() is True + # Seed a couple of entries + db.save_new_version("2025-01-01", "

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.

", + ) + return db + + +def test_search_exception_path_closed_db_triggers_quiet_handling(app, fresh_db, qtbot): + # Close the DB to provoke an exception inside Search._search + fresh_db.close() + w = Search(fresh_db) + w.show() + qtbot.addWidget(w) + + # Typing should not raise; exception path returns empty results + w._search("anything") + assert w.results.isHidden() # remains hidden because there are no rows + + +def test_make_html_snippet_ellipses_both_sides(app, fresh_db): + w = Search(fresh_db) + # Choose a query so that the first match sits well inside a long string, + # forcing both left and right ellipses. + html = fresh_db.get_entry("2025-01-03") + snippet, left_ell, right_ell = w._make_html_snippet(html, "middle") + assert snippet # non-empty + assert left_ell is True + assert right_ell is True + + +def test_search_results_middle(app, fresh_db, qtbot): + w = Search(fresh_db) + w.show() + qtbot.addWidget(w) + # Choose a query so that the first match sits well inside a long string, + # forcing both left and right ellipses. + assert fresh_db.connect() + + w._search("middle") + assert w.results.isVisible() diff --git a/tests/test_search_windows.py b/tests/test_search_windows.py new file mode 100644 index 0000000..5770e73 --- /dev/null +++ b/tests/test_search_windows.py @@ -0,0 +1,37 @@ +import pytest +from bouquin.search import Search + + +@pytest.fixture +def search_widget(qapp): + # We don't need a real DB for snippet generation – pass None + return Search(db=None) + + +def test_make_html_snippet_empty(search_widget: Search): + html = "" + frag, has_prev, has_next = search_widget._make_html_snippet( + html, "", radius=10, maxlen=20 + ) + assert frag == "" and has_prev is False and has_next is False + + +def test_make_html_snippet_phrase_preferred(search_widget: Search): + html = "

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 new file mode 100644 index 0000000..bd86325 --- /dev/null +++ b/tests/test_settings_dialog_cancel_paths.py @@ -0,0 +1,111 @@ +import pytest +from PySide6.QtWidgets import QApplication, QDialog, QWidget + +from bouquin.db import DBConfig, DBManager +from bouquin.settings_dialog import SettingsDialog +from bouquin.settings import APP_NAME, APP_ORG +from bouquin.key_prompt import KeyPrompt +from bouquin.theme import Theme, ThemeManager, ThemeConfig + + +@pytest.fixture(scope="module") +def app(): + a = QApplication.instance() + if a is None: + a = QApplication([]) + a.setApplicationName(APP_NAME) + a.setOrganizationName(APP_ORG) + return a + + +@pytest.fixture +def db(tmp_path): + cfg = DBConfig(path=tmp_path / "s.db", key="abc") + m = DBManager(cfg) + assert m.connect() + return m + + +def test_theme_radio_initialisation_dark_and_light(app, db, monkeypatch, qtbot): + # Dark preselection + parent = _ParentWithThemes(app) + qtbot.addWidget(parent) + dlg = SettingsDialog(db.cfg, db, parent=parent) + qtbot.addWidget(dlg) + dlg.theme_dark.setChecked(True) + dlg._save() + assert dlg.config.theme == Theme.DARK.value + + # Light preselection + parent2 = _ParentWithThemes(app) + qtbot.addWidget(parent2) + dlg2 = SettingsDialog(db.cfg, db, parent=parent2) + qtbot.addWidget(dlg2) + dlg2.theme_light.setChecked(True) + dlg2._save() + assert dlg2.config.theme == Theme.LIGHT.value + + +def test_change_key_cancel_branches(app, db, monkeypatch, qtbot): + parent = _ParentWithThemes(app) + qtbot.addWidget(parent) + dlg = SettingsDialog(db.cfg, db, parent=parent) + qtbot.addWidget(dlg) + + # First prompt cancelled -> early return + monkeypatch.setattr(KeyPrompt, "exec", lambda self: QDialog.Rejected) + dlg._change_key() # should just return without altering key + assert dlg.key == "" + + # First OK, second cancelled -> early return at the second branch + state = {"calls": 0} + + def _exec(self): + state["calls"] += 1 + return QDialog.Accepted if state["calls"] == 1 else QDialog.Rejected + + monkeypatch.setattr(KeyPrompt, "exec", _exec) + # Also monkeypatch to control key() values + monkeypatch.setattr(KeyPrompt, "key", lambda self: "new-secret") + dlg._change_key() + # Because the second prompt was rejected, key should remain unchanged + assert dlg.key == "" + + +def test_save_key_checkbox_cancel_restores_checkbox(app, db, monkeypatch, qtbot): + parent = _ParentWithThemes(app) + qtbot.addWidget(parent) + dlg = SettingsDialog(db.cfg, db, parent=parent) + qtbot.addWidget(dlg) + qtbot.addWidget(dlg) + + # Simulate user checking the box, but cancelling the prompt -> code unchecks it again + monkeypatch.setattr(KeyPrompt, "exec", lambda self: QDialog.Rejected) + dlg.save_key_btn.setChecked(True) + # The slot toggled should run and revert it to unchecked + assert dlg.save_key_btn.isChecked() is False + + +def test_change_key_exception_path(app, db, monkeypatch, qtbot): + parent = _ParentWithThemes(app) + qtbot.addWidget(parent) + dlg = SettingsDialog(db.cfg, db, parent=parent) + qtbot.addWidget(dlg) + + # Accept both prompts and supply a key + monkeypatch.setattr(KeyPrompt, "exec", lambda self: QDialog.Accepted) + monkeypatch.setattr(KeyPrompt, "key", lambda self: "boom") + + # Force DB rekey to raise to exercise the except-branch + monkeypatch.setattr( + db, "rekey", lambda new_key: (_ for _ in ()).throw(RuntimeError("fail")) + ) + + # Should not raise; error is handled internally + dlg._change_key() + + +class _ParentWithThemes(QWidget): + def __init__(self, app): + super().__init__() + self.themes = ThemeManager(app, ThemeConfig())