Various tweaks to theme, more code coverage
This commit is contained in:
parent
c3b83b0238
commit
7c3ec19748
17 changed files with 812 additions and 49 deletions
|
|
@ -1 +0,0 @@
|
|||
from .main import main
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
137
tests/test_db_unit.py
Normal file
137
tests/test_db_unit.py
Normal file
|
|
@ -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", "<b>Hi</b>")]
|
||||
# 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
|
||||
|
|
@ -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 "<img" in html
|
||||
|
||||
# Now with urls: local non-image + local image + remote url
|
||||
png = tmp_path / "t.png"
|
||||
img.save(str(png))
|
||||
txt = tmp_path / "x.txt"
|
||||
txt.write_text("hi", encoding="utf-8")
|
||||
mime2 = QMimeData()
|
||||
mime2.setUrls(
|
||||
[
|
||||
QUrl.fromLocalFile(str(txt)),
|
||||
QUrl.fromLocalFile(str(png)),
|
||||
QUrl("https://example.com/file"),
|
||||
]
|
||||
)
|
||||
e.insertFromMimeData(mime2)
|
||||
h2 = e.document().toHtml()
|
||||
assert 'href="file://' in h2 # local file link inserted
|
||||
assert "<img" in h2 # image inserted
|
||||
assert 'href="https://example.com/file"' in h2 # remote url link
|
||||
|
||||
|
||||
def test_mouse_release_ctrl_click_opens(monkeypatch, qtbot):
|
||||
e = Editor(_themes())
|
||||
qtbot.addWidget(e)
|
||||
# Anchor under cursor
|
||||
monkeypatch.setattr(e, "anchorAt", lambda p: "https://example.com")
|
||||
opened = {}
|
||||
from PySide6.QtGui import QDesktopServices as DS
|
||||
|
||||
monkeypatch.setattr(
|
||||
DS, "openUrl", lambda url: opened.setdefault("u", url.toString())
|
||||
)
|
||||
ev = QMouseEvent(
|
||||
QMouseEvent.MouseButtonRelease,
|
||||
QPoint(1, 1),
|
||||
Qt.LeftButton,
|
||||
Qt.LeftButton,
|
||||
Qt.ControlModifier,
|
||||
)
|
||||
e.mouseReleaseEvent(ev)
|
||||
assert opened.get("u") == "https://example.com"
|
||||
|
||||
|
||||
def test_keypress_space_breaks_anchor(monkeypatch, qtbot):
|
||||
e = Editor(_themes())
|
||||
qtbot.addWidget(e)
|
||||
called = {}
|
||||
monkeypatch.setattr(
|
||||
e, "_break_anchor_for_next_char", lambda: called.setdefault("x", True)
|
||||
)
|
||||
ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Space, Qt.NoModifier, " ")
|
||||
e.keyPressEvent(ev)
|
||||
assert called.get("x") is True
|
||||
|
||||
|
||||
def test_enter_leaves_code_frame(qtbot):
|
||||
e = Editor(_themes())
|
||||
qtbot.addWidget(e)
|
||||
e.setPlainText("")
|
||||
# Insert a code block frame
|
||||
e.apply_code()
|
||||
# Place cursor inside the empty code block
|
||||
c = e.textCursor()
|
||||
c.movePosition(QTextCursor.End)
|
||||
e.setTextCursor(c)
|
||||
# Press Enter; should jump outside the frame and start normal paragraph
|
||||
ev = QKeyEvent(QKeyEvent.KeyPress, Qt.Key_Return, Qt.NoModifier)
|
||||
e.keyPressEvent(ev)
|
||||
# After enter, the cursor should not be inside a code frame
|
||||
assert e._find_code_frame(e.textCursor()) is None
|
||||
|
||||
|
||||
def test_space_does_not_bleed_anchor_format(qtbot):
|
||||
e = _mk_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_editor_small_helpers(qtbot):
|
||||
app = QApplication.instance()
|
||||
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||
e = Editor(themes)
|
||||
qtbot.addWidget(e)
|
||||
# _approx returns True when |a-b| <= eps
|
||||
assert e._approx(1.0, 1.25, eps=0.3) is True
|
||||
assert e._approx(1.0, 1.6, eps=0.3) is False
|
||||
# Exercise helpers
|
||||
_ = e._is_heading_typing()
|
||||
e._apply_normal_typing()
|
||||
|
|
|
|||
69
tests/test_entrypoints.py
Normal file
69
tests/test_entrypoints.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import importlib
|
||||
|
||||
|
||||
def test___main___exports_main():
|
||||
entry_mod = importlib.import_module("bouquin.__main__")
|
||||
main_mod = importlib.import_module("bouquin.main")
|
||||
assert entry_mod.main is main_mod.main
|
||||
|
||||
|
||||
def test_main_entry_initializes_qt(monkeypatch):
|
||||
main_mod = importlib.import_module("bouquin.main")
|
||||
|
||||
# Fakes to avoid real Qt event loop
|
||||
class FakeApp:
|
||||
def __init__(self, argv):
|
||||
self.argv = argv
|
||||
self.name = None
|
||||
self.org = None
|
||||
|
||||
def setApplicationName(self, n):
|
||||
self.name = n
|
||||
|
||||
def setOrganizationName(self, n):
|
||||
self.org = n
|
||||
|
||||
def exec(self):
|
||||
return 0
|
||||
|
||||
class FakeWin:
|
||||
def __init__(self, themes=None):
|
||||
self.themes = themes
|
||||
self.shown = False
|
||||
|
||||
def show(self):
|
||||
self.shown = True
|
||||
|
||||
class FakeThemes:
|
||||
def __init__(self, app, cfg):
|
||||
self._applied = None
|
||||
self.app = app
|
||||
self.cfg = cfg
|
||||
|
||||
def apply(self, t):
|
||||
self._applied = t
|
||||
|
||||
class FakeSettings:
|
||||
def __init__(self):
|
||||
self._map = {"ui/theme": "dark"}
|
||||
|
||||
def value(self, k, default=None, type=None):
|
||||
return self._map.get(k, default)
|
||||
|
||||
def fake_get_settings():
|
||||
return FakeSettings()
|
||||
|
||||
monkeypatch.setattr(main_mod, "QApplication", FakeApp)
|
||||
monkeypatch.setattr(main_mod, "MainWindow", FakeWin)
|
||||
monkeypatch.setattr(main_mod, "ThemeManager", FakeThemes)
|
||||
monkeypatch.setattr(main_mod, "get_settings", fake_get_settings)
|
||||
|
||||
exits = {}
|
||||
|
||||
def fake_exit(code):
|
||||
exits["code"] = code
|
||||
|
||||
monkeypatch.setattr(main_mod.sys, "exit", fake_exit)
|
||||
|
||||
main_mod.main()
|
||||
assert exits.get("code", None) == 0
|
||||
66
tests/test_history_dialog_unit.py
Normal file
66
tests/test_history_dialog_unit.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
from PySide6.QtWidgets import QListWidgetItem
|
||||
from PySide6.QtCore import Qt
|
||||
from bouquin.history_dialog import HistoryDialog
|
||||
|
||||
|
||||
class FakeDB:
|
||||
def __init__(self):
|
||||
self.fail_revert = False
|
||||
|
||||
def list_versions(self, date_iso):
|
||||
# Simulate two versions; mark second as current
|
||||
return [
|
||||
{
|
||||
"id": 1,
|
||||
"version_no": 1,
|
||||
"created_at": "2025-01-01T10:00:00Z",
|
||||
"note": None,
|
||||
"is_current": False,
|
||||
"content": "<p>a</p>",
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"version_no": 2,
|
||||
"created_at": "2025-01-02T10:00:00Z",
|
||||
"note": None,
|
||||
"is_current": True,
|
||||
"content": "<p>b</p>",
|
||||
},
|
||||
]
|
||||
|
||||
def get_version(self, version_id):
|
||||
if version_id == 2:
|
||||
return {"content": "<p>b</p>"}
|
||||
return {"content": "<p>a</p>"}
|
||||
|
||||
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]
|
||||
113
tests/test_misc.py
Normal file
113
tests/test_misc.py
Normal file
|
|
@ -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 "<p>hi</p>"
|
||||
|
||||
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"]
|
||||
57
tests/test_search_unit.py
Normal file
57
tests/test_search_unit.py
Normal file
|
|
@ -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("<p>Hello world</p>", "world")
|
||||
assert "<b>world</b>" in frag or "world" in frag
|
||||
|
|
@ -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
|
||||
|
|
|
|||
28
tests/test_settings_module.py
Normal file
28
tests/test_settings_module.py
Normal file
|
|
@ -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"
|
||||
19
tests/test_theme_integration.py
Normal file
19
tests/test_theme_integration.py
Normal file
|
|
@ -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
|
||||
19
tests/test_theme_manager.py
Normal file
19
tests/test_theme_manager.py
Normal file
|
|
@ -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")
|
||||
23
tests/test_toolbar_private.py
Normal file
23
tests/test_toolbar_private.py
Normal file
|
|
@ -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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue