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()
|
||||
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:
|
||||
# strip common trailing punctuation not part of the URL
|
||||
trimmed = url.rstrip(".,;:!?\"'")
|
||||
|
|
@ -854,14 +844,6 @@ class Editor(QTextEdit):
|
|||
break
|
||||
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()
|
||||
def apply_weight(self):
|
||||
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