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()) +