diff --git a/bouquin/__init__.py b/bouquin/__init__.py index c28a133..e69de29 100644 --- a/bouquin/__init__.py +++ b/bouquin/__init__.py @@ -1 +0,0 @@ -from .main import main diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 5f8f5fd..7b29bbc 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -359,8 +359,8 @@ class MainWindow(QMainWindow): def _apply_link_css(self): if self.themes and self.themes.current() == Theme.DARK: - anchor = "#FFA500" # Orange links - visited = "#B38000" # Visited links color + anchor = Theme.ORANGE_ANCHOR.value + visited = Theme.ORANGE_ANCHOR_VISITED.value css = f""" a {{ color: {anchor}; text-decoration: underline; }} a:visited {{ color: {visited}; }} @@ -385,31 +385,35 @@ class MainWindow(QMainWindow): app_pal = QApplication.instance().palette() if theme == Theme.DARK: - orange = QColor("#FFA500") - black = QColor(0, 0, 0) + highlight = QColor(Theme.ORANGE_ANCHOR.value) + black = QColor(0, 0, 0) + + highlight_css = Theme.ORANGE_ANCHOR.value # Per-widget palette: selection color inside the date grid pal = self.calendar.palette() - pal.setColor(QPalette.Highlight, orange) + pal.setColor(QPalette.Highlight, highlight) pal.setColor(QPalette.HighlightedText, black) self.calendar.setPalette(pal) # Stylesheet: nav bar + selected-day background - self.calendar.setStyleSheet(""" - QWidget#qt_calendar_navigationbar { background-color: #FFA500; } - QCalendarWidget QToolButton { color: black; } - QCalendarWidget QToolButton:hover { background-color: rgba(255,165,0,0.20); } + self.calendar.setStyleSheet( + f""" + QWidget#qt_calendar_navigationbar {{ background-color: {highlight_css}; }} + QCalendarWidget QToolButton {{ color: black; }} + QCalendarWidget QToolButton:hover {{ background-color: rgba(255,165,0,0.20); }} /* Selected day color in the table view */ - QCalendarWidget QTableView:enabled { - selection-background-color: #FFA500; + QCalendarWidget QTableView:enabled {{ + selection-background-color: {highlight_css}; selection-color: black; - } + }} /* Optional: keep weekday header readable */ - QCalendarWidget QTableView QHeaderView::section { + QCalendarWidget QTableView QHeaderView::section {{ background: transparent; color: palette(windowText); - } - """) + }} + """ + ) else: # Back to app defaults in light/system self.calendar.setPalette(app_pal) diff --git a/bouquin/settings_dialog.py b/bouquin/settings_dialog.py index 48acfe6..ac36337 100644 --- a/bouquin/settings_dialog.py +++ b/bouquin/settings_dialog.py @@ -259,6 +259,7 @@ class SettingsDialog(QDialog): @Slot(bool) def _save_key_btn_clicked(self, checked: bool): + self.key = "" if checked: if not self.key: p1 = KeyPrompt( @@ -270,8 +271,6 @@ class SettingsDialog(QDialog): self.save_key_btn.blockSignals(False) return self.key = p1.key() or "" - else: - self.key = "" @Slot(bool) def _compact_btn_clicked(self): diff --git a/bouquin/theme.py b/bouquin/theme.py index 61f9458..341466e 100644 --- a/bouquin/theme.py +++ b/bouquin/theme.py @@ -10,6 +10,8 @@ class Theme(Enum): SYSTEM = "system" LIGHT = "light" DARK = "dark" + ORANGE_ANCHOR = "#FFA500" + ORANGE_ANCHOR_VISITED = "#B38000" @dataclass @@ -87,8 +89,8 @@ class ThemeManager(QObject): pal.setColor(QPalette.BrightText, QColor(255, 84, 84)) pal.setColor(QPalette.Highlight, focus) pal.setColor(QPalette.HighlightedText, QColor(0, 0, 0)) - pal.setColor(QPalette.Link, QColor("#FFA500")) - pal.setColor(QPalette.LinkVisited, QColor("#B38000")) + pal.setColor(QPalette.Link, QColor(Theme.ORANGE_ANCHOR.value)) + pal.setColor(QPalette.LinkVisited, QColor(Theme.ORANGE_ANCHOR_VISITED.value)) return pal diff --git a/tests/conftest.py b/tests/conftest.py index 1900f40..8d885e6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,6 +12,9 @@ os.environ.setdefault("QT_FILE_DIALOG_ALWAYS_USE_NATIVE", "0") # Make project importable +from PySide6.QtWidgets import QApplication, QWidget +from bouquin.theme import ThemeManager, ThemeConfig, Theme + PROJECT_ROOT = Path(__file__).resolve().parents[1] if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) @@ -59,7 +62,10 @@ def open_window(qtbot, temp_home, clean_settings): """Launch the app and immediately satisfy first-run/unlock key prompts.""" from bouquin.main_window import MainWindow - win = MainWindow() + app = QApplication.instance() + themes = ThemeManager(app, ThemeConfig()) + themes.apply(Theme.SYSTEM) + win = MainWindow(themes=themes) qtbot.addWidget(win) win.show() qtbot.waitExposed(win) @@ -75,3 +81,24 @@ def today_iso(): d = date.today() return f"{d.year:04d}-{d.month:02d}-{d.day:02d}" + + +@pytest.fixture +def theme_parent_widget(qtbot): + """A minimal parent that provides .themes.apply(...) like MainWindow.""" + + class _ThemesStub: + def __init__(self): + self.applied = [] + + def apply(self, theme): + self.applied.append(theme) + + class _Parent(QWidget): + def __init__(self): + super().__init__() + self.themes = _ThemesStub() + + parent = _Parent() + qtbot.addWidget(parent) + return parent diff --git a/tests/qt_helpers.py b/tests/qt_helpers.py index 1b9b9a3..f228177 100644 --- a/tests/qt_helpers.py +++ b/tests/qt_helpers.py @@ -166,7 +166,7 @@ class AutoResponder: continue wid = id(w) - # Handle first-run / unlock / save-name prompts (your existing branches) + # Handle first-run / unlock / save-name prompts if _looks_like_set_key_dialog(w) or _looks_like_unlock_dialog(w): fill_first_line_edit_and_accept(w, "ci-secret-key") self._seen.add(wid) diff --git a/tests/test_db_unit.py b/tests/test_db_unit.py new file mode 100644 index 0000000..d369abf --- /dev/null +++ b/tests/test_db_unit.py @@ -0,0 +1,137 @@ +import bouquin.db as dbmod +from bouquin.db import DBConfig, DBManager + + +class FakeCursor: + def __init__(self, rows=None): + self._rows = rows or [] + self.executed = [] + + def execute(self, sql, params=None): + self.executed.append((sql, tuple(params) if params else None)) + return self + + def fetchall(self): + return list(self._rows) + + def fetchone(self): + return self._rows[0] if self._rows else None + + +class FakeConn: + def __init__(self, rows=None): + self._rows = rows or [] + self.closed = False + self.cursors = [] + self.row_factory = None + + def cursor(self): + c = FakeCursor(rows=self._rows) + self.cursors.append(c) + return c + + def close(self): + self.closed = True + + def commit(self): + pass + + def __enter__(self): + return self + + def __exit__(self, *a): + pass + + +def test_integrity_ok_ok(monkeypatch, tmp_path): + mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x")) + mgr.conn = FakeConn(rows=[]) + assert mgr._integrity_ok() is None + + +def test_integrity_ok_raises(monkeypatch, tmp_path): + mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x")) + mgr.conn = FakeConn(rows=[("oops",), (None,)]) + try: + mgr._integrity_ok() + except Exception as e: + assert isinstance(e, dbmod.sqlite.IntegrityError) + + +def test_connect_closes_on_integrity_failure(monkeypatch, tmp_path): + # Use a non-empty key to avoid SQLCipher complaining before our patch runs + mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x")) + # Make the integrity check raise so connect() takes the failure path + monkeypatch.setattr( + DBManager, + "_integrity_ok", + lambda self: (_ for _ in ()).throw(RuntimeError("bad")), + ) + ok = mgr.connect() + assert ok is False + assert mgr.conn is None + + +def test_rekey_not_connected_raises(tmp_path): + mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="old")) + mgr.conn = None + import pytest + + with pytest.raises(RuntimeError): + mgr.rekey("new") + + +def test_rekey_reopen_failure(monkeypatch, tmp_path): + mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="old")) + mgr.conn = FakeConn(rows=[(None,)]) + monkeypatch.setattr(DBManager, "connect", lambda self: False) + import pytest + + with pytest.raises(Exception): + mgr.rekey("new") + + +def test_export_by_extension_and_unknown(tmp_path): + mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x")) + entries = [("2025-01-01", "Hi")] + # Test each exporter writes the file + p = tmp_path / "out.json" + mgr.export_json(entries, str(p)) + assert p.exists() and p.stat().st_size > 0 + p = tmp_path / "out.csv" + mgr.export_csv(entries, str(p)) + assert p.exists() + p = tmp_path / "out.txt" + mgr.export_txt(entries, str(p)) + assert p.exists() + p = tmp_path / "out.html" + mgr.export_html(entries, str(p)) + assert p.exists() + p = tmp_path / "out.md" + mgr.export_markdown(entries, str(p)) + assert p.exists() + # Router + import types + + mgr.get_all_entries = types.MethodType(lambda self: entries, mgr) + for ext in [".json", ".csv", ".txt", ".html"]: + path = tmp_path / f"route{ext}" + mgr.export_by_extension(str(path)) + assert path.exists() + import pytest + + with pytest.raises(ValueError): + mgr.export_by_extension(str(tmp_path / "x.zzz")) + + +def test_compact_error_prints(monkeypatch, tmp_path, capsys): + mgr = DBManager(DBConfig(tmp_path / "db.sqlite", key="x")) + + class BadConn: + def cursor(self): + raise RuntimeError("no") + + mgr.conn = BadConn() + mgr.compact() + out = capsys.readouterr().out + assert "Error:" in out diff --git a/tests/test_editor.py b/tests/test_editor.py index 6935143..f3a9859 100644 --- a/tests/test_editor.py +++ b/tests/test_editor.py @@ -1,12 +1,21 @@ -from PySide6.QtCore import Qt -from PySide6.QtGui import QImage, QTextCursor, QTextImageFormat +from PySide6.QtCore import Qt, QMimeData, QPoint, QUrl +from PySide6.QtGui import QImage, QMouseEvent, QKeyEvent, QTextCursor, QTextImageFormat from PySide6.QtTest import QTest +from PySide6.QtWidgets import QApplication from bouquin.editor import Editor +from bouquin.theme import ThemeManager, ThemeConfig, Theme import re +def _mk_editor() -> Editor: + # pytest-qt ensures a QApplication exists + app = QApplication.instance() + tm = ThemeManager(app, ThemeConfig()) + return Editor(tm) + + def _move_cursor_to_first_image(editor: Editor) -> QTextImageFormat | None: c = editor.textCursor() c.movePosition(QTextCursor.Start) @@ -31,7 +40,7 @@ def _fmt_at(editor: Editor, pos: int): def test_space_breaks_link_anchor_and_styling(qtbot): - e = Editor() + e = _mk_editor() e.resize(600, 300) e.show() qtbot.waitExposed(e) @@ -75,7 +84,7 @@ def test_space_breaks_link_anchor_and_styling(qtbot): def test_embed_qimage_saved_as_data_url(qtbot): - e = Editor() + e = _mk_editor() e.resize(600, 400) qtbot.addWidget(e) e.show() @@ -96,7 +105,7 @@ def test_insert_images_autoscale_and_fit(qtbot, tmp_path): big_path = tmp_path / "big.png" big.save(str(big_path)) - e = Editor() + e = _mk_editor() e.resize(420, 300) # known viewport width qtbot.addWidget(e) e.show() @@ -120,7 +129,7 @@ def test_insert_images_autoscale_and_fit(qtbot, tmp_path): def test_linkify_trims_trailing_punctuation(qtbot): - e = Editor() + e = _mk_editor() qtbot.addWidget(e) e.show() qtbot.waitExposed(e) @@ -135,31 +144,13 @@ def test_linkify_trims_trailing_punctuation(qtbot): assert 'href="https://example.com)."' not in html -def test_space_does_not_bleed_anchor_format(qtbot): - e = Editor() - qtbot.addWidget(e) - e.show() - qtbot.waitExposed(e) - - e.setPlainText("https://a.example") - qtbot.waitUntil(lambda: 'href="' in e.document().toHtml()) - - c = e.textCursor() - c.movePosition(QTextCursor.End) - e.setTextCursor(c) - - # Press Space; keyPressEvent should break the anchor for the next char - QTest.keyClick(e, Qt.Key_Space) - assert e.currentCharFormat().isAnchor() is False - - 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 = Editor() + e = _mk_editor() qtbot.addWidget(e) e.show() qtbot.waitExposed(e) @@ -185,3 +176,169 @@ def test_code_block_enter_exits_on_empty_line(qtbot): # Second Enter should jump *out* of the frame QTest.keyClick(e, Qt.Key_Return) # qtbot.waitUntil(lambda: e._find_code_frame(e.textCursor()) is None) + + +class DummyMenu: + def __init__(self): + self.seps = 0 + self.subs = [] + self.exec_called = False + + def addSeparator(self): + self.seps += 1 + + def addMenu(self, title): + m = DummyMenu() + self.subs.append((title, m)) + return m + + def addAction(self, *a, **k): + pass + + def exec(self, *a, **k): + self.exec_called = True + + +def _themes(): + app = QApplication.instance() + return ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + + +def test_context_menu_adds_image_actions(monkeypatch, qtbot): + e = Editor(_themes()) + qtbot.addWidget(e) + # Fake an image at cursor + qi = QImage(10, 10, QImage.Format_ARGB32) + qi.fill(0xFF00FF00) + imgfmt = QTextImageFormat() + imgfmt.setName("x") + imgfmt.setWidth(10) + imgfmt.setHeight(10) + tc = e.textCursor() + monkeypatch.setattr(e, "_image_info_at_cursor", lambda: (tc, imgfmt, qi)) + + dummy = DummyMenu() + monkeypatch.setattr(e, "createStandardContextMenu", lambda: dummy) + + class Evt: + def globalPos(self): + return QPoint(0, 0) + + e.contextMenuEvent(Evt()) + assert dummy.exec_called + assert dummy.seps == 1 + assert any(t == "Image size" for t, _ in dummy.subs) + + +def test_insert_from_mime_image_and_urls(tmp_path, qtbot): + e = Editor(_themes()) + qtbot.addWidget(e) + # Build a mime with an image + mime = QMimeData() + img = QImage(6, 6, QImage.Format_ARGB32) + img.fill(0xFF0000FF) + mime.setImageData(img) + e.insertFromMimeData(mime) + html = e.document().toHtml() + assert "a

", + }, + { + "id": 2, + "version_no": 2, + "created_at": "2025-01-02T10:00:00Z", + "note": None, + "is_current": True, + "content": "

b

", + }, + ] + + def get_version(self, version_id): + if version_id == 2: + return {"content": "

b

"} + return {"content": "

a

"} + + def revert_to_version(self, date, version_id=None, version_no=None): + if self.fail_revert: + raise RuntimeError("boom") + + +def test_on_select_no_item(qtbot): + dlg = HistoryDialog(FakeDB(), "2025-01-01") + qtbot.addWidget(dlg) + dlg.list.clear() + dlg._on_select() + + +def test_revert_failure_shows_critical(qtbot, monkeypatch): + from PySide6.QtWidgets import QMessageBox + + fake = FakeDB() + fake.fail_revert = True + dlg = HistoryDialog(fake, "2025-01-01") + qtbot.addWidget(dlg) + item = QListWidgetItem("v1") + item.setData(Qt.UserRole, 1) # different from current 2 + dlg.list.addItem(item) + dlg.list.setCurrentItem(item) + msgs = {} + + def fake_crit(parent, title, text): + msgs["t"] = (title, text) + + monkeypatch.setattr(QMessageBox, "critical", staticmethod(fake_crit)) + dlg._revert() + assert "Revert failed" in msgs["t"][0] diff --git a/tests/test_misc.py b/tests/test_misc.py new file mode 100644 index 0000000..20a3b1c --- /dev/null +++ b/tests/test_misc.py @@ -0,0 +1,113 @@ +from PySide6.QtWidgets import QApplication, QMessageBox +from bouquin.main_window import MainWindow +from bouquin.theme import ThemeManager, ThemeConfig, Theme +from bouquin.db import DBConfig + + +def _themes_light(): + app = QApplication.instance() + return ThemeManager(app, ThemeConfig(theme=Theme.LIGHT)) + + +def _themes_dark(): + app = QApplication.instance() + return ThemeManager(app, ThemeConfig(theme=Theme.DARK)) + + +class FakeDBErr: + def __init__(self, cfg): + pass + + def connect(self): + raise Exception("file is not a database") + + +class FakeDBOk: + def __init__(self, cfg): + pass + + def connect(self): + return True + + def save_new_version(self, date, text, note): + raise RuntimeError("nope") + + def get_entry(self, date): + return "

hi

" + + def get_entries_days(self): + return [] + + +def test_try_connect_sqlcipher_error(monkeypatch, qtbot, tmp_path): + # Config with a key so __init__ calls _try_connect immediately + cfg = DBConfig(tmp_path / "db.sqlite", key="x") + (tmp_path / "db.sqlite").write_text("", encoding="utf-8") + monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg) + monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBErr) + msgs = {} + monkeypatch.setattr( + QMessageBox, "critical", staticmethod(lambda p, t, m: msgs.setdefault("m", m)) + ) + w = MainWindow(_themes_light()) # auto-calls _try_connect + qtbot.addWidget(w) + assert "incorrect" in msgs.get("m", "").lower() + + +def test_apply_link_css_dark(qtbot, monkeypatch, tmp_path): + cfg = DBConfig(tmp_path / "db.sqlite", key="x") + (tmp_path / "db.sqlite").write_text("", encoding="utf-8") + monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg) + monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBOk) + w = MainWindow(_themes_dark()) + qtbot.addWidget(w) + w._apply_link_css() + css = w.editor.document().defaultStyleSheet() + assert "a {" in css + + +def test_restore_window_position_first_run(qtbot, monkeypatch, tmp_path): + cfg = DBConfig(tmp_path / "db.sqlite", key="x") + (tmp_path / "db.sqlite").write_text("", encoding="utf-8") + monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg) + monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBOk) + w = MainWindow(_themes_light()) + qtbot.addWidget(w) + called = {} + + class FakeSettings: + def value(self, key, default=None, type=None): + if key == "main/geometry": + return None + if key == "main/windowState": + return None + if key == "main/maximized": + return False + return default + + w.settings = FakeSettings() + monkeypatch.setattr( + w, "_move_to_cursor_screen_center", lambda: called.setdefault("x", True) + ) + w._restore_window_position() + assert called.get("x") is True + + +def test_on_insert_image_calls_editor(qtbot, monkeypatch, tmp_path): + cfg = DBConfig(tmp_path / "db.sqlite", key="x") + (tmp_path / "db.sqlite").write_text("", encoding="utf-8") + monkeypatch.setattr("bouquin.main_window.load_db_config", lambda: cfg) + monkeypatch.setattr("bouquin.main_window.DBManager", FakeDBOk) + w = MainWindow(_themes_light()) + qtbot.addWidget(w) + captured = {} + monkeypatch.setattr( + w.editor, "insert_images", lambda paths: captured.setdefault("p", paths) + ) + # Simulate file dialog returning paths + monkeypatch.setattr( + "bouquin.main_window.QFileDialog.getOpenFileNames", + staticmethod(lambda *a, **k: (["/tmp/a.png", "/tmp/b.jpg"], "Images")), + ) + w._on_insert_image() + assert captured.get("p") == ["/tmp/a.png", "/tmp/b.jpg"] diff --git a/tests/test_search_unit.py b/tests/test_search_unit.py new file mode 100644 index 0000000..13c1ef9 --- /dev/null +++ b/tests/test_search_unit.py @@ -0,0 +1,57 @@ +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QListWidgetItem + +# The widget class is named `Search` in bouquin.search +from bouquin.search import Search as SearchWidget + + +class FakeDB: + def __init__(self, rows): + self.rows = rows + + def search_entries(self, q): + return list(self.rows) + + +def test_search_empty_clears_and_hides(qtbot): + w = SearchWidget(db=FakeDB([])) + qtbot.addWidget(w) + w.show() + qtbot.waitExposed(w) + dates = [] + w.resultDatesChanged.connect(lambda ds: dates.extend(ds)) + w._search(" ") + assert w.results.isHidden() + assert dates == [] + + +def test_populate_empty_hides(qtbot): + w = SearchWidget(db=FakeDB([])) + qtbot.addWidget(w) + w._populate_results("x", []) + assert w.results.isHidden() + + +def test_open_selected_emits_when_present(qtbot): + w = SearchWidget(db=FakeDB([])) + qtbot.addWidget(w) + got = {} + w.openDateRequested.connect(lambda d: got.setdefault("d", d)) + it = QListWidgetItem("x") + it.setData(Qt.ItemDataRole.UserRole, "") + w._open_selected(it) + assert "d" not in got + it.setData(Qt.ItemDataRole.UserRole, "2025-01-02") + w._open_selected(it) + assert got["d"] == "2025-01-02" + + +def test_make_html_snippet_edge_cases(qtbot): + w = SearchWidget(db=FakeDB([])) + qtbot.addWidget(w) + # Empty HTML -> empty fragment, no ellipses + frag, l, r = w._make_html_snippet("", "hello") + assert frag == "" and not l and not r + # Small doc around token -> should not show ellipses + frag, l, r = w._make_html_snippet("

Hello world

", "world") + assert "world" in frag or "world" in frag diff --git a/tests/test_settings_dialog.py b/tests/test_settings_dialog.py index f300c6f..906ec2c 100644 --- a/tests/test_settings_dialog.py +++ b/tests/test_settings_dialog.py @@ -1,9 +1,24 @@ from pathlib import Path -from PySide6.QtWidgets import QDialog, QFileDialog, QMessageBox +from PySide6.QtWidgets import QDialog, QFileDialog, QMessageBox, QWidget from bouquin.db import DBConfig from bouquin.settings_dialog import SettingsDialog +from bouquin.theme import Theme + + +class _ThemeSpy: + def __init__(self): + self.calls = [] + + def apply(self, t): + self.calls.append(t) + + +class _Parent(QWidget): + def __init__(self): + super().__init__() + self.themes = _ThemeSpy() class FakeDB: @@ -58,7 +73,22 @@ def test_save_persists_all_fields(monkeypatch, qtbot, tmp_path): p = AcceptingPrompt().set_key("sekrit") monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: p) - dlg = SettingsDialog(cfg, db) + # Provide a lightweight parent that mimics MainWindow’s `themes` API + class _ThemeSpy: + def __init__(self): + self.calls = [] + + def apply(self, theme): + self.calls.append(theme) + + class _Parent(QWidget): + def __init__(self): + super().__init__() + self.themes = _ThemeSpy() + + parent = _Parent() + qtbot.addWidget(parent) + dlg = SettingsDialog(cfg, db, parent=parent) qtbot.addWidget(dlg) dlg.show() qtbot.waitExposed(dlg) @@ -77,6 +107,7 @@ def test_save_persists_all_fields(monkeypatch, qtbot, tmp_path): assert out.path == new_path assert out.idle_minutes == 0 assert out.key == "sekrit" + assert parent.themes.calls and parent.themes.calls[-1] == Theme.SYSTEM def test_save_key_checkbox_requires_key_and_reverts_if_cancelled(monkeypatch, qtbot): @@ -250,3 +281,16 @@ def test_save_key_checkbox_preexisting_key_does_not_crash(monkeypatch, qtbot): dlg.save_key_btn.setChecked(True) # We should reach here with the original key preserved. assert dlg.key == "already" + + +def test_save_unchecked_clears_key_and_applies_theme(qtbot, tmp_path): + parent = _Parent() + qtbot.addWidget(parent) + cfg = DBConfig(tmp_path / "db.sqlite", key="sekrit", idle_minutes=5) + dlg = SettingsDialog(cfg, FakeDB(), parent=parent) + qtbot.addWidget(dlg) + dlg.save_key_btn.setChecked(False) + # Trigger save + dlg._save() + assert dlg.config.key == "" # cleared + assert parent.themes.calls # applied some theme diff --git a/tests/test_settings_module.py b/tests/test_settings_module.py new file mode 100644 index 0000000..24a9aac --- /dev/null +++ b/tests/test_settings_module.py @@ -0,0 +1,28 @@ +from bouquin.db import DBConfig +import bouquin.settings as settings + + +class FakeSettings: + def __init__(self): + self.store = {} + + def value(self, key, default=None, type=None): + return self.store.get(key, default) + + def setValue(self, key, value): + self.store[key] = value + + +def test_save_and_load_db_config_roundtrip(monkeypatch, tmp_path): + fake = FakeSettings() + monkeypatch.setattr(settings, "get_settings", lambda: fake) + + cfg = DBConfig(path=tmp_path / "db.sqlite", key="k", idle_minutes=7, theme="dark") + settings.save_db_config(cfg) + + # Now read back into a new DBConfig + cfg2 = settings.load_db_config() + assert cfg2.path == cfg.path + assert cfg2.key == "k" + assert cfg2.idle_minutes == "7" + assert cfg2.theme == "dark" diff --git a/tests/test_theme_integration.py b/tests/test_theme_integration.py new file mode 100644 index 0000000..f1949c3 --- /dev/null +++ b/tests/test_theme_integration.py @@ -0,0 +1,19 @@ +from bouquin.theme import Theme + + +def test_apply_link_css_dark_theme(open_window, qtbot): + win = open_window + # Switch to dark and apply link CSS + win.themes.set(Theme.DARK) + win._apply_link_css() + css = win.editor.document().defaultStyleSheet() + assert "#FFA500" in css and "a:visited" in css + + +def test_apply_link_css_light_theme(open_window, qtbot): + win = open_window + # Switch to light and apply link CSS + win.themes.set(Theme.LIGHT) + win._apply_link_css() + css = win.editor.document().defaultStyleSheet() + assert css == "" or "a {" not in css diff --git a/tests/test_theme_manager.py b/tests/test_theme_manager.py new file mode 100644 index 0000000..39121ea --- /dev/null +++ b/tests/test_theme_manager.py @@ -0,0 +1,19 @@ +from PySide6.QtWidgets import QApplication +from PySide6.QtGui import QPalette, QColor + +from bouquin.theme import ThemeManager, ThemeConfig, Theme + + +def test_theme_manager_applies_palettes(qtbot): + app = QApplication.instance() + tm = ThemeManager(app, ThemeConfig()) + + # Light palette should set Link to the light blue + tm.apply(Theme.LIGHT) + pal = app.palette() + assert pal.color(QPalette.Link) == QColor("#1a73e8") + + # Dark palette should set Link to lavender-ish + tm.apply(Theme.DARK) + pal = app.palette() + assert pal.color(QPalette.Link) == QColor("#FFA500") diff --git a/tests/test_toolbar_private.py b/tests/test_toolbar_private.py new file mode 100644 index 0000000..834f4c2 --- /dev/null +++ b/tests/test_toolbar_private.py @@ -0,0 +1,23 @@ +from bouquin.toolbar import ToolBar + + +def test_style_letter_button_handles_missing_widget(qtbot): + tb = ToolBar() + qtbot.addWidget(tb) + # Create a dummy action detached from toolbar to force widgetForAction->None + from PySide6.QtGui import QAction + + act = QAction("X", tb) + # No crash and early return + tb._style_letter_button(act, "X") + + +def test_style_letter_button_sets_tooltip_and_accessible(qtbot): + tb = ToolBar() + qtbot.addWidget(tb) + # Use an existing action so widgetForAction returns a button + act = tb.actBold + tb._style_letter_button(act, "B", bold=True, tooltip="Bold") + btn = tb.widgetForAction(act) + assert btn.toolTip() == "Bold" + assert btn.accessibleName() == "Bold"