More test coverage, remove unused functions from editor.py
This commit is contained in:
parent
ada1d8ffad
commit
66950eeff5
6 changed files with 301 additions and 18 deletions
|
|
@ -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()
|
||||||
|
|
|
||||||
94
tests/test_editor_features_more.py
Normal file
94
tests/test_editor_features_more.py
Normal 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
|
||||||
40
tests/test_history_dialog_revert_edges.py
Normal file
40
tests/test_history_dialog_revert_edges.py
Normal 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
14
tests/test_main_module.py
Normal 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
|
||||||
48
tests/test_search_edges.py
Normal file
48
tests/test_search_edges.py
Normal 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
|
||||||
105
tests/test_settings_dialog_cancel_paths.py
Normal file
105
tests/test_settings_dialog_cancel_paths.py
Normal 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())
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue