Code cleanup/comments, more test coverage (92%)
This commit is contained in:
parent
66950eeff5
commit
74177f2cd3
19 changed files with 463 additions and 22 deletions
|
|
@ -296,7 +296,6 @@ class DBManager:
|
|||
) -> None:
|
||||
"""
|
||||
Point the page head (pages.current_version_id) to an existing version.
|
||||
Fast revert: no content is rewritten.
|
||||
"""
|
||||
if self.conn is None:
|
||||
raise RuntimeError("Database is not connected")
|
||||
|
|
@ -356,6 +355,9 @@ class DBManager:
|
|||
json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
def export_csv(self, entries: Sequence[Entry], file_path: str) -> None:
|
||||
"""
|
||||
Export pages to CSV.
|
||||
"""
|
||||
# utf-8-sig adds a BOM so Excel opens as UTF-8 by default.
|
||||
with open(file_path, "w", encoding="utf-8-sig", newline="") as f:
|
||||
writer = csv.writer(f)
|
||||
|
|
@ -369,6 +371,10 @@ class DBManager:
|
|||
separator: str = "\n\n— — — — —\n\n",
|
||||
strip_html: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Strip the HTML from the latest version of the pages
|
||||
and save to a text file.
|
||||
"""
|
||||
import re, html as _html
|
||||
|
||||
# Precompiled patterns
|
||||
|
|
@ -407,6 +413,9 @@ class DBManager:
|
|||
def export_html(
|
||||
self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export"
|
||||
) -> None:
|
||||
"""
|
||||
Export to HTML with a heading.
|
||||
"""
|
||||
parts = [
|
||||
"<!doctype html>",
|
||||
'<html lang="en">',
|
||||
|
|
@ -429,6 +438,10 @@ class DBManager:
|
|||
def export_markdown(
|
||||
self, entries: Sequence[Entry], file_path: str, title: str = "Bouquin export"
|
||||
) -> None:
|
||||
"""
|
||||
Export to HTML, similar to export_html, but then convert to Markdown
|
||||
using markdownify, and finally save to file.
|
||||
"""
|
||||
parts = [
|
||||
"<!doctype html>",
|
||||
'<html lang="en">',
|
||||
|
|
@ -469,6 +482,10 @@ class DBManager:
|
|||
cur.execute("DETACH DATABASE backup")
|
||||
|
||||
def export_by_extension(self, file_path: str) -> None:
|
||||
"""
|
||||
Fallback catch-all that runs one of the above functions based on
|
||||
the extension of the file name that was chosen by the user.
|
||||
"""
|
||||
entries = self.get_all_entries()
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ class HistoryDialog(QDialog):
|
|||
# Diff vs current (textual diff)
|
||||
cur = self._db.get_version(version_id=self._current_id)
|
||||
self.diff.setHtml(_colored_unified_diff_html(cur["content"], sel["content"]))
|
||||
# Enable revert only if selecting a non-current
|
||||
# Enable revert only if selecting a non-current version
|
||||
self.btn_revert.setEnabled(sel_id != self._current_id)
|
||||
|
||||
@Slot()
|
||||
|
|
@ -167,7 +167,7 @@ class HistoryDialog(QDialog):
|
|||
sel_id = item.data(Qt.UserRole)
|
||||
if sel_id == self._current_id:
|
||||
return
|
||||
# Flip head pointer
|
||||
# Flip head pointer to the older version
|
||||
try:
|
||||
self._db.revert_to_version(self._date, version_id=sel_id)
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -17,6 +17,12 @@ class KeyPrompt(QDialog):
|
|||
title: str = "Enter key",
|
||||
message: str = "Enter key",
|
||||
):
|
||||
"""
|
||||
Prompt the user for the key required to decrypt the database.
|
||||
|
||||
Used when opening the app, unlocking the idle locked screen,
|
||||
or when rekeying.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(title)
|
||||
v = QVBoxLayout(self)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton
|
|||
|
||||
class LockOverlay(QWidget):
|
||||
def __init__(self, parent: QWidget, on_unlock: callable):
|
||||
"""
|
||||
Widget that 'locks' the screen after a configured idle time.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.setObjectName("LockOverlay")
|
||||
self.setAttribute(Qt.WA_StyledBackground, True)
|
||||
|
|
@ -39,6 +42,9 @@ class LockOverlay(QWidget):
|
|||
self.hide()
|
||||
|
||||
def _is_dark(self, pal: QPalette) -> bool:
|
||||
"""
|
||||
Detect if dark mode is in use.
|
||||
"""
|
||||
c = pal.color(QPalette.Window)
|
||||
luma = 0.2126 * c.redF() + 0.7152 * c.greenF() + 0.0722 * c.blueF()
|
||||
return luma < 0.5
|
||||
|
|
@ -58,7 +64,7 @@ class LockOverlay(QWidget):
|
|||
|
||||
self.setStyleSheet(
|
||||
f"""
|
||||
#LockOverlay {{ background-color: rgb(0,0,0); }} /* opaque, no transparency */
|
||||
#LockOverlay {{ background-color: rgb(0,0,0); }}
|
||||
#LockOverlay QLabel#lockLabel {{ color: {accent_hex}; font-weight: 600; }}
|
||||
|
||||
#LockOverlay QPushButton#unlockButton {{
|
||||
|
|
@ -113,7 +119,7 @@ class LockOverlay(QWidget):
|
|||
|
||||
def changeEvent(self, ev):
|
||||
super().changeEvent(ev)
|
||||
# Only re-style on palette flips
|
||||
# Only re-style on palette flips (user changed theme)
|
||||
if ev.type() in (QEvent.PaletteChange, QEvent.ApplicationPaletteChange):
|
||||
self._apply_overlay_style()
|
||||
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ class MainWindow(QMainWindow):
|
|||
split = QSplitter()
|
||||
split.addWidget(left_panel)
|
||||
split.addWidget(self.editor)
|
||||
split.setStretchFactor(1, 1) # editor grows
|
||||
split.setStretchFactor(1, 1)
|
||||
|
||||
container = QWidget()
|
||||
lay = QVBoxLayout(container)
|
||||
|
|
@ -281,7 +281,7 @@ class MainWindow(QMainWindow):
|
|||
if hasattr(self, "_lock_overlay"):
|
||||
self._lock_overlay._apply_overlay_style()
|
||||
self._apply_calendar_text_colors()
|
||||
self._apply_link_css() # Reapply link styles based on the current theme
|
||||
self._apply_link_css()
|
||||
self._apply_search_highlights(getattr(self, "_search_highlighted_dates", set()))
|
||||
self.calendar.update()
|
||||
self.editor.viewport().update()
|
||||
|
|
@ -298,7 +298,6 @@ class MainWindow(QMainWindow):
|
|||
css = "" # Default to no custom styling for links (system or light theme)
|
||||
|
||||
try:
|
||||
# Apply to the editor (QTextEdit or any other relevant widgets)
|
||||
self.editor.document().setDefaultStyleSheet(css)
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -347,7 +346,6 @@ class MainWindow(QMainWindow):
|
|||
self.calendar.setPalette(app_pal)
|
||||
self.calendar.setStyleSheet("")
|
||||
|
||||
# Keep weekend text color in sync with the current palette
|
||||
self._apply_calendar_text_colors()
|
||||
self.calendar.update()
|
||||
|
||||
|
|
@ -855,6 +853,9 @@ If you want an encrypted backup, choose Backup instead of Export.
|
|||
return super().eventFilter(obj, event)
|
||||
|
||||
def _enter_lock(self):
|
||||
"""
|
||||
Trigger the lock overlay and disable widgets
|
||||
"""
|
||||
if self._locked:
|
||||
return
|
||||
self._locked = True
|
||||
|
|
@ -870,6 +871,10 @@ If you want an encrypted backup, choose Backup instead of Export.
|
|||
|
||||
@Slot()
|
||||
def _on_unlock_clicked(self):
|
||||
"""
|
||||
Prompt for key to unlock screen
|
||||
If successful, re-enable widgets
|
||||
"""
|
||||
try:
|
||||
ok = self._prompt_for_key_until_valid(first_time=False)
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ class SaveDialog(QDialog):
|
|||
title: str = "Enter a name for this version",
|
||||
message: str = "Enter a name for this version?",
|
||||
):
|
||||
"""
|
||||
Used for explicitly saving a new version of a page.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(title)
|
||||
v = QVBoxLayout(self)
|
||||
|
|
|
|||
|
|
@ -70,7 +70,6 @@ class Search(QWidget):
|
|||
try:
|
||||
rows: Iterable[Row] = self._db.search_entries(q)
|
||||
except Exception:
|
||||
# be quiet on DB errors here; caller can surface if desired
|
||||
rows = []
|
||||
|
||||
self._populate_results(q, rows)
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ class ThemeManager(QObject):
|
|||
scheme = getattr(hints, "colorScheme", None)
|
||||
if callable(scheme):
|
||||
scheme = hints.colorScheme()
|
||||
# 0=Light, 1=Dark in newer Qt; fall back to Light
|
||||
# 0=Light, 1=Dark; fall back to Light
|
||||
theme = Theme.DARK if scheme == 1 else Theme.LIGHT
|
||||
|
||||
# Always use Fusion so palette applies consistently cross-platform
|
||||
|
|
@ -58,7 +58,6 @@ class ThemeManager(QObject):
|
|||
if theme == Theme.DARK:
|
||||
pal = self._dark_palette()
|
||||
self._app.setPalette(pal)
|
||||
# keep stylesheet empty unless you need widget-specific tweaks
|
||||
self._app.setStyleSheet("")
|
||||
else:
|
||||
pal = self._light_palette()
|
||||
|
|
|
|||
|
|
@ -140,6 +140,11 @@ class ToolBar(QToolBar):
|
|||
for a in (self.actAlignL, self.actAlignC, self.actAlignR):
|
||||
a.setActionGroup(self.grpAlign)
|
||||
|
||||
self.grpLists = QActionGroup(self)
|
||||
self.grpLists.setExclusive(True)
|
||||
for a in (self.actBullets, self.actNumbers, self.actCheckboxes):
|
||||
a.setActionGroup(self.grpLists)
|
||||
|
||||
# Add actions
|
||||
self.addActions(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -102,3 +102,32 @@ def theme_parent_widget(qtbot):
|
|||
parent = _Parent()
|
||||
qtbot.addWidget(parent)
|
||||
return parent
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def qapp():
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
yield app
|
||||
# do not quit; pytest might still need it
|
||||
# app.quit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_db_path(tmp_path):
|
||||
return tmp_path / "notebook.db"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cfg(temp_db_path):
|
||||
# Use the real DBConfig from the app (SQLCipher-backed)
|
||||
from bouquin.db import DBConfig
|
||||
|
||||
return DBConfig(
|
||||
path=Path(temp_db_path),
|
||||
key="testkey",
|
||||
idle_minutes=0,
|
||||
theme="system",
|
||||
move_todos=True,
|
||||
)
|
||||
|
|
|
|||
129
tests/test_db_migrations_and_versions.py
Normal file
129
tests/test_db_migrations_and_versions.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from bouquin.db import DBManager, DBConfig
|
||||
|
||||
# Use the same sqlite driver as the app (sqlcipher3) to prepare pre-0.1.5 "entries" DBs
|
||||
from sqlcipher3 import dbapi2 as sqlite
|
||||
|
||||
|
||||
def connect_raw_sqlcipher(db_path: Path, key: str):
|
||||
conn = sqlite.connect(str(db_path))
|
||||
conn.row_factory = sqlite.Row
|
||||
cur = conn.cursor()
|
||||
cur.execute(f"PRAGMA key = '{key}';")
|
||||
cur.execute("PRAGMA foreign_keys = ON;")
|
||||
cur.execute("PRAGMA journal_mode = WAL;").fetchone()
|
||||
return conn
|
||||
|
||||
|
||||
def test_migration_from_legacy_entries_table(cfg: DBConfig, tmp_path: Path):
|
||||
# Prepare a "legacy" DB that has only entries(date, content) and no pages/versions
|
||||
db_path = cfg.path
|
||||
conn = connect_raw_sqlcipher(db_path, cfg.key)
|
||||
cur = conn.cursor()
|
||||
cur.execute("CREATE TABLE entries(date TEXT PRIMARY KEY, content TEXT NOT NULL);")
|
||||
cur.execute(
|
||||
"INSERT INTO entries(date, content) VALUES(?, ?);",
|
||||
("2025-01-02", "<p>Hello</p>"),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Now use the real DBManager, which will run _ensure_schema and migrate
|
||||
mgr = DBManager(cfg)
|
||||
assert mgr.connect() is True
|
||||
|
||||
# After migration, legacy table should be gone and content reachable via get_entry
|
||||
text = mgr.get_entry("2025-01-02")
|
||||
assert "Hello" in text
|
||||
|
||||
cur = mgr.conn.cursor()
|
||||
# entries table should be dropped
|
||||
with pytest.raises(sqlite.OperationalError):
|
||||
cur.execute("SELECT count(*) FROM entries;").fetchone()
|
||||
|
||||
# pages & versions exist and head points to v1
|
||||
rows = cur.execute(
|
||||
"SELECT current_version_id FROM pages WHERE date='2025-01-02'"
|
||||
).fetchone()
|
||||
assert rows is not None and rows["current_version_id"] is not None
|
||||
vers = mgr.list_versions("2025-01-02")
|
||||
assert vers and vers[0]["version_no"] == 1 and vers[0]["is_current"] == 1
|
||||
|
||||
|
||||
def test_save_new_version_requires_connection_raises(cfg: DBConfig):
|
||||
mgr = DBManager(cfg)
|
||||
with pytest.raises(RuntimeError):
|
||||
mgr.save_new_version("2025-01-03", "<p>x</p>")
|
||||
|
||||
|
||||
def _bootstrap_db(cfg: DBConfig) -> DBManager:
|
||||
mgr = DBManager(cfg)
|
||||
assert mgr.connect() is True
|
||||
return mgr
|
||||
|
||||
|
||||
def test_revert_to_version_by_number_and_id_and_errors(cfg: DBConfig):
|
||||
mgr = _bootstrap_db(cfg)
|
||||
# Create two versions for the same date
|
||||
ver1_id, ver1_no = mgr.save_new_version("2025-01-04", "<p>v1</p>", note="init")
|
||||
ver2_id, ver2_no = mgr.save_new_version("2025-01-04", "<p>v2</p>", note="edit")
|
||||
assert ver1_no == 1 and ver2_no == 2
|
||||
|
||||
# Revert using version_no (exercises branch where version_id is None)
|
||||
mgr.revert_to_version(date_iso="2025-01-04", version_no=1, version_id=None)
|
||||
cur = mgr.conn.cursor()
|
||||
head = cur.execute(
|
||||
"SELECT current_version_id FROM pages WHERE date=?", ("2025-01-04",)
|
||||
).fetchone()[0]
|
||||
assert head == ver1_id
|
||||
|
||||
# Revert using version_id directly should also work
|
||||
mgr.revert_to_version(date_iso="2025-01-04", version_id=ver2_id)
|
||||
head2 = cur.execute(
|
||||
"SELECT current_version_id FROM pages WHERE date=?", ("2025-01-04",)
|
||||
).fetchone()[0]
|
||||
assert head2 == ver2_id
|
||||
|
||||
# Error: version not found for date (non-existent version_no)
|
||||
with pytest.raises(ValueError):
|
||||
mgr.revert_to_version(date_iso="2025-01-04", version_no=99)
|
||||
|
||||
# Error: version_id belongs to a different date
|
||||
other_id, _ = mgr.save_new_version("2025-01-05", "<p>other</p>")
|
||||
with pytest.raises(ValueError):
|
||||
mgr.revert_to_version(date_iso="2025-01-04", version_id=other_id)
|
||||
|
||||
|
||||
def test_export_by_extension_and_compact(cfg: DBConfig, tmp_path: Path):
|
||||
mgr = _bootstrap_db(cfg)
|
||||
# Seed a couple of entries
|
||||
mgr.save_new_version("2025-01-06", "<p>A</p>")
|
||||
mgr.save_new_version("2025-01-07", "<p>B</p>")
|
||||
|
||||
# Prepare output files
|
||||
out = tmp_path
|
||||
exts = [
|
||||
".json",
|
||||
".csv",
|
||||
".txt",
|
||||
".html",
|
||||
".sql",
|
||||
] # exclude .md due to different signature
|
||||
for ext in exts:
|
||||
path = out / f"export{ext}"
|
||||
mgr.export_by_extension(str(path))
|
||||
assert path.exists() and path.stat().st_size > 0
|
||||
|
||||
# Markdown export uses a different signature (entries + path)
|
||||
entries = mgr.get_all_entries()
|
||||
md_path = out / "export.md"
|
||||
mgr.export_markdown(entries, str(md_path))
|
||||
assert md_path.exists() and md_path.stat().st_size > 0
|
||||
|
||||
# Run VACUUM path
|
||||
mgr.compact() # should not raise
|
||||
|
|
@ -10,6 +10,7 @@ from PySide6.QtTest import QTest
|
|||
from bouquin.editor import Editor
|
||||
from bouquin.theme import ThemeManager, ThemeConfig
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
a = QApplication.instance()
|
||||
|
|
@ -17,6 +18,7 @@ def app():
|
|||
a = QApplication([])
|
||||
return a
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def editor(app, qtbot):
|
||||
themes = ThemeManager(app, ThemeConfig())
|
||||
|
|
@ -25,6 +27,7 @@ def editor(app, qtbot):
|
|||
e.show()
|
||||
return e
|
||||
|
||||
|
||||
def test_todo_prefix_converts_to_checkbox_on_space(editor):
|
||||
editor.clear()
|
||||
editor.setPlainText("TODO")
|
||||
|
|
@ -35,6 +38,7 @@ def test_todo_prefix_converts_to_checkbox_on_space(editor):
|
|||
# Now the line should start with the checkbox glyph and a space
|
||||
assert editor.toPlainText().startswith("☐ ")
|
||||
|
||||
|
||||
def test_enter_inside_empty_code_frame_jumps_out(editor):
|
||||
editor.clear()
|
||||
editor.setPlainText("") # single empty block
|
||||
|
|
@ -46,13 +50,17 @@ def test_enter_inside_empty_code_frame_jumps_out(editor):
|
|||
txt = editor.toPlainText()
|
||||
assert "\n" in txt # a normal paragraph created after exiting the frame
|
||||
|
||||
|
||||
def test_insertFromMimeData_with_data_image(editor):
|
||||
# Build an in-memory PNG and embed as data URL inside HTML
|
||||
img = QImage(8, 8, QImage.Format_ARGB32)
|
||||
img.fill(0xff00ff00) # green
|
||||
img.fill(0xFF00FF00) # green
|
||||
ba = QByteArray()
|
||||
from PySide6.QtCore import QBuffer, QIODevice
|
||||
buf = QBuffer(ba); buf.open(QIODevice.WriteOnly); img.save(buf, "PNG")
|
||||
|
||||
buf = QBuffer(ba)
|
||||
buf.open(QIODevice.WriteOnly)
|
||||
img.save(buf, "PNG")
|
||||
data_b64 = base64.b64encode(bytes(ba)).decode("ascii")
|
||||
html = f'<img src="data:image/png;base64,{data_b64}"/>'
|
||||
|
||||
|
|
@ -64,6 +72,7 @@ def test_insertFromMimeData_with_data_image(editor):
|
|||
h = editor.to_html_with_embedded_images()
|
||||
assert "data:image/png;base64," in h
|
||||
|
||||
|
||||
def test_toggle_checkboxes_selection(editor):
|
||||
editor.clear()
|
||||
editor.setPlainText("item 1\nitem 2")
|
||||
|
|
@ -79,6 +88,7 @@ def test_toggle_checkboxes_selection(editor):
|
|||
editor.toggle_checkboxes()
|
||||
assert not editor.toPlainText().startswith("☐ ")
|
||||
|
||||
|
||||
def test_heading_then_enter_reverts_to_normal(editor):
|
||||
editor.clear()
|
||||
editor.setPlainText("A heading")
|
||||
|
|
|
|||
77
tests/test_editor_images_text_states.py
Normal file
77
tests/test_editor_images_text_states.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import base64
|
||||
from pathlib import Path
|
||||
from PySide6.QtCore import QUrl, QByteArray
|
||||
from PySide6.QtGui import QImage, QTextCursor, QTextImageFormat, QColor
|
||||
from bouquin.theme import ThemeManager
|
||||
from bouquin.editor import Editor
|
||||
|
||||
|
||||
def _mk_editor(qapp, cfg):
|
||||
themes = ThemeManager(qapp, cfg)
|
||||
ed = Editor(themes)
|
||||
ed.resize(400, 300)
|
||||
return ed
|
||||
|
||||
|
||||
def test_image_scale_and_reset(qapp, cfg):
|
||||
ed = _mk_editor(qapp, cfg)
|
||||
|
||||
# Register an image resource and insert it at the cursor
|
||||
img = QImage(20, 10, QImage.Format_ARGB32)
|
||||
img.fill(QColor(200, 0, 0))
|
||||
url = QUrl("test://img")
|
||||
from PySide6.QtGui import QTextDocument
|
||||
|
||||
ed.document().addResource(QTextDocument.ResourceType.ImageResource, url, img)
|
||||
|
||||
fmt = QTextImageFormat()
|
||||
fmt.setName(url.toString())
|
||||
# No explicit width -> code should use original width
|
||||
tc = ed.textCursor()
|
||||
tc.insertImage(fmt)
|
||||
|
||||
# Place cursor at start (on the image) and scale
|
||||
tc = ed.textCursor()
|
||||
tc.movePosition(QTextCursor.Start)
|
||||
ed.setTextCursor(tc)
|
||||
ed._scale_image_at_cursor(1.5) # increases width
|
||||
ed._reset_image_size() # restores to original width
|
||||
|
||||
# Ensure resulting HTML contains an <img> tag
|
||||
html = ed.toHtml()
|
||||
assert "<img" in html
|
||||
|
||||
|
||||
def test_apply_image_size_fallbacks(qapp, cfg):
|
||||
ed = _mk_editor(qapp, cfg)
|
||||
# Create a dummy image format with no width/height -> fallback branch inside _apply_image_size
|
||||
fmt = QTextImageFormat()
|
||||
fmt.setName("") # no resource available
|
||||
tc = ed.textCursor()
|
||||
# Insert a single character to have a valid cursor
|
||||
tc.insertText("x")
|
||||
tc.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor, 1)
|
||||
ed._apply_image_size(tc, fmt, new_w=100.0, orig_img=None) # should not raise
|
||||
|
||||
|
||||
def test_to_html_with_embedded_images_and_link_tint(qapp, cfg):
|
||||
ed = _mk_editor(qapp, cfg)
|
||||
|
||||
# Insert an anchor + image and ensure HTML embedding + retint pass runs
|
||||
img = QImage(8, 8, QImage.Format_ARGB32)
|
||||
img.fill(QColor(0, 200, 0))
|
||||
url = QUrl("test://img2")
|
||||
from PySide6.QtGui import QTextDocument
|
||||
|
||||
ed.document().addResource(QTextDocument.ResourceType.ImageResource, url, img)
|
||||
|
||||
# Compose HTML with a link and an image referencing our resource
|
||||
ed.setHtml(
|
||||
f'<p><a href="http://example.com">link</a></p><p><img src="{url.toString()}"></p>'
|
||||
)
|
||||
|
||||
html = ed.to_html_with_embedded_images()
|
||||
# Embedded data URL should appear for the image
|
||||
assert "data:image" in html
|
||||
# The link should still be present (retinted internally) without crashing
|
||||
assert "example.com" in html
|
||||
|
|
@ -5,6 +5,7 @@ from PySide6.QtCore import Qt
|
|||
from bouquin.db import DBConfig, DBManager
|
||||
from bouquin.history_dialog import HistoryDialog
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
a = QApplication.instance()
|
||||
|
|
@ -12,6 +13,7 @@ def app():
|
|||
a = QApplication([])
|
||||
return a
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
cfg = DBConfig(path=tmp_path / "h.db", key="k")
|
||||
|
|
@ -22,6 +24,7 @@ def db(tmp_path):
|
|||
db.save_new_version("2025-02-10", "<p>v2</p>", note="v2", set_current=True)
|
||||
return db
|
||||
|
||||
|
||||
def test_revert_early_returns(app, db, qtbot):
|
||||
dlg = HistoryDialog(db, date_iso="2025-02-10")
|
||||
qtbot.addWidget(dlg)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import types
|
|||
import sys
|
||||
import builtins
|
||||
|
||||
|
||||
def test_dunder_main_executes_without_launching_qt(monkeypatch):
|
||||
# Replace bouquin.main with a stub that records invocation and returns immediately
|
||||
calls = {"called": False}
|
||||
|
|
|
|||
93
tests/test_main_window_actions.py
Normal file
93
tests/test_main_window_actions.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import os
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from PySide6.QtCore import QDate, QByteArray
|
||||
from bouquin.theme import ThemeManager
|
||||
from bouquin.main_window import MainWindow
|
||||
from bouquin.settings import save_db_config
|
||||
from bouquin.db import DBManager
|
||||
|
||||
|
||||
def _bootstrap_window(qapp, cfg):
|
||||
# Ensure DB exists and key is valid in settings
|
||||
mgr = DBManager(cfg)
|
||||
assert mgr.connect() is True
|
||||
save_db_config(cfg)
|
||||
|
||||
themes = ThemeManager(qapp, cfg)
|
||||
win = MainWindow(themes)
|
||||
# Force an initial selected date
|
||||
win.calendar.setSelectedDate(QDate.currentDate())
|
||||
return win
|
||||
|
||||
|
||||
def test_move_todos_copies_unchecked(qapp, cfg, tmp_path):
|
||||
cfg.move_todos = True
|
||||
win = _bootstrap_window(qapp, cfg)
|
||||
|
||||
# Seed yesterday with both checked and unchecked items using the same HTML pattern the app expects
|
||||
y = QDate.currentDate().addDays(-1).toString("yyyy-MM-dd")
|
||||
html = (
|
||||
"<p><span>☐</span> Unchecked 1</p>"
|
||||
"<p><span>☑</span> Checked 1</p>"
|
||||
"<p><span>☐</span> Unchecked 2</p>"
|
||||
)
|
||||
win.db.save_new_version(y, html)
|
||||
|
||||
# Ensure today starts blank
|
||||
today_iso = QDate.currentDate().toString("yyyy-MM-dd")
|
||||
win.editor.setHtml("<p></p>")
|
||||
_html = win.editor.toHtml()
|
||||
win.db.save_new_version(today_iso, _html)
|
||||
|
||||
# Invoke the move-todos logic
|
||||
win._load_yesterday_todos()
|
||||
|
||||
# Verify today's entry now contains only the unchecked items
|
||||
txt = win.db.get_entry(today_iso)
|
||||
assert "Unchecked 1" in txt and "Unchecked 2" in txt and "Checked 1" not in txt
|
||||
|
||||
|
||||
def test_adjust_and_save_paths(qapp, cfg):
|
||||
win = _bootstrap_window(qapp, cfg)
|
||||
|
||||
# Move date selection and jump to today
|
||||
before = win.calendar.selectedDate()
|
||||
win._adjust_day(-1)
|
||||
assert win.calendar.selectedDate().toString("yyyy-MM-dd") != before.toString(
|
||||
"yyyy-MM-dd"
|
||||
)
|
||||
win._adjust_today()
|
||||
assert win.calendar.selectedDate() == QDate.currentDate()
|
||||
|
||||
# Save path exercises success feedback + dirty flag reset
|
||||
win.editor.setHtml("<p>content</p>")
|
||||
win._dirty = True
|
||||
win._save_date(QDate.currentDate().toString("yyyy-MM-dd"), explicit=True)
|
||||
assert win._dirty is False
|
||||
|
||||
|
||||
def test_restore_window_position(qapp, cfg, tmp_path):
|
||||
win = _bootstrap_window(qapp, cfg)
|
||||
|
||||
# Save geometry/state into settings and restore it (covers maximize singleShot branch too)
|
||||
geom = win.saveGeometry()
|
||||
state = win.saveState()
|
||||
s = win.settings
|
||||
s.setValue("ui/geometry", geom)
|
||||
s.setValue("ui/window_state", state)
|
||||
s.sync()
|
||||
|
||||
win._restore_window_position() # should restore without error
|
||||
|
||||
|
||||
def test_idle_lock_unlock_flow(qapp, cfg):
|
||||
win = _bootstrap_window(qapp, cfg)
|
||||
|
||||
# Enter lock
|
||||
win._enter_lock()
|
||||
assert getattr(win, "_locked", False) is True
|
||||
|
||||
# Disabling idle minutes should unlock and hide overlay
|
||||
win._apply_idle_minutes(0)
|
||||
assert getattr(win, "_locked", False) is False
|
||||
|
|
@ -6,6 +6,7 @@ import pytest
|
|||
from bouquin.db import DBConfig, DBManager
|
||||
from bouquin.search import Search
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
# Ensure a single QApplication exists
|
||||
|
|
@ -14,6 +15,7 @@ def app():
|
|||
a = QApplication([])
|
||||
yield a
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_db(tmp_path):
|
||||
cfg = DBConfig(path=tmp_path / "test.db", key="testkey")
|
||||
|
|
@ -21,10 +23,20 @@ def fresh_db(tmp_path):
|
|||
assert db.connect() is True
|
||||
# Seed a couple of entries
|
||||
db.save_new_version("2025-01-01", "<p>Hello world first day</p>")
|
||||
db.save_new_version("2025-01-02", "<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>")
|
||||
db.save_new_version("2025-01-03", "<p>Long content begins " + ("x"*200) + " middle token here " + ("y"*200) + " ends.</p>")
|
||||
db.save_new_version(
|
||||
"2025-01-02", "<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>"
|
||||
)
|
||||
db.save_new_version(
|
||||
"2025-01-03",
|
||||
"<p>Long content begins "
|
||||
+ ("x" * 200)
|
||||
+ " middle token here "
|
||||
+ ("y" * 200)
|
||||
+ " ends.</p>",
|
||||
)
|
||||
return db
|
||||
|
||||
|
||||
def test_search_exception_path_closed_db_triggers_quiet_handling(app, fresh_db, qtbot):
|
||||
# Close the DB to provoke an exception inside Search._search
|
||||
fresh_db.close()
|
||||
|
|
@ -37,6 +49,7 @@ def test_search_exception_path_closed_db_triggers_quiet_handling(app, fresh_db,
|
|||
assert w.results.isHidden() # remains hidden because there are no rows
|
||||
# Also, the "resultDatesChanged" signal should emit an empty list (coverage on that branch)
|
||||
|
||||
|
||||
def test_make_html_snippet_ellipses_both_sides(app, fresh_db):
|
||||
w = Search(fresh_db)
|
||||
# Choose a query so that the first match sits well inside a long string,
|
||||
|
|
|
|||
38
tests/test_search_windows.py
Normal file
38
tests/test_search_windows.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import pytest
|
||||
from PySide6.QtWidgets import QWidget
|
||||
from bouquin.search import Search
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def search_widget(qapp):
|
||||
# We don't need a real DB for snippet generation – pass None
|
||||
return Search(db=None)
|
||||
|
||||
|
||||
def test_make_html_snippet_empty(search_widget: Search):
|
||||
html = ""
|
||||
frag, has_prev, has_next = search_widget._make_html_snippet(
|
||||
html, "", radius=10, maxlen=20
|
||||
)
|
||||
assert frag == "" and has_prev is False and has_next is False
|
||||
|
||||
|
||||
def test_make_html_snippet_phrase_preferred(search_widget: Search):
|
||||
html = "<p>Alpha beta gamma delta</p>"
|
||||
frag, has_prev, has_next = search_widget._make_html_snippet(
|
||||
html, "beta gamma", radius=1, maxlen=10
|
||||
)
|
||||
# We expect a window that includes the phrase and has previous text
|
||||
assert "beta" in frag and "gamma" in frag
|
||||
assert has_prev is True
|
||||
|
||||
|
||||
def test_make_html_snippet_token_fallback_and_window_flags(search_widget: Search):
|
||||
html = "<p>One two three four five six seven eight nine ten eleven twelve</p>"
|
||||
# Use tokens such that the phrase doesn't exist, but individual tokens do
|
||||
frag, has_prev, has_next = search_widget._make_html_snippet(
|
||||
html, "eleven two", radius=3, maxlen=20
|
||||
)
|
||||
assert "two" in frag
|
||||
# The snippet should be a slice within the text (has more following content)
|
||||
assert has_next is True
|
||||
|
|
@ -9,6 +9,7 @@ from bouquin.settings import APP_NAME, APP_ORG
|
|||
from bouquin.key_prompt import KeyPrompt
|
||||
from bouquin.theme import Theme, ThemeManager, ThemeConfig
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
a = QApplication.instance()
|
||||
|
|
@ -18,6 +19,7 @@ def app():
|
|||
a.setOrganizationName(APP_ORG)
|
||||
return a
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
cfg = DBConfig(path=tmp_path / "s.db", key="abc")
|
||||
|
|
@ -25,6 +27,7 @@ def db(tmp_path):
|
|||
assert m.connect()
|
||||
return m
|
||||
|
||||
|
||||
def test_theme_radio_initialisation_dark_and_light(app, db, monkeypatch, qtbot):
|
||||
# Dark preselection
|
||||
parent = _ParentWithThemes(app)
|
||||
|
|
@ -44,6 +47,7 @@ def test_theme_radio_initialisation_dark_and_light(app, db, monkeypatch, qtbot):
|
|||
dlg2._save()
|
||||
assert dlg2.config.theme == Theme.LIGHT.value
|
||||
|
||||
|
||||
def test_change_key_cancel_branches(app, db, monkeypatch, qtbot):
|
||||
parent = _ParentWithThemes(app)
|
||||
qtbot.addWidget(parent)
|
||||
|
|
@ -56,17 +60,20 @@ def test_change_key_cancel_branches(app, db, monkeypatch, qtbot):
|
|||
assert dlg.key == ""
|
||||
|
||||
# First OK, second cancelled -> early return at the second branch
|
||||
state = {'calls': 0}
|
||||
state = {"calls": 0}
|
||||
|
||||
def _exec(self):
|
||||
state['calls'] += 1
|
||||
return QDialog.Accepted if state['calls'] == 1 else QDialog.Rejected
|
||||
monkeypatch.setattr(KeyPrompt, 'exec', _exec)
|
||||
state["calls"] += 1
|
||||
return QDialog.Accepted if state["calls"] == 1 else QDialog.Rejected
|
||||
|
||||
monkeypatch.setattr(KeyPrompt, "exec", _exec)
|
||||
# Also monkeypatch to control key() values
|
||||
monkeypatch.setattr(KeyPrompt, "key", lambda self: "new-secret")
|
||||
dlg._change_key()
|
||||
# Because the second prompt was rejected, key should remain unchanged
|
||||
assert dlg.key == ""
|
||||
|
||||
|
||||
def test_save_key_checkbox_cancel_restores_checkbox(app, db, monkeypatch, qtbot):
|
||||
parent = _ParentWithThemes(app)
|
||||
qtbot.addWidget(parent)
|
||||
|
|
@ -92,7 +99,9 @@ def test_change_key_exception_path(app, db, monkeypatch, qtbot):
|
|||
monkeypatch.setattr(KeyPrompt, "key", lambda self: "boom")
|
||||
|
||||
# Force DB rekey to raise to exercise the except-branch
|
||||
monkeypatch.setattr(db, "rekey", lambda new_key: (_ for _ in ()).throw(RuntimeError("fail")))
|
||||
monkeypatch.setattr(
|
||||
db, "rekey", lambda new_key: (_ for _ in ()).throw(RuntimeError("fail"))
|
||||
)
|
||||
|
||||
# Should not raise; error is handled internally
|
||||
dlg._change_key()
|
||||
|
|
@ -102,4 +111,3 @@ class _ParentWithThemes(QWidget):
|
|||
def __init__(self, app):
|
||||
super().__init__()
|
||||
self.themes = ThemeManager(app, ThemeConfig())
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue