diff --git a/bouquin/editor.py b/bouquin/editor.py
index cb18755..ff3504b 100644
--- a/bouquin/editor.py
+++ b/bouquin/editor.py
@@ -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()
diff --git a/tests/test_editor_features_more.py b/tests/test_editor_features_more.py
new file mode 100644
index 0000000..aa63ecf
--- /dev/null
+++ b/tests/test_editor_features_more.py
@@ -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''
+
+ 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
diff --git a/tests/test_history_dialog_revert_edges.py b/tests/test_history_dialog_revert_edges.py
new file mode 100644
index 0000000..d120e13
--- /dev/null
+++ b/tests/test_history_dialog_revert_edges.py
@@ -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", "
v1
", note="v1", set_current=True) + db.save_new_version("2025-02-10", "v2
", 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) diff --git a/tests/test_main_module.py b/tests/test_main_module.py new file mode 100644 index 0000000..abf4a50 --- /dev/null +++ b/tests/test_main_module.py @@ -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 diff --git a/tests/test_search_edges.py b/tests/test_search_edges.py new file mode 100644 index 0000000..406ff4c --- /dev/null +++ b/tests/test_search_edges.py @@ -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", "Hello world first day
") + db.save_new_version("2025-01-02", "Lorem ipsum dolor sit amet, consectetur adipiscing elit.
") + db.save_new_version("2025-01-03", "Long content begins " + ("x"*200) + " middle token here " + ("y"*200) + " ends.
") + 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 diff --git a/tests/test_settings_dialog_cancel_paths.py b/tests/test_settings_dialog_cancel_paths.py new file mode 100644 index 0000000..8cf698b --- /dev/null +++ b/tests/test_settings_dialog_cancel_paths.py @@ -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()) +