More test coverage, remove unused functions from editor.py

This commit is contained in:
Miguel Jacq 2025-11-07 11:13:04 +11:00
parent ada1d8ffad
commit 66950eeff5
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
6 changed files with 301 additions and 18 deletions

View file

@ -271,16 +271,6 @@ class Editor(QTextEdit):
cur.endEditBlock() cur.endEditBlock()
self.viewport().update() self.viewport().update()
def _safe_select(self, cur: QTextCursor, start: int, end: int):
"""Select [start, end] inclusive without exceeding document bounds."""
doc_max = max(0, self.document().characterCount() - 1)
s = max(0, min(start, doc_max))
e = max(0, min(end, doc_max))
if e < s:
s, e = e, s
cur.setPosition(s)
cur.setPosition(e, QTextCursor.KeepAnchor)
def _trim_url_end(self, url: str) -> str: def _trim_url_end(self, url: str) -> str:
# strip common trailing punctuation not part of the URL # strip common trailing punctuation not part of the URL
trimmed = url.rstrip(".,;:!?\"'") trimmed = url.rstrip(".,;:!?\"'")
@ -854,14 +844,6 @@ class Editor(QTextEdit):
break break
b = b.next() b = b.next()
def toggle_current_checkbox_state(self):
"""Tick/untick the current line if it starts with a checkbox."""
b = self.textCursor().block()
state, _ = self._checkbox_info_for_block(b)
if state is None:
return
self._set_block_checkbox_state(b, not state)
@Slot() @Slot()
def apply_weight(self): def apply_weight(self):
cur = self.currentCharFormat() cur = self.currentCharFormat()

View file

@ -0,0 +1,94 @@
import base64
from io import BytesIO
import pytest
from PySide6.QtCore import Qt, QMimeData, QByteArray
from PySide6.QtGui import QImage, QPixmap, QKeyEvent, QTextCursor
from PySide6.QtWidgets import QApplication
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()
if a is None:
a = QApplication([])
return a
@pytest.fixture
def editor(app, qtbot):
themes = ThemeManager(app, ThemeConfig())
e = Editor(themes)
qtbot.addWidget(e)
e.show()
return e
def test_todo_prefix_converts_to_checkbox_on_space(editor):
editor.clear()
editor.setPlainText("TODO")
c = editor.textCursor()
c.movePosition(QTextCursor.End)
editor.setTextCursor(c)
QTest.keyClick(editor, Qt.Key_Space)
# 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
# Apply code block to current line
editor.apply_code()
# Cursor is inside the code frame. Press Enter on empty block should jump out.
QTest.keyClick(editor, Qt.Key_Return)
# We expect two blocks: one code block (with a newline inserted) and then a normal block
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
ba = QByteArray()
from PySide6.QtCore import QBuffer, QIODevice
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}"/>'
md = QMimeData()
md.setHtml(html)
editor.insertFromMimeData(md)
# HTML export with embedded images should contain a data: URL
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")
# Select both lines
c = editor.textCursor()
c.movePosition(QTextCursor.Start)
c.movePosition(QTextCursor.End, QTextCursor.KeepAnchor)
editor.setTextCursor(c)
# Toggle on -> inserts ☐
editor.toggle_checkboxes()
assert editor.toPlainText().startswith("")
# Toggle again -> remove ☐
editor.toggle_checkboxes()
assert not editor.toPlainText().startswith("")
def test_heading_then_enter_reverts_to_normal(editor):
editor.clear()
editor.setPlainText("A heading")
# Apply H2 via apply_heading(size=18)
editor.apply_heading(18)
c = editor.textCursor()
c.movePosition(QTextCursor.End)
editor.setTextCursor(c)
# Press Enter -> new block should be Normal (not bold/large)
QTest.keyClick(editor, Qt.Key_Return)
# The new block exists
txt = editor.toPlainText()
assert "\n" in txt

View file

@ -0,0 +1,40 @@
import pytest
from PySide6.QtWidgets import QApplication, QListWidgetItem
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()
if a is None:
a = QApplication([])
return a
@pytest.fixture
def db(tmp_path):
cfg = DBConfig(path=tmp_path / "h.db", key="k")
db = DBManager(cfg)
assert db.connect()
# Seed two versions for a date
db.save_new_version("2025-02-10", "<p>v1</p>", note="v1", set_current=True)
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)
# (1) No current item -> returns immediately
dlg.list.setCurrentItem(None)
dlg._revert() # should not crash and should not accept
# (2) Selecting the current item -> still returns early
# Build an item with the *current* id as payload
cur_id = next(v["id"] for v in db.list_versions("2025-02-10") if v["is_current"])
it = QListWidgetItem("current")
it.setData(Qt.UserRole, cur_id)
dlg.list.addItem(it)
dlg.list.setCurrentItem(it)
dlg._revert() # should return early (no accept called)

14
tests/test_main_module.py Normal file
View file

@ -0,0 +1,14 @@
import runpy
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}
mod = types.SimpleNamespace(main=lambda: calls.__setitem__("called", True))
monkeypatch.setitem(sys.modules, "bouquin.main", mod)
# Running the module as __main__ should call mod.main() but not start a Qt loop
runpy.run_module("bouquin.__main__", run_name="__main__")
assert calls["called"] is True

View file

@ -0,0 +1,48 @@
import os
import tempfile
from PySide6.QtWidgets import QApplication
import pytest
from bouquin.db import DBConfig, DBManager
from bouquin.search import Search
@pytest.fixture(scope="module")
def app():
# Ensure a single QApplication exists
a = QApplication.instance()
if a is None:
a = QApplication([])
yield a
@pytest.fixture
def fresh_db(tmp_path):
cfg = DBConfig(path=tmp_path / "test.db", key="testkey")
db = DBManager(cfg)
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>")
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()
w = Search(fresh_db)
w.show()
qtbot.addWidget(w)
# Typing should not raise; exception path returns empty results
w._search("anything")
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,
# forcing both left and right ellipses.
html = fresh_db.get_entry("2025-01-03")
snippet, left_ell, right_ell = w._make_html_snippet(html, "middle")
assert snippet # non-empty
assert left_ell is True
assert right_ell is True

View file

@ -0,0 +1,105 @@
import types
import pytest
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QDialog, QWidget
from bouquin.db import DBConfig, DBManager
from bouquin.settings_dialog import SettingsDialog
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()
if a is None:
a = QApplication([])
a.setApplicationName(APP_NAME)
a.setOrganizationName(APP_ORG)
return a
@pytest.fixture
def db(tmp_path):
cfg = DBConfig(path=tmp_path / "s.db", key="abc")
m = DBManager(cfg)
assert m.connect()
return m
def test_theme_radio_initialisation_dark_and_light(app, db, monkeypatch, qtbot):
# Dark preselection
parent = _ParentWithThemes(app)
qtbot.addWidget(parent)
dlg = SettingsDialog(db.cfg, db, parent=parent)
qtbot.addWidget(dlg)
dlg.theme_dark.setChecked(True)
dlg._save()
assert dlg.config.theme == Theme.DARK.value
# Light preselection
parent2 = _ParentWithThemes(app)
qtbot.addWidget(parent2)
dlg2 = SettingsDialog(db.cfg, db, parent=parent2)
qtbot.addWidget(dlg2)
dlg2.theme_light.setChecked(True)
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)
dlg = SettingsDialog(db.cfg, db, parent=parent)
qtbot.addWidget(dlg)
# First prompt cancelled -> early return
monkeypatch.setattr(KeyPrompt, "exec", lambda self: QDialog.Rejected)
dlg._change_key() # should just return without altering key
assert dlg.key == ""
# First OK, second cancelled -> early return at the second branch
state = {'calls': 0}
def _exec(self):
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)
dlg = SettingsDialog(db.cfg, db, parent=parent)
qtbot.addWidget(dlg)
qtbot.addWidget(dlg)
# Simulate user checking the box, but cancelling the prompt -> code unchecks it again
monkeypatch.setattr(KeyPrompt, "exec", lambda self: QDialog.Rejected)
dlg.save_key_btn.setChecked(True)
# The slot toggled should run and revert it to unchecked
assert dlg.save_key_btn.isChecked() is False
def test_change_key_exception_path(app, db, monkeypatch, qtbot):
parent = _ParentWithThemes(app)
qtbot.addWidget(parent)
dlg = SettingsDialog(db.cfg, db, parent=parent)
qtbot.addWidget(dlg)
# Accept both prompts and supply a key
monkeypatch.setattr(KeyPrompt, "exec", lambda self: QDialog.Accepted)
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")))
# Should not raise; error is handled internally
dlg._change_key()
class _ParentWithThemes(QWidget):
def __init__(self, app):
super().__init__()
self.themes = ThemeManager(app, ThemeConfig())