diff --git a/CHANGELOG.md b/CHANGELOG.md index acf8dd7..7243f00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,6 @@ # 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 b6c937b..54c811c 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -257,31 +257,68 @@ class DBManager: ).fetchall() return [dict(r) for r in rows] - def get_version(self, *, version_id: int) -> dict | None: + def get_version( + self, + *, + date_iso: str | None = None, + version_no: int | None = None, + version_id: int | None = None, + ) -> dict | None: """ - Fetch a specific version by version_id. + Fetch a specific version by (date, version_no) OR by version_id. Returns a dict with keys: id, date, version_no, created_at, note, content. """ cur = self.conn.cursor() - row = cur.execute( - "SELECT id, date, version_no, created_at, note, content " - "FROM versions WHERE id=?;", - (version_id,), - ).fetchone() + 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() return dict(row) if row else None - def revert_to_version(self, date_iso: str, version_id: int) -> None: + def revert_to_version( + self, + date_iso: str, + *, + version_no: int | None = None, + version_id: int | None = None, + ) -> 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() - # 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") + 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") with self.conn: cur.execute( @@ -305,18 +342,20 @@ class DBManager: ).fetchall() return [(r[0], r[1]) for r in rows] - def export_json(self, entries: Sequence[Entry], file_path: str) -> None: + def export_json( + self, entries: Sequence[Entry], file_path: str, pretty: bool = True + ) -> None: """ Export to json. """ data = [{"date": d, "content": c} for d, c in entries] with open(file_path, "w", encoding="utf-8") as f: - json.dump(data, f, ensure_ascii=False, indent=2) + if pretty: + json.dump(data, f, ensure_ascii=False, indent=2) + else: + 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) @@ -330,10 +369,6 @@ 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 @@ -372,9 +407,6 @@ class DBManager: def export_html( self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export" ) -> None: - """ - Export to HTML with a heading. - """ parts = [ "", '', @@ -397,10 +429,6 @@ 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 = [ "", '', @@ -441,10 +469,6 @@ 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() @@ -459,7 +483,7 @@ class DBManager: elif ext in {".sql", ".sqlite"}: self.export_sql(file_path) elif ext == ".md": - self.export_markdown(entries, file_path) + self.export_markdown(file_path) else: raise ValueError(f"Unsupported extension: {ext}") diff --git a/bouquin/editor.py b/bouquin/editor.py index e3f7133..cb18755 100644 --- a/bouquin/editor.py +++ b/bouquin/editor.py @@ -140,8 +140,10 @@ class Editor(QTextEdit): bc.setPosition(b.position() + b.length()) return blocks > 0 and (codeish / blocks) >= 0.6 - def _nearest_code_frame(self, cursor, tolerant: bool = False): + def _nearest_code_frame(self, cursor=None, 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): @@ -269,6 +271,16 @@ 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(".,;:!?\"'") @@ -842,6 +854,14 @@ 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 0113ba1..98399b9 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 version + # Enable revert only if selecting a non-current 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 to the older version + # Flip head pointer 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 bef0571..095093c 100644 --- a/bouquin/key_prompt.py +++ b/bouquin/key_prompt.py @@ -17,12 +17,6 @@ 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 5d7d40a..d019f3b 100644 --- a/bouquin/lock_overlay.py +++ b/bouquin/lock_overlay.py @@ -7,9 +7,6 @@ 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) @@ -42,9 +39,6 @@ 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 @@ -64,7 +58,7 @@ class LockOverlay(QWidget): self.setStyleSheet( f""" -#LockOverlay {{ background-color: rgb(0,0,0); }} +#LockOverlay {{ background-color: rgb(0,0,0); }} /* opaque, no transparency */ #LockOverlay QLabel#lockLabel {{ color: {accent_hex}; font-weight: 600; }} #LockOverlay QPushButton#unlockButton {{ @@ -119,7 +113,7 @@ class LockOverlay(QWidget): def changeEvent(self, ev): super().changeEvent(ev) - # Only re-style on palette flips (user changed theme) + # Only re-style on palette flips 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 d00e013..f3f1ef5 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -26,7 +26,6 @@ from PySide6.QtGui import ( QGuiApplication, QPalette, QTextCharFormat, - QTextCursor, QTextListFormat, ) from PySide6.QtWidgets import ( @@ -122,7 +121,7 @@ class MainWindow(QMainWindow): split = QSplitter() split.addWidget(left_panel) split.addWidget(self.editor) - split.setStretchFactor(1, 1) + split.setStretchFactor(1, 1) # editor grows container = QWidget() lay = QVBoxLayout(container) @@ -147,16 +146,6 @@ 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) @@ -292,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() + self._apply_link_css() # Reapply link styles based on the current theme self._apply_search_highlights(getattr(self, "_search_highlighted_dates", set())) self.calendar.update() self.editor.viewport().update() @@ -309,6 +298,7 @@ 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 @@ -357,6 +347,7 @@ 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() @@ -492,8 +483,7 @@ class MainWindow(QMainWindow): # Inject the extra_data before the closing modified = re.sub(r"(<\/body><\/html>)", extra_data_html + r"\1", text) text = modified - # Force a save now so we don't lose it. - self._set_editor_html_preserve_view(text) + self.editor.setHtml(text) self._dirty = True self._save_date(date_iso, True) @@ -501,7 +491,9 @@ class MainWindow(QMainWindow): QMessageBox.critical(self, "Read Error", str(e)) return - self._set_editor_html_preserve_view(text) + self.editor.blockSignals(True) + self.editor.setHtml(text) + self.editor.blockSignals(False) self._dirty = False # track which date the editor currently represents @@ -860,14 +852,9 @@ 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 @@ -883,10 +870,6 @@ 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: @@ -903,7 +886,6 @@ 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): @@ -919,61 +901,3 @@ 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 27feeaf..5e4095e 100644 --- a/bouquin/save_dialog.py +++ b/bouquin/save_dialog.py @@ -18,9 +18,6 @@ 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 bbe5a53..2805e4c 100644 --- a/bouquin/search.py +++ b/bouquin/search.py @@ -70,6 +70,7 @@ 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 ddd9fa5..341466e 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; fall back to Light + # 0=Light, 1=Dark in newer Qt; fall back to Light theme = Theme.DARK if scheme == 1 else Theme.LIGHT # Always use Fusion so palette applies consistently cross-platform @@ -58,6 +58,7 @@ 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 7b0f248..78c737e 100644 --- a/bouquin/toolbar.py +++ b/bouquin/toolbar.py @@ -140,11 +140,6 @@ 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 d9ecc99..e949b1a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -102,32 +102,3 @@ 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 deleted file mode 100644 index 8fd1166..0000000 --- a/tests/test_db_migrations_and_versions.py +++ /dev/null @@ -1,117 +0,0 @@ -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 8c80160..d369abf 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", ".md"]: + for ext in [".json", ".csv", ".txt", ".html"]: 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 e0951b8..520e941 100644 --- a/tests/test_editor.py +++ b/tests/test_editor.py @@ -145,6 +145,10 @@ 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 deleted file mode 100644 index 4d905f7..0000000 --- a/tests/test_editor_features_more.py +++ /dev/null @@ -1,103 +0,0 @@ -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 deleted file mode 100644 index 8cb81d9..0000000 --- a/tests/test_editor_images_text_states.py +++ /dev/null @@ -1,75 +0,0 @@ -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 deleted file mode 100644 index fd015a9..0000000 --- a/tests/test_editor_more.py +++ /dev/null @@ -1,136 +0,0 @@ -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 deleted file mode 100644 index f54e4d8..0000000 --- a/tests/test_history_dialog_revert_edges.py +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index 6d596b3..0000000 --- a/tests/test_main_module.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 2630830..0000000 --- a/tests/test_main_window_actions.py +++ /dev/null @@ -1,90 +0,0 @@ -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 deleted file mode 100644 index 712f7e3..0000000 --- a/tests/test_search_edgecase.py +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index b3a6751..0000000 --- a/tests/test_search_edges.py +++ /dev/null @@ -1,70 +0,0 @@ -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 deleted file mode 100644 index 5770e73..0000000 --- a/tests/test_search_windows.py +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index bd86325..0000000 --- a/tests/test_settings_dialog_cancel_paths.py +++ /dev/null @@ -1,111 +0,0 @@ -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())