From 6bc5b66d3f6ecf396e5c8861c14136910cb92009 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Mon, 17 Nov 2025 15:15:00 +1100 Subject: [PATCH] Add the ability to choose the database path at startup. Add more tests. Add bandit --- .forgejo/workflows/lint.yml | 3 +- CHANGELOG.md | 5 ++ bouquin/db.py | 4 +- bouquin/key_prompt.py | 67 ++++++++++++++++++-- bouquin/locales/en.json | 4 +- bouquin/main_window.py | 16 ++++- bouquin/settings.py | 28 +++++++-- bouquin/settings_dialog.py | 39 ++---------- pyproject.toml | 2 +- tests/conftest.py | 4 +- tests/test_key_prompt.py | 2 +- tests/test_main_window.py | 38 +++++++++--- tests/test_settings.py | 35 ++++++++++- tests/test_settings_dialog.py | 31 ++-------- tests/test_statistics_dialog.py | 106 ++++++++++++++++++++++++++++++++ tests/test_tabs.py | 10 +-- 16 files changed, 297 insertions(+), 97 deletions(-) create mode 100644 tests/test_statistics_dialog.py diff --git a/.forgejo/workflows/lint.yml b/.forgejo/workflows/lint.yml index 53ab3eb..5bb3794 100644 --- a/.forgejo/workflows/lint.yml +++ b/.forgejo/workflows/lint.yml @@ -15,7 +15,7 @@ jobs: run: | apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - black pyflakes3 vulture + black pyflakes3 vulture python3-bandit - name: Run linters run: | @@ -24,3 +24,4 @@ jobs: pyflakes3 bouquin/* pyflakes3 tests/* vulture + bandit -s B110 -r bouquin/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b35218..6c479bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 0.3.2 + + * Add weekday letters on left axis of Statistics page + * Add the ability to choose the database path at startup + # 0.3.1 * Make it possible to add a tag from the Tag Browser diff --git a/bouquin/db.py b/bouquin/db.py index 7fd521c..25d7527 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -433,7 +433,7 @@ class DBManager: """ if not name: return "#CCCCCC" - h = int(hashlib.sha1(name.encode("utf-8")).hexdigest()[:8], 16) + h = int(hashlib.sha1(name.encode("utf-8")).hexdigest()[:8], 16) # nosec return _TAG_COLORS[h % len(_TAG_COLORS)] # -------- Tags: per-page ------------------------------------------- @@ -514,7 +514,7 @@ class DBManager: SELECT id, name FROM tags WHERE name IN ({placeholders}); - """, + """, # nosec tuple(final_tag_names), ).fetchall() ids_by_name = {r["name"]: r["id"] for r in rows} diff --git a/bouquin/key_prompt.py b/bouquin/key_prompt.py index b29101c..67942ab 100644 --- a/bouquin/key_prompt.py +++ b/bouquin/key_prompt.py @@ -1,12 +1,16 @@ from __future__ import annotations +from pathlib import Path + from PySide6.QtWidgets import ( QDialog, QVBoxLayout, + QHBoxLayout, QLabel, QLineEdit, QPushButton, QDialogButtonBox, + QFileDialog, ) from . import strings @@ -18,32 +22,85 @@ class KeyPrompt(QDialog): parent=None, title: str = strings._("key_prompt_enter_key"), message: str = strings._("key_prompt_enter_key"), + initial_db_path: str | Path | None = None, + show_db_change: bool = False, ): """ Prompt the user for the key required to decrypt the database. Used when opening the app, unlocking the idle locked screen, or when rekeying. + + If show_db_change is true, also show a QFileDialog allowing to + select a database file, else the default from settings is used. """ super().__init__(parent) self.setWindowTitle(title) + + self._db_path: Path | None = Path(initial_db_path) if initial_db_path else None + v = QVBoxLayout(self) + v.addWidget(QLabel(message)) - self.edit = QLineEdit() - self.edit.setEchoMode(QLineEdit.Password) - v.addWidget(self.edit) + + # DB chooser + self.path_edit: QLineEdit | None = None + if show_db_change: + path_row = QHBoxLayout() + self.path_edit = QLineEdit() + if self._db_path is not None: + self.path_edit.setText(str(self._db_path)) + + browse_btn = QPushButton(strings._("select_notebook")) + + def _browse(): + start_dir = str(self._db_path or "") + fname, _ = QFileDialog.getOpenFileName( + self, + strings._("select_notebook"), + start_dir, + "SQLCipher DB (*.db);;All files (*)", + ) + if fname: + self._db_path = Path(fname) + if self.path_edit is not None: + self.path_edit.setText(fname) + + browse_btn.clicked.connect(_browse) + + path_row.addWidget(self.path_edit, 1) + path_row.addWidget(browse_btn) + v.addLayout(path_row) + + # Key entry + self.key_entry = QLineEdit() + self.key_entry.setEchoMode(QLineEdit.Password) + v.addWidget(self.key_entry) + toggle = QPushButton(strings._("show")) toggle.setCheckable(True) toggle.toggled.connect( - lambda c: self.edit.setEchoMode( + lambda c: self.key_entry.setEchoMode( QLineEdit.Normal if c else QLineEdit.Password ) ) v.addWidget(toggle) + bb = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) bb.accepted.connect(self.accept) bb.rejected.connect(self.reject) v.addWidget(bb) + self.key_entry.setFocus() + self.resize(500, self.sizeHint().height()) + def key(self) -> str: - return self.edit.text() + return self.key_entry.text() + + def db_path(self) -> Path | None: + """Return the chosen DB path (or None if unchanged/not shown).""" + if self.path_edit is not None: + text = self.path_edit.text().strip() + if text: + return Path(text) + return self._db_path diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index 09dba58..69af68a 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -147,5 +147,7 @@ "stats_heatmap_metric": "Colour by", "stats_metric_words": "Words", "stats_metric_revisions": "Revisions", - "stats_no_data": "No statistics available yet." + "stats_no_data": "No statistics available yet.", + "select_notebook": "Select notebook" + } diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 6a64888..efdbfd8 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -329,14 +329,14 @@ class MainWindow(QMainWindow): try: self.db = DBManager(self.cfg) ok = self.db.connect() + return ok except Exception as e: if str(e) == "file is not a database": error = strings._("db_key_incorrect") else: error = str(e) QMessageBox.critical(self, strings._("db_database_error"), error) - return False - return ok + sys.exit(1) def _prompt_for_key_until_valid(self, first_time: bool) -> bool: """ @@ -349,10 +349,20 @@ class MainWindow(QMainWindow): title = strings._("unlock_encrypted_notebook") message = strings._("unlock_encrypted_notebook_explanation") while True: - dlg = KeyPrompt(self, title, message) + dlg = KeyPrompt( + self, title, message, initial_db_path=self.cfg.path, show_db_change=True + ) if dlg.exec() != QDialog.Accepted: return False self.cfg.key = dlg.key() + + # Update DB path if the user changed it + new_path = dlg.db_path() + if new_path is not None and new_path != self.cfg.path: + self.cfg.path = new_path + # Persist immediately so next run is pre-filled with this file + save_db_config(self.cfg) + if self._try_connect(): return True diff --git a/bouquin/settings.py b/bouquin/settings.py index cc9b794..3a2becc 100644 --- a/bouquin/settings.py +++ b/bouquin/settings.py @@ -13,15 +13,31 @@ def get_settings() -> QSettings: return QSettings(APP_ORG, APP_NAME) +def _default_db_location() -> Path: + """Where we put the notebook if nothing has been configured yet.""" + base = Path(QStandardPaths.writableLocation(QStandardPaths.AppDataLocation)) + base.mkdir(parents=True, exist_ok=True) + return base / "notebook.db" + + def load_db_config() -> DBConfig: s = get_settings() - default_db_path = str( - Path(QStandardPaths.writableLocation(QStandardPaths.AppDataLocation)) - / "notebook.db" - ) - path = Path(s.value("db/path", default_db_path)) + # --- DB Path ------------------------------------------------------- + # Prefer the new key; fall back to the legacy one. + path_str = s.value("db/default_db", "", type=str) + if not path_str: + legacy = s.value("db/path", "", type=str) + if legacy: + path_str = legacy + # Optional: migrate and clean up the old key + s.setValue("db/default_db", legacy) + s.remove("db/path") + path = Path(path_str) if path_str else _default_db_location() + + # --- Other settings ------------------------------------------------ key = s.value("db/key", "") + idle = s.value("ui/idle_minutes", 15, type=int) theme = s.value("ui/theme", "system", type=str) move_todos = s.value("ui/move_todos", False, type=bool) @@ -38,7 +54,7 @@ def load_db_config() -> DBConfig: def save_db_config(cfg: DBConfig) -> None: s = get_settings() - s.setValue("db/path", str(cfg.path)) + s.setValue("db/default_db", str(cfg.path)) s.setValue("db/key", str(cfg.key)) s.setValue("ui/idle_minutes", str(cfg.idle_minutes)) s.setValue("ui/theme", str(cfg.theme)) diff --git a/bouquin/settings_dialog.py b/bouquin/settings_dialog.py index 56a1ecd..7a9c73a 100644 --- a/bouquin/settings_dialog.py +++ b/bouquin/settings_dialog.py @@ -12,10 +12,7 @@ from PySide6.QtWidgets import ( QLabel, QHBoxLayout, QVBoxLayout, - QWidget, - QLineEdit, QPushButton, - QFileDialog, QDialogButtonBox, QRadioButton, QSizePolicy, @@ -47,7 +44,7 @@ class SettingsDialog(QDialog): self.setMinimumWidth(560) self.setSizeGripEnabled(True) - current_settings = load_db_config() + self.current_settings = load_db_config() # Add theme selection theme_group = QGroupBox(strings._("theme")) @@ -58,7 +55,7 @@ class SettingsDialog(QDialog): self.theme_dark = QRadioButton(strings._("dark")) # Load current theme from settings - current_theme = current_settings.theme + current_theme = self.current_settings.theme if current_theme == Theme.DARK.value: self.theme_dark.setChecked(True) elif current_theme == Theme.LIGHT.value: @@ -80,7 +77,7 @@ class SettingsDialog(QDialog): self.locale_combobox = QComboBox() self.locale_combobox.addItems(strings._AVAILABLE) - self.locale_combobox.setCurrentText(current_settings.locale) + self.locale_combobox.setCurrentText(self.current_settings.locale) locale_layout.addWidget(self.locale_combobox, 0, Qt.AlignLeft) # Explanation for locale @@ -104,26 +101,12 @@ class SettingsDialog(QDialog): self.move_todos = QCheckBox( strings._("move_yesterdays_unchecked_todos_to_today_on_startup") ) - self.move_todos.setChecked(current_settings.move_todos) + self.move_todos.setChecked(self.current_settings.move_todos) self.move_todos.setCursor(Qt.PointingHandCursor) behaviour_layout.addWidget(self.move_todos) form.addRow(behaviour_group) - self.path_edit = QLineEdit(str(self._cfg.path)) - self.path_edit.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - browse_btn = QPushButton(strings._("browse")) - browse_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) - browse_btn.clicked.connect(self._browse) - path_row = QWidget() - h = QHBoxLayout(path_row) - h.setContentsMargins(0, 0, 0, 0) - h.addWidget(self.path_edit, 1) - h.addWidget(browse_btn, 0) - h.setStretch(0, 1) - h.setStretch(1, 0) - form.addRow(strings._("database_path"), path_row) - # Encryption settings enc_group = QGroupBox(strings._("encryption")) enc = QVBoxLayout(enc_group) @@ -132,7 +115,7 @@ class SettingsDialog(QDialog): # Checkbox to remember key self.save_key_btn = QCheckBox(strings._("remember_key")) - self.key = current_settings.key or "" + self.key = self.current_settings.key or "" self.save_key_btn.setChecked(bool(self.key)) self.save_key_btn.setCursor(Qt.PointingHandCursor) self.save_key_btn.toggled.connect(self._save_key_btn_clicked) @@ -236,16 +219,6 @@ class SettingsDialog(QDialog): v.addLayout(form) v.addWidget(bb, 0, Qt.AlignRight) - def _browse(self): - p, _ = QFileDialog.getSaveFileName( - self, - strings._("database_path"), - self.path_edit.text(), - "(*.db);;(*)", - ) - if p: - self.path_edit.setText(p) - def _save(self): # Save the selected theme into QSettings if self.theme_dark.isChecked(): @@ -258,7 +231,7 @@ class SettingsDialog(QDialog): key_to_save = self.key if self.save_key_btn.isChecked() else "" self._cfg = DBConfig( - path=Path(self.path_edit.text()), + path=Path(self.current_settings.path), key=key_to_save, idle_minutes=self.idle_spin.value(), theme=selected_theme.value, diff --git a/pyproject.toml b/pyproject.toml index e132b00..ebf80ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bouquin" -version = "0.3.1" +version = "0.3.2" description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." authors = ["Miguel Jacq "] readme = "README.md" diff --git a/tests/conftest.py b/tests/conftest.py index c29e6bb..445c48e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,10 +33,10 @@ def isolate_qsettings(tmp_path_factory): def tmp_db_cfg(tmp_path): from bouquin.db import DBConfig - db_path = tmp_path / "notebook.db" + default_db = tmp_path / "notebook.db" key = "test-secret-key" return DBConfig( - path=db_path, key=key, idle_minutes=0, theme="light", move_todos=True + path=default_db, key=key, idle_minutes=0, theme="light", move_todos=True ) diff --git a/tests/test_key_prompt.py b/tests/test_key_prompt.py index 07a0044..32db6d9 100644 --- a/tests/test_key_prompt.py +++ b/tests/test_key_prompt.py @@ -5,5 +5,5 @@ def test_key_prompt_roundtrip(qtbot): kp = KeyPrompt() qtbot.addWidget(kp) kp.show() - kp.edit.setText("swordfish") + kp.key_entry.setText("swordfish") assert kp.key() == "swordfish" diff --git a/tests/test_main_window.py b/tests/test_main_window.py index 8ce5564..75314d1 100644 --- a/tests/test_main_window.py +++ b/tests/test_main_window.py @@ -18,7 +18,7 @@ from unittest.mock import Mock, patch def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db): s = get_settings() - s.setValue("db/path", str(tmp_db_cfg.path)) + s.setValue("db/default_db", str(tmp_db_cfg.path)) s.setValue("db/key", tmp_db_cfg.key) s.setValue("ui/idle_minutes", 0) s.setValue("ui/theme", "light") @@ -48,7 +48,7 @@ def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db): def _auto_accept_keyprompt(): for wdg in QApplication.topLevelWidgets(): if isinstance(wdg, KeyPrompt): - wdg.edit.setText(tmp_db_cfg.key) + wdg.key_entry.setText(tmp_db_cfg.key) wdg.accept() w._enter_lock() @@ -59,7 +59,7 @@ def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db): def test_load_yesterday_todos_moves_items(qtbot, app, tmp_db_cfg, fresh_db): s = get_settings() - s.setValue("db/path", str(tmp_db_cfg.path)) + s.setValue("db/default_db", str(tmp_db_cfg.path)) s.setValue("db/key", tmp_db_cfg.key) s.setValue("ui/move_todos", True) s.setValue("ui/theme", "light") @@ -122,7 +122,7 @@ def test_export_success_and_error(qtbot, app, fresh_db, tmp_path, monkeypatch): from bouquin.settings import get_settings s = get_settings() - s.setValue("db/path", str(fresh_db.cfg.path)) + s.setValue("db/default_db", str(fresh_db.cfg.path)) s.setValue("db/key", fresh_db.cfg.key) s.setValue("ui/idle_minutes", 0) s.setValue("ui/theme", "light") @@ -196,7 +196,7 @@ def test_backup_path(qtbot, app, fresh_db, tmp_path, monkeypatch): from bouquin.settings import get_settings s = get_settings() - s.setValue("db/path", str(fresh_db.cfg.path)) + s.setValue("db/default_db", str(fresh_db.cfg.path)) s.setValue("db/key", fresh_db.cfg.key) s.setValue("ui/idle_minutes", 0) s.setValue("ui/theme", "light") @@ -251,7 +251,7 @@ def test_close_tab_edges_and_autosave(qtbot, app, fresh_db, monkeypatch): from bouquin.settings import get_settings s = get_settings() - s.setValue("db/path", str(fresh_db.cfg.path)) + s.setValue("db/default_db", str(fresh_db.cfg.path)) s.setValue("db/key", fresh_db.cfg.key) s.setValue("ui/idle_minutes", 0) s.setValue("ui/theme", "light") @@ -472,8 +472,24 @@ def test_try_connect_maps_errors( mwmod.QMessageBox, "critical", staticmethod(fake_critical), raising=True ) - ok = w._try_connect() - assert ok is False + # Intercept sys.exit so the test process doesn't actually die + exited = {} + + def fake_exit(code=0): + exited["code"] = code + # mimic real behaviour: raise SystemExit so callers see a fatal exit + raise SystemExit(code) + + monkeypatch.setattr(mwmod.sys, "exit", fake_exit, raising=True) + + # _try_connect should now raise SystemExit instead of returning + with pytest.raises(SystemExit): + w._try_connect() + + # We attempted to exit with code 1 + assert exited["code"] == 1 + + # And we still showed the right error message assert "database" in shown["title"].lower() if expect_key_msg: assert "key" in shown["text"].lower() @@ -499,6 +515,9 @@ def test_prompt_for_key_cancel_returns_false(qtbot, tmp_db_cfg, app, monkeypatch def key(self): return "" + def db_path(self) -> Path | None: + return "foo.db" + monkeypatch.setattr(mwmod, "KeyPrompt", CancelPrompt, raising=True) assert w._prompt_for_key_until_valid(first_time=False) is False @@ -517,6 +536,9 @@ def test_prompt_for_key_accept_then_connects(qtbot, tmp_db_cfg, app, monkeypatch def key(self): return "abc" + def db_path(self) -> Path | None: + return "foo.db" + monkeypatch.setattr(mwmod, "KeyPrompt", OKPrompt, raising=True) monkeypatch.setattr(w, "_try_connect", lambda: True, raising=True) assert w._prompt_for_key_until_valid(first_time=True) is True diff --git a/tests/test_settings.py b/tests/test_settings.py index 15b5f85..5654193 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -6,17 +6,30 @@ from bouquin.settings import ( from bouquin.db import DBConfig -def test_load_and_save_db_config_roundtrip(app, tmp_path): +def _clear_db_settings(): s = get_settings() - for k in ["db/path", "db/key", "ui/idle_minutes", "ui/theme", "ui/move_todos"]: + for k in [ + "db/default_db", + "db/path", # legacy key + "db/key", + "ui/idle_minutes", + "ui/theme", + "ui/move_todos", + "ui/locale", + ]: s.remove(k) + +def test_load_and_save_db_config_roundtrip(app, tmp_path): + _clear_db_settings() + cfg = DBConfig( path=tmp_path / "notes.db", key="abc123", idle_minutes=7, theme="dark", move_todos=True, + locale="en", ) save_db_config(cfg) @@ -26,3 +39,21 @@ def test_load_and_save_db_config_roundtrip(app, tmp_path): assert loaded.idle_minutes == cfg.idle_minutes assert loaded.theme == cfg.theme assert loaded.move_todos == cfg.move_todos + assert loaded.locale == cfg.locale + + +def test_load_db_config_migrates_legacy_db_path(app, tmp_path): + _clear_db_settings() + s = get_settings() + + legacy_path = tmp_path / "legacy.db" + s.setValue("db/path", str(legacy_path)) + + cfg = load_db_config() + + # Uses the legacy value… + assert cfg.path == legacy_path + + # …but also migrates to the new key and clears the old one. + assert s.value("db/default_db", "", type=str) == str(legacy_path) + assert s.value("db/path", "", type=str) == "" diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index 9d7e03a..4301de4 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -8,7 +8,7 @@ from PySide6.QtCore import QTimer from PySide6.QtWidgets import QApplication, QMessageBox, QWidget, QDialog -def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db, tmp_path): +def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db): # Provide a parent that exposes a real ThemeManager (dialog calls parent().themes.set(...)) app = QApplication.instance() parent = QWidget() @@ -17,7 +17,6 @@ def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db, tmp_path) qtbot.addWidget(dlg) dlg.show() - dlg.path_edit.setText(str(tmp_path / "alt.db")) dlg.idle_spin.setValue(3) dlg.theme_light.setChecked(True) dlg.move_todos.setChecked(True) @@ -34,7 +33,6 @@ def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db, tmp_path) dlg._save() cfg = dlg.config - assert cfg.path.name == "alt.db" assert cfg.idle_minutes == 3 assert cfg.theme in ("light", "dark", "system") @@ -55,7 +53,7 @@ def test_save_key_toggle_roundtrip(qtbot, tmp_db_cfg, fresh_db, app): def _pump(): for w in QApplication.topLevelWidgets(): if isinstance(w, KeyPrompt): - w.edit.setText("supersecret") + w.key_entry.setText("supersecret") w.accept() elif isinstance(w, QMessageBox): w.accept() @@ -99,7 +97,7 @@ def test_change_key_mismatch_shows_error(qtbot, tmp_db_cfg, tmp_path, app): def _pump_popups(): for w in QApplication.topLevelWidgets(): if isinstance(w, KeyPrompt): - w.edit.setText(keys.pop(0) if keys else "zzz") + w.key_entry.setText(keys.pop(0) if keys else "zzz") w.accept() elif isinstance(w, QMessageBox): w.accept() @@ -141,7 +139,7 @@ def test_change_key_success(qtbot, tmp_path, app): def _pump(): for w in QApplication.topLevelWidgets(): if isinstance(w, KeyPrompt): - w.edit.setText(keys.pop(0) if keys else "newkey") + w.key_entry.setText(keys.pop(0) if keys else "newkey") w.accept() elif isinstance(w, QMessageBox): w.accept() @@ -203,27 +201,6 @@ def test_settings_compact_error(qtbot, app, monkeypatch, tmp_db_cfg, fresh_db): assert called["text"] -def test_settings_browse_sets_path(qtbot, app, tmp_path, fresh_db, monkeypatch): - parent = QWidget() - parent.themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) - cfg = DBConfig( - path=tmp_path / "x.db", key="k", idle_minutes=0, theme="light", move_todos=True - ) - dlg = SettingsDialog(cfg, fresh_db, parent=parent) - qtbot.addWidget(dlg) - dlg.show() - - p = tmp_path / "new_file.db" - monkeypatch.setattr( - sd.QFileDialog, - "getSaveFileName", - staticmethod(lambda *a, **k: (str(p), "DB Files (*.db)")), - raising=False, - ) - dlg._browse() - assert dlg.path_edit.text().endswith("new_file.db") - - class _Host(QWidget): def __init__(self, themes): super().__init__() diff --git a/tests/test_statistics_dialog.py b/tests/test_statistics_dialog.py new file mode 100644 index 0000000..9334ee0 --- /dev/null +++ b/tests/test_statistics_dialog.py @@ -0,0 +1,106 @@ +import datetime as _dt + +from PySide6.QtWidgets import QLabel + +from bouquin.statistics_dialog import StatisticsDialog +from bouquin import strings + + +class FakeStatsDB: + """Minimal stub that returns a fixed stats payload.""" + + def __init__(self): + d1 = _dt.date(2024, 1, 1) + d2 = _dt.date(2024, 1, 2) + self.stats = ( + 2, # pages_with_content + 5, # total_revisions + "2024-01-02", # page_most_revisions + 3, # page_most_revisions_count + {d1: 10, d2: 20}, # words_by_date + 30, # total_words + 4, # unique_tags + "2024-01-02", # page_most_tags + 2, # page_most_tags_count + {d1: 1, d2: 2}, # revisions_by_date + ) + self.called = False + + def gather_stats(self): + self.called = True + return self.stats + + +def test_statistics_dialog_populates_fields_and_heatmap(qtbot): + # Make sure we have a known language for label texts + strings.load_strings("en") + + db = FakeStatsDB() + dlg = StatisticsDialog(db) + qtbot.addWidget(dlg) + dlg.show() + + # Stats were actually requested from the DB + assert db.called + + # Window title comes from translations + assert dlg.windowTitle() == strings._("statistics") + + # Grab all label texts for simple content checks + label_texts = {lbl.text() for lbl in dlg.findChildren(QLabel)} + + # Page with most revisions / tags are rendered as "DATE (COUNT)" + assert "2024-01-02 (3)" in label_texts + assert "2024-01-02 (2)" in label_texts + + # Heatmap is created and uses "words" by default + words_by_date = db.stats[4] + revisions_by_date = db.stats[-1] + + assert hasattr(dlg, "_heatmap") + assert dlg._heatmap._data == words_by_date + + # Switching the metric to "revisions" should swap the dataset + dlg.metric_combo.setCurrentIndex(1) # 0 = words, 1 = revisions + qtbot.wait(10) + assert dlg._heatmap._data == revisions_by_date + + +class EmptyStatsDB: + """Stub that returns a 'no data yet' stats payload.""" + + def __init__(self): + self.called = False + + def gather_stats(self): + self.called = True + return ( + 0, # pages_with_content + 0, # total_revisions + None, # page_most_revisions + 0, + {}, # words_by_date + 0, # total_words + 0, # unique_tags + None, # page_most_tags + 0, + {}, # revisions_by_date + ) + + +def test_statistics_dialog_no_data_shows_placeholder(qtbot): + strings.load_strings("en") + + db = EmptyStatsDB() + dlg = StatisticsDialog(db) + qtbot.addWidget(dlg) + dlg.show() + + assert db.called + + label_texts = [lbl.text() for lbl in dlg.findChildren(QLabel)] + assert strings._("stats_no_data") in label_texts + + # When there's no data, the heatmap and metric combo shouldn't exist + assert not hasattr(dlg, "metric_combo") + assert not hasattr(dlg, "_heatmap") diff --git a/tests/test_tabs.py b/tests/test_tabs.py index 0b7d781..fe73828 100644 --- a/tests/test_tabs.py +++ b/tests/test_tabs.py @@ -12,7 +12,7 @@ from bouquin.history_dialog import HistoryDialog def test_tabs_open_and_deduplicate(qtbot, app, tmp_db_cfg, fresh_db): # point to the temp encrypted DB s = get_settings() - s.setValue("db/path", str(tmp_db_cfg.path)) + s.setValue("db/default_db", str(tmp_db_cfg.path)) s.setValue("db/key", tmp_db_cfg.key) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) @@ -45,7 +45,7 @@ def test_toolbar_signals_dispatch_once_per_click( qtbot, app, tmp_db_cfg, fresh_db, monkeypatch ): s = get_settings() - s.setValue("db/path", str(tmp_db_cfg.path)) + s.setValue("db/default_db", str(tmp_db_cfg.path)) s.setValue("db/key", tmp_db_cfg.key) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) @@ -116,7 +116,7 @@ def test_history_and_insert_image_not_duplicated( qtbot, app, tmp_db_cfg, fresh_db, monkeypatch, tmp_path ): s = get_settings() - s.setValue("db/path", str(tmp_db_cfg.path)) + s.setValue("db/default_db", str(tmp_db_cfg.path)) s.setValue("db/key", tmp_db_cfg.key) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) @@ -156,7 +156,7 @@ def test_history_and_insert_image_not_duplicated( def test_highlighter_attached_after_text_load(qtbot, app, tmp_db_cfg, fresh_db): s = get_settings() - s.setValue("db/path", str(tmp_db_cfg.path)) + s.setValue("db/default_db", str(tmp_db_cfg.path)) s.setValue("db/key", tmp_db_cfg.key) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) @@ -171,7 +171,7 @@ def test_highlighter_attached_after_text_load(qtbot, app, tmp_db_cfg, fresh_db): def test_findbar_works_for_current_tab(qtbot, app, tmp_db_cfg, fresh_db): s = get_settings() - s.setValue("db/path", str(tmp_db_cfg.path)) + s.setValue("db/default_db", str(tmp_db_cfg.path)) s.setValue("db/key", tmp_db_cfg.key) themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))