Add the ability to choose the database path at startup. Add more tests. Add bandit
This commit is contained in:
parent
8c7226964a
commit
6bc5b66d3f
16 changed files with 297 additions and 97 deletions
|
|
@ -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
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) == ""
|
||||
|
|
|
|||
|
|
@ -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__()
|
||||
|
|
|
|||
106
tests/test_statistics_dialog.py
Normal file
106
tests/test_statistics_dialog.py
Normal file
|
|
@ -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")
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue