Well, 95% test coverage is okay I guess
This commit is contained in:
parent
ab5ec2bfae
commit
db0476f9ad
15 changed files with 1851 additions and 78 deletions
|
|
@ -122,8 +122,6 @@ class FindBar(QWidget):
|
||||||
return flags
|
return flags
|
||||||
|
|
||||||
def find_next(self):
|
def find_next(self):
|
||||||
if not self.editor:
|
|
||||||
return
|
|
||||||
txt = self.edit.text()
|
txt = self.edit.text()
|
||||||
if not txt:
|
if not txt:
|
||||||
return
|
return
|
||||||
|
|
@ -149,8 +147,6 @@ class FindBar(QWidget):
|
||||||
self._update_highlight()
|
self._update_highlight()
|
||||||
|
|
||||||
def find_prev(self):
|
def find_prev(self):
|
||||||
if not self.editor:
|
|
||||||
return
|
|
||||||
txt = self.edit.text()
|
txt = self.edit.text()
|
||||||
if not txt:
|
if not txt:
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -163,8 +163,6 @@ class HistoryDialog(QDialog):
|
||||||
@Slot()
|
@Slot()
|
||||||
def _revert(self):
|
def _revert(self):
|
||||||
item = self.list.currentItem()
|
item = self.list.currentItem()
|
||||||
if not item:
|
|
||||||
return
|
|
||||||
sel_id = item.data(Qt.UserRole)
|
sel_id = item.data(Qt.UserRole)
|
||||||
if sel_id == self._current_id:
|
if sel_id == self._current_id:
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -111,4 +111,3 @@
|
||||||
"toolbar_heading": "Titre",
|
"toolbar_heading": "Titre",
|
||||||
"toolbar_toggle_checkboxes": "Cocher/Décocher les cases"
|
"toolbar_toggle_checkboxes": "Cocher/Décocher les cases"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,6 @@ class LockOverlay(QWidget):
|
||||||
self.setFocusPolicy(Qt.StrongFocus)
|
self.setFocusPolicy(Qt.StrongFocus)
|
||||||
self.setGeometry(parent.rect())
|
self.setGeometry(parent.rect())
|
||||||
|
|
||||||
self._last_dark: bool | None = None
|
|
||||||
|
|
||||||
lay = QVBoxLayout(self)
|
lay = QVBoxLayout(self)
|
||||||
lay.addStretch(1)
|
lay.addStretch(1)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -675,6 +675,9 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
def _save_editor_content(self, editor: MarkdownEditor):
|
def _save_editor_content(self, editor: MarkdownEditor):
|
||||||
"""Save a specific editor's content to its associated date."""
|
"""Save a specific editor's content to its associated date."""
|
||||||
|
# Skip if DB is missing or not connected somehow.
|
||||||
|
if not getattr(self, "db", None) or getattr(self.db, "conn", None) is None:
|
||||||
|
return
|
||||||
if not hasattr(editor, "current_date"):
|
if not hasattr(editor, "current_date"):
|
||||||
return
|
return
|
||||||
date_iso = editor.current_date.toString("yyyy-MM-dd")
|
date_iso = editor.current_date.toString("yyyy-MM-dd")
|
||||||
|
|
@ -773,9 +776,13 @@ class MainWindow(QMainWindow):
|
||||||
Save editor contents into the given date. Shows status on success.
|
Save editor contents into the given date. Shows status on success.
|
||||||
explicit=True means user invoked Save: show feedback even if nothing changed.
|
explicit=True means user invoked Save: show feedback even if nothing changed.
|
||||||
"""
|
"""
|
||||||
|
# Bail out if there is no DB connection (can happen during construction/teardown)
|
||||||
|
if not getattr(self.db, "conn", None):
|
||||||
|
return
|
||||||
|
|
||||||
if not self._dirty and not explicit:
|
if not self._dirty and not explicit:
|
||||||
return
|
return
|
||||||
text = self.editor.to_markdown()
|
text = self.editor.to_markdown() if hasattr(self, "editor") else ""
|
||||||
self.db.save_new_version(date_iso, text, note)
|
self.db.save_new_version(date_iso, text, note)
|
||||||
self._dirty = False
|
self._dirty = False
|
||||||
self._refresh_calendar_marks()
|
self._refresh_calendar_marks()
|
||||||
|
|
@ -867,13 +874,14 @@ class MainWindow(QMainWindow):
|
||||||
fmt.setFontWeight(QFont.Weight.Normal) # remove bold only
|
fmt.setFontWeight(QFont.Weight.Normal) # remove bold only
|
||||||
self.calendar.setDateTextFormat(d, fmt)
|
self.calendar.setDateTextFormat(d, fmt)
|
||||||
self._marked_dates = set()
|
self._marked_dates = set()
|
||||||
for date_iso in self.db.dates_with_content():
|
if self.db.conn is not None:
|
||||||
qd = QDate.fromString(date_iso, "yyyy-MM-dd")
|
for date_iso in self.db.dates_with_content():
|
||||||
if qd.isValid():
|
qd = QDate.fromString(date_iso, "yyyy-MM-dd")
|
||||||
fmt = self.calendar.dateTextFormat(qd)
|
if qd.isValid():
|
||||||
fmt.setFontWeight(QFont.Weight.Bold) # add bold only
|
fmt = self.calendar.dateTextFormat(qd)
|
||||||
self.calendar.setDateTextFormat(qd, fmt)
|
fmt.setFontWeight(QFont.Weight.Bold) # add bold only
|
||||||
self._marked_dates.add(qd)
|
self.calendar.setDateTextFormat(qd, fmt)
|
||||||
|
self._marked_dates.add(qd)
|
||||||
|
|
||||||
# -------------------- UI handlers ------------------- #
|
# -------------------- UI handlers ------------------- #
|
||||||
|
|
||||||
|
|
@ -1248,17 +1256,39 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
# ----------------- Close handlers ----------------- #
|
# ----------------- Close handlers ----------------- #
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
# Save window position
|
# Persist geometry if settings exist (window might be half-initialized).
|
||||||
self.settings.setValue("main/geometry", self.saveGeometry())
|
if getattr(self, "settings", None) is not None:
|
||||||
self.settings.setValue("main/windowState", self.saveState())
|
try:
|
||||||
self.settings.setValue("main/maximized", self.isMaximized())
|
self.settings.setValue("main/geometry", self.saveGeometry())
|
||||||
|
self.settings.setValue("main/windowState", self.saveState())
|
||||||
|
self.settings.setValue("main/maximized", self.isMaximized())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Stop timers if present to avoid late autosaves firing during teardown.
|
||||||
|
for _t in ("_autosave_timer", "_idle_timer"):
|
||||||
|
t = getattr(self, _t, None)
|
||||||
|
if t:
|
||||||
|
t.stop()
|
||||||
|
|
||||||
|
# Save content from tabs if the database is still connected
|
||||||
|
db = getattr(self, "db", None)
|
||||||
|
conn = getattr(db, "conn", None)
|
||||||
|
tw = getattr(self, "tab_widget", None)
|
||||||
|
if db is not None and conn is not None and tw is not None:
|
||||||
|
try:
|
||||||
|
for i in range(tw.count()):
|
||||||
|
editor = tw.widget(i)
|
||||||
|
if editor is not None:
|
||||||
|
self._save_editor_content(editor)
|
||||||
|
except Exception:
|
||||||
|
# Don't let teardown crash if one tab fails to save.
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
db.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Ensure we save all tabs before closing
|
|
||||||
for i in range(self.tab_widget.count()):
|
|
||||||
editor = self.tab_widget.widget(i)
|
|
||||||
if editor:
|
|
||||||
self._save_editor_content(editor)
|
|
||||||
self.db.close()
|
|
||||||
super().closeEvent(event)
|
super().closeEvent(event)
|
||||||
|
|
||||||
# ----------------- Below logic helps focus the editor ----------------- #
|
# ----------------- Below logic helps focus the editor ----------------- #
|
||||||
|
|
|
||||||
|
|
@ -41,15 +41,15 @@ class MarkdownHighlighter(QSyntaxHighlighter):
|
||||||
self.italic_format = QTextCharFormat()
|
self.italic_format = QTextCharFormat()
|
||||||
self.italic_format.setFontItalic(True)
|
self.italic_format.setFontItalic(True)
|
||||||
|
|
||||||
# Strikethrough: ~~text~~
|
|
||||||
self.strike_format = QTextCharFormat()
|
|
||||||
self.strike_format.setFontStrikeOut(True)
|
|
||||||
|
|
||||||
# Allow combination of bold/italic
|
# Allow combination of bold/italic
|
||||||
self.bold_italic_format = QTextCharFormat()
|
self.bold_italic_format = QTextCharFormat()
|
||||||
self.bold_italic_format.setFontWeight(QFont.Weight.Bold)
|
self.bold_italic_format.setFontWeight(QFont.Weight.Bold)
|
||||||
self.bold_italic_format.setFontItalic(True)
|
self.bold_italic_format.setFontItalic(True)
|
||||||
|
|
||||||
|
# Strikethrough: ~~text~~
|
||||||
|
self.strike_format = QTextCharFormat()
|
||||||
|
self.strike_format.setFontStrikeOut(True)
|
||||||
|
|
||||||
# Inline code: `code`
|
# Inline code: `code`
|
||||||
mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
|
mono = QFontDatabase.systemFont(QFontDatabase.FixedFont)
|
||||||
self.code_format = QTextCharFormat()
|
self.code_format = QTextCharFormat()
|
||||||
|
|
@ -163,25 +163,30 @@ class MarkdownHighlighter(QSyntaxHighlighter):
|
||||||
self.setFormat(marker_len, len(text) - marker_len, heading_fmt)
|
self.setFormat(marker_len, len(text) - marker_len, heading_fmt)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Bold+Italic: ***text*** or ___text___
|
# Bold+Italic (*** or ___): do these first and record occupied spans.
|
||||||
# Do these first and remember their spans so later passes don't override them.
|
# --- Triple emphasis: detect first, hide markers now, but DEFER applying content style
|
||||||
occupied = []
|
triple_contents: list[tuple[int, int]] = [] # (start, length) for content only
|
||||||
|
occupied: list[tuple[int, int]] = (
|
||||||
|
[]
|
||||||
|
) # full spans including markers, for overlap checks
|
||||||
|
|
||||||
for m in re.finditer(
|
for m in re.finditer(
|
||||||
r"(?<!\*)\*\*\*(.+?)(?<!\*)\*\*\*|(?<!_)___(.+?)(?<!_)___", text
|
r"(?<!\*)\*\*\*(.+?)(?<!\*)\*\*\*|(?<!_)___(.+?)(?<!_)___", text
|
||||||
):
|
):
|
||||||
start, end = m.span()
|
start, end = m.span()
|
||||||
content_start, content_end = start + 3, end - 3
|
content_start, content_end = start + 3, end - 3
|
||||||
self.setFormat(start, 3, self.syntax_format) # hide leading ***
|
# hide the *** / ___ markers now
|
||||||
self.setFormat(end - 3, 3, self.syntax_format) # hide trailing ***
|
self.setFormat(start, 3, self.syntax_format)
|
||||||
self.setFormat(
|
self.setFormat(end - 3, 3, self.syntax_format)
|
||||||
content_start, content_end - content_start, self.bold_italic_format
|
|
||||||
)
|
|
||||||
occupied.append((start, end))
|
|
||||||
|
|
||||||
def _overlaps(a, b):
|
# remember the full occupied span and the content span
|
||||||
|
occupied.append((start, end))
|
||||||
|
triple_contents.append((content_start, content_end - content_start))
|
||||||
|
|
||||||
|
def _overlaps(a, b): # a, b are (start, end)
|
||||||
return not (a[1] <= b[0] or b[1] <= a[0])
|
return not (a[1] <= b[0] or b[1] <= a[0])
|
||||||
|
|
||||||
# Bold: **text** or __text__ (but not part of *** or ___)
|
# --- Bold (**) or (__): skip if it overlaps any triple
|
||||||
for m in re.finditer(
|
for m in re.finditer(
|
||||||
r"(?<!\*)\*\*(?!\*)(.+?)(?<!\*)\*\*(?!\*)|(?<!_)__(?!_)(.+?)(?<!_)__(?!_)",
|
r"(?<!\*)\*\*(?!\*)(.+?)(?<!\*)\*\*(?!\*)|(?<!_)__(?!_)(.+?)(?<!_)__(?!_)",
|
||||||
text,
|
text,
|
||||||
|
|
@ -194,14 +199,14 @@ class MarkdownHighlighter(QSyntaxHighlighter):
|
||||||
self.setFormat(end - 2, 2, self.syntax_format)
|
self.setFormat(end - 2, 2, self.syntax_format)
|
||||||
self.setFormat(content_start, content_end - content_start, self.bold_format)
|
self.setFormat(content_start, content_end - content_start, self.bold_format)
|
||||||
|
|
||||||
# Italic: *text* or _text_ (but not part of bold/** and not inside *** or ___)
|
# --- Italic (*) or (_): skip if it overlaps any triple, keep your guards
|
||||||
for m in re.finditer(
|
for m in re.finditer(
|
||||||
r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)|(?<!_)_(?!_)(.+?)(?<!_)_(?!_)", text
|
r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)|(?<!_)_(?!_)(.+?)(?<!_)_(?!_)", text
|
||||||
):
|
):
|
||||||
start, end = m.span()
|
start, end = m.span()
|
||||||
if any(_overlaps((start, end), occ) for occ in occupied):
|
if any(_overlaps((start, end), occ) for occ in occupied):
|
||||||
continue
|
continue
|
||||||
# Keep your existing guards that avoid grabbing * from **:
|
# avoid stealing a single marker that is part of a double
|
||||||
if start > 0 and text[start - 1 : start + 1] in ("**", "__"):
|
if start > 0 and text[start - 1 : start + 1] in ("**", "__"):
|
||||||
continue
|
continue
|
||||||
if end < len(text) and text[end : end + 1] in ("*", "_"):
|
if end < len(text) and text[end : end + 1] in ("*", "_"):
|
||||||
|
|
@ -213,6 +218,10 @@ class MarkdownHighlighter(QSyntaxHighlighter):
|
||||||
content_start, content_end - content_start, self.italic_format
|
content_start, content_end - content_start, self.italic_format
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- NOW overlay bold+italic for triple contents LAST (so nothing clobbers it)
|
||||||
|
for cs, length in triple_contents:
|
||||||
|
self._overlay_range(cs, length, self.bold_italic_format)
|
||||||
|
|
||||||
# Strikethrough: ~~text~~
|
# Strikethrough: ~~text~~
|
||||||
for m in re.finditer(r"~~(.+?)~~", text):
|
for m in re.finditer(r"~~(.+?)~~", text):
|
||||||
start, end = m.span()
|
start, end = m.span()
|
||||||
|
|
|
||||||
|
|
@ -303,7 +303,7 @@ class SettingsDialog(QDialog):
|
||||||
self, strings._("key_changed"), strings._("key_changed_explanation")
|
self, strings._("key_changed"), strings._("key_changed_explanation")
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, strings._("error"), e)
|
QMessageBox.critical(self, strings._("error"), str(e))
|
||||||
|
|
||||||
@Slot(bool)
|
@Slot(bool)
|
||||||
def _save_key_btn_clicked(self, checked: bool):
|
def _save_key_btn_clicked(self, checked: bool):
|
||||||
|
|
@ -330,7 +330,7 @@ class SettingsDialog(QDialog):
|
||||||
self, strings._("success"), strings._("database_compacted_successfully")
|
self, strings._("success"), strings._("database_compacted_successfully")
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(self, strings._("error"), e)
|
QMessageBox.critical(self, strings._("error"), str(e))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def config(self) -> DBConfig:
|
def config(self) -> DBConfig:
|
||||||
|
|
|
||||||
|
|
@ -166,3 +166,42 @@ def test_compact_error_path(monkeypatch, tmp_db_cfg):
|
||||||
db.conn = BadConn()
|
db.conn = BadConn()
|
||||||
# Should not raise; just print error
|
# Should not raise; just print error
|
||||||
db.compact()
|
db.compact()
|
||||||
|
|
||||||
|
|
||||||
|
class _Cur:
|
||||||
|
def __init__(self, rows):
|
||||||
|
self._rows = rows
|
||||||
|
|
||||||
|
def execute(self, *a, **k):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def fetchall(self):
|
||||||
|
return list(self._rows)
|
||||||
|
|
||||||
|
|
||||||
|
class _Conn:
|
||||||
|
def __init__(self, rows):
|
||||||
|
self._rows = rows
|
||||||
|
|
||||||
|
def cursor(self):
|
||||||
|
return _Cur(self._rows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_integrity_check_raises_with_details(tmp_db_cfg):
|
||||||
|
db = DBManager(tmp_db_cfg)
|
||||||
|
assert db.connect()
|
||||||
|
# Force the integrity check to report problems with text details
|
||||||
|
db.conn = _Conn([("bad page checksum",), (None,)])
|
||||||
|
with pytest.raises(sqlite.IntegrityError) as ei:
|
||||||
|
db._integrity_ok()
|
||||||
|
# Message should contain the detail string
|
||||||
|
assert "bad page checksum" in str(ei.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_integrity_check_raises_without_details(tmp_db_cfg):
|
||||||
|
db = DBManager(tmp_db_cfg)
|
||||||
|
assert db.connect()
|
||||||
|
# Force the integrity check to report problems but without textual details
|
||||||
|
db.conn = _Conn([(None,), (None,)])
|
||||||
|
with pytest.raises(sqlite.IntegrityError):
|
||||||
|
db._integrity_ok()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from PySide6.QtGui import QTextCursor
|
from PySide6.QtGui import QTextCursor
|
||||||
|
from PySide6.QtWidgets import QTextEdit, QWidget
|
||||||
from bouquin.markdown_editor import MarkdownEditor
|
from bouquin.markdown_editor import MarkdownEditor
|
||||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||||
from bouquin.find_bar import FindBar
|
from bouquin.find_bar import FindBar
|
||||||
|
|
@ -133,3 +134,40 @@ def test_maybe_hide_and_wrap_prev(qtbot, editor):
|
||||||
c.movePosition(QTextCursor.Start)
|
c.movePosition(QTextCursor.Start)
|
||||||
editor.setTextCursor(c)
|
editor.setTextCursor(c)
|
||||||
fb.find_prev()
|
fb.find_prev()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_fb(editor, qtbot):
|
||||||
|
"""Create a FindBar with a live parent kept until teardown."""
|
||||||
|
parent = QWidget()
|
||||||
|
qtbot.addWidget(parent)
|
||||||
|
fb = FindBar(editor=editor, parent=parent)
|
||||||
|
qtbot.addWidget(fb)
|
||||||
|
parent.show()
|
||||||
|
fb.show()
|
||||||
|
return fb, parent
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_next_early_returns_no_editor(qtbot):
|
||||||
|
# No editor: should early return and not crash
|
||||||
|
fb, _parent = _make_fb(editor=None, qtbot=qtbot)
|
||||||
|
fb.find_next()
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_next_early_returns_empty_text(qtbot):
|
||||||
|
ed = QTextEdit()
|
||||||
|
fb, _parent = _make_fb(editor=ed, qtbot=qtbot)
|
||||||
|
fb.edit.setText("") # empty -> early return
|
||||||
|
fb.find_next()
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_prev_early_returns_empty_text(qtbot):
|
||||||
|
ed = QTextEdit()
|
||||||
|
fb, _parent = _make_fb(editor=ed, qtbot=qtbot)
|
||||||
|
fb.edit.setText("") # empty -> early return
|
||||||
|
fb.find_prev()
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_highlight_early_returns_no_editor(qtbot):
|
||||||
|
fb, _parent = _make_fb(editor=None, qtbot=qtbot)
|
||||||
|
fb.edit.setText("abc")
|
||||||
|
fb._update_highlight() # should return without error
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from PySide6.QtWidgets import QWidget, QMessageBox
|
from PySide6.QtWidgets import QWidget, QMessageBox, QApplication
|
||||||
from PySide6.QtCore import Qt, QTimer
|
from PySide6.QtCore import Qt, QTimer
|
||||||
|
|
||||||
from bouquin.history_dialog import HistoryDialog
|
from bouquin.history_dialog import HistoryDialog
|
||||||
|
|
@ -83,3 +83,87 @@ def test_history_dialog_revert_error_shows_message(qtbot, fresh_db):
|
||||||
dlg._revert()
|
dlg._revert()
|
||||||
finally:
|
finally:
|
||||||
t.stop()
|
t.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_revert_returns_when_no_item_selected(qtbot, fresh_db):
|
||||||
|
d = "2000-01-01"
|
||||||
|
fresh_db.save_new_version(d, "v1", "first")
|
||||||
|
w = QWidget()
|
||||||
|
dlg = HistoryDialog(fresh_db, d, parent=w)
|
||||||
|
qtbot.addWidget(dlg)
|
||||||
|
dlg.show()
|
||||||
|
# No selection at all -> early return
|
||||||
|
dlg.list.clearSelection()
|
||||||
|
dlg._revert() # should not raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_revert_returns_when_current_selected(qtbot, fresh_db):
|
||||||
|
d = "2000-01-02"
|
||||||
|
fresh_db.save_new_version(d, "v1", "first")
|
||||||
|
# Create a second version so there is a 'current'
|
||||||
|
fresh_db.save_new_version(d, "v2", "second")
|
||||||
|
w = QWidget()
|
||||||
|
dlg = HistoryDialog(fresh_db, d, parent=w)
|
||||||
|
qtbot.addWidget(dlg)
|
||||||
|
dlg.show()
|
||||||
|
# Select the current item -> early return
|
||||||
|
for i in range(dlg.list.count()):
|
||||||
|
item = dlg.list.item(i)
|
||||||
|
if item.data(Qt.UserRole) == dlg._current_id:
|
||||||
|
dlg.list.setCurrentItem(item)
|
||||||
|
break
|
||||||
|
dlg._revert() # no-op
|
||||||
|
|
||||||
|
|
||||||
|
def test_revert_exception_shows_message(qtbot, fresh_db, monkeypatch):
|
||||||
|
"""
|
||||||
|
Trigger the exception path in _revert() and auto-accept the modal
|
||||||
|
QMessageBox that HistoryDialog pops so the test doesn't hang.
|
||||||
|
"""
|
||||||
|
d = "2000-01-03"
|
||||||
|
fresh_db.save_new_version(d, "v1", "first")
|
||||||
|
fresh_db.save_new_version(d, "v2", "second")
|
||||||
|
|
||||||
|
w = QWidget()
|
||||||
|
dlg = HistoryDialog(fresh_db, d, parent=w)
|
||||||
|
qtbot.addWidget(dlg)
|
||||||
|
dlg.show()
|
||||||
|
|
||||||
|
# Select a non-current item
|
||||||
|
for i in range(dlg.list.count()):
|
||||||
|
item = dlg.list.item(i)
|
||||||
|
if item.data(Qt.UserRole) != dlg._current_id:
|
||||||
|
dlg.list.setCurrentItem(item)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Make revert raise to hit the except/critical message path.
|
||||||
|
def boom(*_a, **_k):
|
||||||
|
raise RuntimeError("nope")
|
||||||
|
|
||||||
|
monkeypatch.setattr(dlg._db, "revert_to_version", boom)
|
||||||
|
|
||||||
|
# Prepare a small helper that keeps trying to close an active modal box,
|
||||||
|
# but gives up after a bounded number of attempts.
|
||||||
|
def make_closer(max_tries=50, interval_ms=10):
|
||||||
|
tries = {"n": 0}
|
||||||
|
|
||||||
|
def closer():
|
||||||
|
tries["n"] += 1
|
||||||
|
w = QApplication.activeModalWidget()
|
||||||
|
if isinstance(w, QMessageBox):
|
||||||
|
# Prefer clicking the OK button if present; otherwise accept().
|
||||||
|
ok = w.button(QMessageBox.Ok)
|
||||||
|
if ok is not None:
|
||||||
|
ok.click()
|
||||||
|
else:
|
||||||
|
w.accept()
|
||||||
|
elif tries["n"] < max_tries:
|
||||||
|
QTimer.singleShot(interval_ms, closer)
|
||||||
|
|
||||||
|
return closer
|
||||||
|
|
||||||
|
# Schedule auto-close right before we trigger the modal dialog.
|
||||||
|
QTimer.singleShot(0, make_closer())
|
||||||
|
|
||||||
|
# Should show the critical box, which our timer will accept; _revert returns.
|
||||||
|
dlg._revert()
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,19 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, QPoint
|
from PySide6.QtCore import Qt, QPoint
|
||||||
from PySide6.QtGui import QImage, QColor, QKeyEvent, QTextCursor
|
from PySide6.QtGui import (
|
||||||
|
QImage,
|
||||||
|
QColor,
|
||||||
|
QKeyEvent,
|
||||||
|
QTextCursor,
|
||||||
|
QTextDocument,
|
||||||
|
QFont,
|
||||||
|
QTextCharFormat,
|
||||||
|
)
|
||||||
|
from PySide6.QtWidgets import QTextEdit
|
||||||
|
|
||||||
from bouquin.markdown_editor import MarkdownEditor
|
from bouquin.markdown_editor import MarkdownEditor
|
||||||
|
from bouquin.markdown_highlighter import MarkdownHighlighter
|
||||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -32,6 +43,15 @@ def editor(app, qtbot):
|
||||||
return ed
|
return ed
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def editor_hello(app):
|
||||||
|
tm = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||||
|
e = MarkdownEditor(tm)
|
||||||
|
e.setPlainText("hello")
|
||||||
|
e.moveCursor(QTextCursor.MoveOperation.End)
|
||||||
|
return e
|
||||||
|
|
||||||
|
|
||||||
def test_from_and_to_markdown_roundtrip(editor):
|
def test_from_and_to_markdown_roundtrip(editor):
|
||||||
md = "# Title\n\nThis is **bold** and _italic_ and ~~strike~~.\n\n- [ ] task\n- [x] done\n\n```\ncode\n```"
|
md = "# Title\n\nThis is **bold** and _italic_ and ~~strike~~.\n\n- [ ] task\n- [x] done\n\n```\ncode\n```"
|
||||||
editor.from_markdown(md)
|
editor.from_markdown(md)
|
||||||
|
|
@ -69,8 +89,8 @@ def test_insert_image_from_path(editor, tmp_path):
|
||||||
|
|
||||||
editor.insert_image_from_path(img)
|
editor.insert_image_from_path(img)
|
||||||
md = editor.to_markdown()
|
md = editor.to_markdown()
|
||||||
# Images are saved as base64 data URIs in markdown
|
# Accept either "image/png" or older "image/image/png" prefix
|
||||||
assert "data:image/image/png;base64" in md
|
assert "data:image/png;base64" in md or "data:image/image/png;base64" in md
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.gui
|
@pytest.mark.gui
|
||||||
|
|
@ -83,13 +103,10 @@ def test_checkbox_toggle_by_click(editor, qtbot):
|
||||||
|
|
||||||
# Click on the first character region to toggle
|
# Click on the first character region to toggle
|
||||||
c = editor.textCursor()
|
c = editor.textCursor()
|
||||||
from PySide6.QtGui import QTextCursor
|
|
||||||
|
|
||||||
c.movePosition(QTextCursor.StartOfBlock)
|
c.movePosition(QTextCursor.StartOfBlock)
|
||||||
editor.setTextCursor(c)
|
editor.setTextCursor(c)
|
||||||
r = editor.cursorRect()
|
r = editor.cursorRect()
|
||||||
center = r.center()
|
center = r.center()
|
||||||
# Send click slightly right to land within checkbox icon region
|
|
||||||
pos = QPoint(r.left() + 2, center.y())
|
pos = QPoint(r.left() + 2, center.y())
|
||||||
qtbot.mouseClick(editor.viewport(), Qt.LeftButton, pos=pos)
|
qtbot.mouseClick(editor.viewport(), Qt.LeftButton, pos=pos)
|
||||||
|
|
||||||
|
|
@ -164,7 +181,7 @@ def test_triple_backtick_autoexpands(editor, qtbot):
|
||||||
def test_toolbar_inserts_block_on_own_lines(editor, qtbot):
|
def test_toolbar_inserts_block_on_own_lines(editor, qtbot):
|
||||||
editor.from_markdown("hello")
|
editor.from_markdown("hello")
|
||||||
editor.moveCursor(QTextCursor.End)
|
editor.moveCursor(QTextCursor.End)
|
||||||
editor.apply_code() # </> action
|
editor.apply_code() # </> action inserts fenced code block
|
||||||
qtbot.wait(0)
|
qtbot.wait(0)
|
||||||
|
|
||||||
t = text(editor)
|
t = text(editor)
|
||||||
|
|
@ -270,3 +287,271 @@ def test_no_orphan_two_backticks_lines_after_edits(editor, qtbot):
|
||||||
|
|
||||||
# ensure there are no stray "``" lines
|
# ensure there are no stray "``" lines
|
||||||
assert not any(ln.strip() == "``" for ln in lines_keep(editor))
|
assert not any(ln.strip() == "``" for ln in lines_keep(editor))
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_at(block, pos):
|
||||||
|
"""Return a *copy* of the char format at pos so it doesn't dangle."""
|
||||||
|
layout = block.layout()
|
||||||
|
for fr in list(layout.formats()):
|
||||||
|
if fr.start <= pos < fr.start + fr.length:
|
||||||
|
return QTextCharFormat(fr.format)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def highlighter(app):
|
||||||
|
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||||
|
doc = QTextDocument()
|
||||||
|
hl = MarkdownHighlighter(doc, themes)
|
||||||
|
return doc, hl
|
||||||
|
|
||||||
|
|
||||||
|
def test_headings_and_inline_styles(highlighter):
|
||||||
|
doc, hl = highlighter
|
||||||
|
doc.setPlainText("# H1\n## H2\n### H3\n***b+i*** **b** *i* __b__ _i_\n")
|
||||||
|
hl.rehighlight()
|
||||||
|
|
||||||
|
# H1: '#' markers hidden (very small size), text bold/larger
|
||||||
|
b0 = doc.findBlockByNumber(0)
|
||||||
|
fmt_marker = _fmt_at(b0, 0)
|
||||||
|
assert fmt_marker is not None
|
||||||
|
assert fmt_marker.fontPointSize() <= 0.2 # marker hidden
|
||||||
|
|
||||||
|
fmt_h1_text = _fmt_at(b0, 2)
|
||||||
|
assert fmt_h1_text is not None
|
||||||
|
assert fmt_h1_text.fontWeight() == QFont.Weight.Bold
|
||||||
|
|
||||||
|
# Bold-italic precedence
|
||||||
|
b3 = doc.findBlockByNumber(3)
|
||||||
|
line = b3.text()
|
||||||
|
triple = "***b+i***"
|
||||||
|
start = line.find(triple)
|
||||||
|
assert start != -1
|
||||||
|
pos_inside = start + 3 # skip the *** markers, land on 'b'
|
||||||
|
f_bi_inner = _fmt_at(b3, pos_inside)
|
||||||
|
assert f_bi_inner is not None
|
||||||
|
assert f_bi_inner.fontWeight() == QFont.Weight.Bold and f_bi_inner.fontItalic()
|
||||||
|
|
||||||
|
# Bold without triples
|
||||||
|
f_b = _fmt_at(b3, b3.text().find("**b**") + 2)
|
||||||
|
assert f_b.fontWeight() == QFont.Weight.Bold
|
||||||
|
|
||||||
|
# Italic without bold
|
||||||
|
f_i = _fmt_at(b3, b3.text().rfind("_i_") + 1)
|
||||||
|
assert f_i.fontItalic()
|
||||||
|
|
||||||
|
|
||||||
|
def test_code_blocks_inline_code_and_strike_overlay(highlighter):
|
||||||
|
doc, hl = highlighter
|
||||||
|
doc.setPlainText("```\n**B**\n```\nX ~~**boom**~~ Y `code`\n")
|
||||||
|
hl.rehighlight()
|
||||||
|
|
||||||
|
# Fence and inner lines use code block format
|
||||||
|
fence = doc.findBlockByNumber(0)
|
||||||
|
inner = doc.findBlockByNumber(1)
|
||||||
|
|
||||||
|
fmt_fence = _fmt_at(fence, 0)
|
||||||
|
fmt_inner = _fmt_at(inner, 0)
|
||||||
|
assert fmt_fence is not None and fmt_inner is not None
|
||||||
|
|
||||||
|
# check key properties
|
||||||
|
assert fmt_inner.fontFixedPitch() or fmt_inner.font().styleHint() == QFont.Monospace
|
||||||
|
assert fmt_inner.background() == hl.code_block_format.background()
|
||||||
|
|
||||||
|
# Inline code uses fixed pitch and hides the backticks
|
||||||
|
inline = doc.findBlockByNumber(3)
|
||||||
|
start = inline.text().find("`code`")
|
||||||
|
|
||||||
|
fmt_inline_char = _fmt_at(inline, start + 1)
|
||||||
|
fmt_inline_tick = _fmt_at(inline, start)
|
||||||
|
assert fmt_inline_char is not None and fmt_inline_tick is not None
|
||||||
|
assert fmt_inline_char.fontFixedPitch()
|
||||||
|
assert fmt_inline_tick.fontPointSize() <= 0.2 # backtick hidden
|
||||||
|
|
||||||
|
boom_pos = inline.text().find("boom")
|
||||||
|
fmt_boom = _fmt_at(inline, boom_pos)
|
||||||
|
assert fmt_boom is not None
|
||||||
|
assert fmt_boom.fontStrikeOut() and fmt_boom.fontWeight() == QFont.Weight.Bold
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_change_rehighlight(highlighter):
|
||||||
|
doc, hl = highlighter
|
||||||
|
hl._on_theme_changed()
|
||||||
|
doc.setPlainText("`x`")
|
||||||
|
hl.rehighlight()
|
||||||
|
b = doc.firstBlock()
|
||||||
|
fmt = _fmt_at(b, 1)
|
||||||
|
assert fmt is not None and fmt.fontFixedPitch()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def hl_light(app):
|
||||||
|
# Light theme path (covers lines ~74-75 in _on_theme_changed)
|
||||||
|
tm = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||||
|
doc = QTextDocument()
|
||||||
|
hl = MarkdownHighlighter(doc, tm)
|
||||||
|
return doc, hl
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def hl_light_edit(app, qtbot):
|
||||||
|
tm = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
||||||
|
doc = QTextDocument()
|
||||||
|
edit = QTextEdit() # <-- give the doc a layout
|
||||||
|
edit.setDocument(doc)
|
||||||
|
qtbot.addWidget(edit)
|
||||||
|
edit.show()
|
||||||
|
qtbot.wait(10) # let Qt build the layouts
|
||||||
|
hl = MarkdownHighlighter(doc, tm)
|
||||||
|
return doc, hl, edit
|
||||||
|
|
||||||
|
|
||||||
|
def fmt(doc, block_no, pos):
|
||||||
|
"""Return the QTextCharFormat at character position `pos` in the given block."""
|
||||||
|
b = doc.findBlockByNumber(block_no)
|
||||||
|
it = b.begin()
|
||||||
|
off = 0
|
||||||
|
while not it.atEnd():
|
||||||
|
frag = it.fragment()
|
||||||
|
length = frag.length() # includes chars in this fragment
|
||||||
|
if off + length > pos:
|
||||||
|
return frag.charFormat()
|
||||||
|
off += length
|
||||||
|
it = it.next()
|
||||||
|
# Fallback (shouldn't happen in our tests)
|
||||||
|
cf = QTextCharFormat()
|
||||||
|
return cf
|
||||||
|
|
||||||
|
|
||||||
|
def test_light_palette_specific_colors(hl_light_edit, qtbot):
|
||||||
|
doc, hl, edit = hl_light_edit
|
||||||
|
doc.setPlainText("```\ncode\n```")
|
||||||
|
hl.rehighlight()
|
||||||
|
# the second block ("code") is the one inside the fenced block
|
||||||
|
b_code = doc.firstBlock().next()
|
||||||
|
fmt = _fmt_at(b_code, 0)
|
||||||
|
assert fmt is not None and fmt.background().style() != 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_code_block_light_colors(hl_light):
|
||||||
|
"""Ensure code block colors use the light palette (covers 74-75)."""
|
||||||
|
doc, hl = hl_light
|
||||||
|
doc.setPlainText("```\ncode\n```")
|
||||||
|
hl.rehighlight()
|
||||||
|
# Background is a light gray and text is dark/black-ish in light theme
|
||||||
|
bg = hl.code_block_format.background().color()
|
||||||
|
fg = hl.code_block_format.foreground().color()
|
||||||
|
assert bg.red() >= 240 and bg.green() >= 240 and bg.blue() >= 240
|
||||||
|
assert fg.red() < 40 and fg.green() < 40 and fg.blue() < 40
|
||||||
|
|
||||||
|
|
||||||
|
def test_end_guard_skips_italic_followed_by_marker(hl_light):
|
||||||
|
"""
|
||||||
|
Triggers the end-following guard for italic (line ~208), e.g. '*i**'.
|
||||||
|
"""
|
||||||
|
doc, hl = hl_light
|
||||||
|
doc.setPlainText("*i**")
|
||||||
|
hl.rehighlight()
|
||||||
|
# The 'i' should not get italic due to the guard (closing '*' followed by '*')
|
||||||
|
f = fmt(doc, 0, 1)
|
||||||
|
assert not f.fontItalic()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.gui
|
||||||
|
def test_char_rect_at_edges_and_click_checkbox(editor, qtbot):
|
||||||
|
"""
|
||||||
|
Exercises char_rect_at()-style logic and checkbox toggle via click
|
||||||
|
to push coverage on geometry-dependent paths.
|
||||||
|
"""
|
||||||
|
editor.from_markdown("- [ ] task")
|
||||||
|
c = editor.textCursor()
|
||||||
|
c.movePosition(QTextCursor.StartOfBlock)
|
||||||
|
editor.setTextCursor(c)
|
||||||
|
r = editor.cursorRect()
|
||||||
|
qtbot.mouseClick(editor.viewport(), Qt.LeftButton, pos=r.center())
|
||||||
|
assert "☑" in editor.toPlainText()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.gui
|
||||||
|
def test_heading_apply_levels_and_inline_styles(editor):
|
||||||
|
editor.setPlainText("hello")
|
||||||
|
editor.selectAll()
|
||||||
|
editor.apply_heading(18) # H2
|
||||||
|
assert editor.toPlainText().startswith("## ")
|
||||||
|
editor.selectAll()
|
||||||
|
editor.apply_heading(12) # normal
|
||||||
|
assert not editor.toPlainText().startswith("#")
|
||||||
|
|
||||||
|
# Bold/italic/strike together to nudge style branches
|
||||||
|
editor.setPlainText("hi")
|
||||||
|
editor.selectAll()
|
||||||
|
editor.apply_weight()
|
||||||
|
editor.apply_italic()
|
||||||
|
editor.apply_strikethrough()
|
||||||
|
md = editor.to_markdown()
|
||||||
|
assert "**" in md and "*" in md and "~~" in md
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.gui
|
||||||
|
def test_insert_image_and_markdown_roundtrip(editor, tmp_path):
|
||||||
|
img = tmp_path / "p.png"
|
||||||
|
qimg = QImage(2, 2, QImage.Format_RGBA8888)
|
||||||
|
qimg.fill(QColor(255, 0, 0))
|
||||||
|
assert qimg.save(str(img))
|
||||||
|
editor.insert_image_from_path(img)
|
||||||
|
# At least a replacement char shows in the plain-text view
|
||||||
|
assert "\ufffc" in editor.toPlainText()
|
||||||
|
# And markdown contains a data: URI
|
||||||
|
assert "data:image" in editor.to_markdown()
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_italic_and_strike(editor):
|
||||||
|
# Italic: insert markers with no selection and place caret in between
|
||||||
|
editor.setPlainText("x")
|
||||||
|
editor.moveCursor(QTextCursor.MoveOperation.End)
|
||||||
|
editor.apply_italic()
|
||||||
|
assert editor.toPlainText().endswith("x**")
|
||||||
|
assert editor.textCursor().position() == len(editor.toPlainText()) - 1
|
||||||
|
|
||||||
|
# With selection toggling
|
||||||
|
editor.setPlainText("*y*")
|
||||||
|
c = editor.textCursor()
|
||||||
|
c.movePosition(QTextCursor.MoveOperation.End, QTextCursor.MoveMode.MoveAnchor)
|
||||||
|
c.movePosition(QTextCursor.MoveOperation.Start, QTextCursor.MoveMode.KeepAnchor)
|
||||||
|
editor.setTextCursor(c)
|
||||||
|
editor.apply_italic()
|
||||||
|
assert editor.toPlainText() == "y"
|
||||||
|
|
||||||
|
# Strike: no selection case inserts placeholder and moves caret
|
||||||
|
editor.setPlainText("z")
|
||||||
|
editor.moveCursor(QTextCursor.MoveOperation.End)
|
||||||
|
editor.apply_strikethrough()
|
||||||
|
assert editor.toPlainText().endswith("z~~~~")
|
||||||
|
assert editor.textCursor().position() == len(editor.toPlainText()) - 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_code_inline_block_navigation(editor):
|
||||||
|
# Selection case -> fenced block around selection
|
||||||
|
editor.setPlainText("code")
|
||||||
|
c = editor.textCursor()
|
||||||
|
c.select(QTextCursor.SelectionType.Document)
|
||||||
|
editor.setTextCursor(c)
|
||||||
|
editor.apply_code()
|
||||||
|
assert "```\ncode\n```\n" in editor.toPlainText()
|
||||||
|
|
||||||
|
# No selection, at EOF with no following block -> creates block and extra newline path
|
||||||
|
editor.setPlainText("before")
|
||||||
|
editor.moveCursor(QTextCursor.MoveOperation.End)
|
||||||
|
editor.apply_code()
|
||||||
|
t = editor.toPlainText()
|
||||||
|
assert t.endswith("before\n```\n\n```\n")
|
||||||
|
# Caret should be inside the code block blank line
|
||||||
|
assert editor.textCursor().position() == len("before\n") + 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_image_from_path_invalid_returns(editor_hello, tmp_path):
|
||||||
|
# Non-existent path should just return (early exit)
|
||||||
|
bad = tmp_path / "missing.png"
|
||||||
|
editor_hello.insert_image_from_path(bad)
|
||||||
|
# Nothing new added
|
||||||
|
assert editor_hello.toPlainText() == "hello"
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import pytest
|
import pytest
|
||||||
import bouquin.settings_dialog as sd
|
|
||||||
|
|
||||||
from bouquin.db import DBManager, DBConfig
|
from bouquin.db import DBManager, DBConfig
|
||||||
from bouquin.key_prompt import KeyPrompt
|
from bouquin.key_prompt import KeyPrompt
|
||||||
|
import bouquin.settings_dialog as sd
|
||||||
from bouquin.settings_dialog import SettingsDialog
|
from bouquin.settings_dialog import SettingsDialog
|
||||||
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
from bouquin.theme import ThemeManager, ThemeConfig, Theme
|
||||||
|
from bouquin.settings import get_settings
|
||||||
from PySide6.QtCore import QTimer
|
from PySide6.QtCore import QTimer
|
||||||
from PySide6.QtWidgets import QApplication, QMessageBox, QWidget
|
from PySide6.QtWidgets import QApplication, QMessageBox, QWidget, QDialog
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.gui
|
@pytest.mark.gui
|
||||||
|
|
@ -225,3 +226,207 @@ def test_settings_browse_sets_path(qtbot, app, tmp_path, fresh_db, monkeypatch):
|
||||||
)
|
)
|
||||||
dlg._browse()
|
dlg._browse()
|
||||||
assert dlg.path_edit.text().endswith("new_file.db")
|
assert dlg.path_edit.text().endswith("new_file.db")
|
||||||
|
|
||||||
|
|
||||||
|
class _Host(QWidget):
|
||||||
|
def __init__(self, themes):
|
||||||
|
super().__init__()
|
||||||
|
self.themes = themes
|
||||||
|
|
||||||
|
|
||||||
|
def _make_host_and_dialog(tmp_db_cfg, fresh_db):
|
||||||
|
# Create a real ThemeManager so we don't have to fake anything here
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
themes = ThemeManager(QApplication.instance(), ThemeConfig(theme=Theme.SYSTEM))
|
||||||
|
host = _Host(themes)
|
||||||
|
dlg = SettingsDialog(tmp_db_cfg, fresh_db, parent=host)
|
||||||
|
return host, dlg
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_qsettings_theme_to_system():
|
||||||
|
"""Make the radio-button default deterministic across the full suite."""
|
||||||
|
s = get_settings()
|
||||||
|
s.clear()
|
||||||
|
s.setValue("ui/theme", "system")
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_theme_radio_is_system_checked(qtbot, tmp_db_cfg, fresh_db):
|
||||||
|
# Ensure no stray theme value from previous tests
|
||||||
|
_clear_qsettings_theme_to_system()
|
||||||
|
|
||||||
|
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
|
||||||
|
qtbot.addWidget(host)
|
||||||
|
qtbot.addWidget(dlg)
|
||||||
|
# With fresh settings (system), the 'system' radio should be selected
|
||||||
|
assert dlg.theme_system.isChecked()
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_selects_system_when_no_explicit_choice(
|
||||||
|
qtbot, tmp_db_cfg, fresh_db, monkeypatch
|
||||||
|
):
|
||||||
|
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
|
||||||
|
qtbot.addWidget(dlg)
|
||||||
|
# Ensure neither dark nor light is checked so SYSTEM path is taken
|
||||||
|
dlg.theme_dark.setChecked(False)
|
||||||
|
dlg.theme_light.setChecked(False)
|
||||||
|
# This should not raise
|
||||||
|
dlg._save()
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_with_dark_selected(qtbot, tmp_db_cfg, fresh_db):
|
||||||
|
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
|
||||||
|
qtbot.addWidget(dlg)
|
||||||
|
dlg.theme_dark.setChecked(True)
|
||||||
|
dlg._save()
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_key_cancel_first_prompt(qtbot, tmp_db_cfg, fresh_db, monkeypatch):
|
||||||
|
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
|
||||||
|
qtbot.addWidget(dlg)
|
||||||
|
|
||||||
|
class P1:
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def exec(self):
|
||||||
|
return QDialog.Rejected
|
||||||
|
|
||||||
|
def key(self):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", P1)
|
||||||
|
dlg._change_key() # returns early
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_key_cancel_second_prompt(qtbot, tmp_db_cfg, fresh_db, monkeypatch):
|
||||||
|
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
|
||||||
|
qtbot.addWidget(dlg)
|
||||||
|
|
||||||
|
class P1:
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def exec(self):
|
||||||
|
return QDialog.Accepted
|
||||||
|
|
||||||
|
def key(self):
|
||||||
|
return "abc"
|
||||||
|
|
||||||
|
class P2:
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def exec(self):
|
||||||
|
return QDialog.Rejected
|
||||||
|
|
||||||
|
def key(self):
|
||||||
|
return "abc"
|
||||||
|
|
||||||
|
# First call yields P1, second yields P2
|
||||||
|
seq = [P1, P2]
|
||||||
|
|
||||||
|
def _factory(*a, **k):
|
||||||
|
cls = seq.pop(0)
|
||||||
|
return cls(*a, **k)
|
||||||
|
|
||||||
|
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", _factory)
|
||||||
|
dlg._change_key() # returns early
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_key_empty_and_exception_paths(qtbot, tmp_db_cfg, fresh_db, monkeypatch):
|
||||||
|
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
|
||||||
|
qtbot.addWidget(dlg)
|
||||||
|
|
||||||
|
# Timer that auto-accepts any modal QMessageBox so we don't hang.
|
||||||
|
def _pump_boxes():
|
||||||
|
# Try both the active modal and the general top-level enumeration
|
||||||
|
m = QApplication.activeModalWidget()
|
||||||
|
if isinstance(m, QMessageBox):
|
||||||
|
m.accept()
|
||||||
|
for w in QApplication.topLevelWidgets():
|
||||||
|
if isinstance(w, QMessageBox):
|
||||||
|
w.accept()
|
||||||
|
|
||||||
|
timer = QTimer()
|
||||||
|
timer.setInterval(10)
|
||||||
|
timer.timeout.connect(_pump_boxes)
|
||||||
|
timer.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
|
||||||
|
class P1:
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def exec(self):
|
||||||
|
return QDialog.Accepted
|
||||||
|
|
||||||
|
def key(self):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
class P2:
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def exec(self):
|
||||||
|
return QDialog.Accepted
|
||||||
|
|
||||||
|
def key(self):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
seq = [P1, P2, P1, P2]
|
||||||
|
|
||||||
|
def _factory(*a, **k):
|
||||||
|
cls = seq.pop(0)
|
||||||
|
return cls(*a, **k)
|
||||||
|
|
||||||
|
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", _factory)
|
||||||
|
# First run triggers empty-key warning path and return (auto-closed)
|
||||||
|
dlg._change_key()
|
||||||
|
|
||||||
|
# Now make rekey() raise to hit the except block (critical dialog)
|
||||||
|
def boom(*a, **k):
|
||||||
|
raise RuntimeError("nope")
|
||||||
|
|
||||||
|
dlg._db.rekey = boom
|
||||||
|
|
||||||
|
# Return a non-empty matching key twice
|
||||||
|
class P3:
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def exec(self):
|
||||||
|
return QDialog.Accepted
|
||||||
|
|
||||||
|
def key(self):
|
||||||
|
return "secret"
|
||||||
|
|
||||||
|
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", lambda *a, **k: P3())
|
||||||
|
dlg._change_key()
|
||||||
|
finally:
|
||||||
|
timer.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_key_btn_clicked_cancel_flow(qtbot, tmp_db_cfg, fresh_db, monkeypatch):
|
||||||
|
host, dlg = _make_host_and_dialog(tmp_db_cfg, fresh_db)
|
||||||
|
qtbot.addWidget(dlg)
|
||||||
|
|
||||||
|
# Make sure we start with no key saved so it will prompt
|
||||||
|
dlg.key = ""
|
||||||
|
|
||||||
|
class P1:
|
||||||
|
def __init__(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def exec(self):
|
||||||
|
return QDialog.Rejected
|
||||||
|
|
||||||
|
def key(self):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
monkeypatch.setattr("bouquin.settings_dialog.KeyPrompt", P1)
|
||||||
|
|
||||||
|
dlg.save_key_btn.setChecked(True) # toggles and calls handler
|
||||||
|
# Handler should have undone the checkbox back to False
|
||||||
|
assert not dlg.save_key_btn.isChecked()
|
||||||
|
|
|
||||||
7
tests/test_strings.py
Normal file
7
tests/test_strings.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
from bouquin import strings
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_strings_uses_system_locale_and_fallback():
|
||||||
|
# pass a bogus locale to trigger fallback-to-default
|
||||||
|
strings.load_strings("zz")
|
||||||
|
assert strings._("next") # key exists in base translations
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import pytest
|
import pytest
|
||||||
from PySide6.QtGui import QPalette
|
from PySide6.QtGui import QPalette
|
||||||
|
from PySide6.QtWidgets import QApplication, QCalendarWidget, QWidget
|
||||||
|
|
||||||
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
||||||
|
|
||||||
|
|
@ -19,3 +20,35 @@ def test_theme_manager_system_roundtrip(app, qtbot):
|
||||||
cfg = ThemeConfig(theme=Theme.SYSTEM)
|
cfg = ThemeConfig(theme=Theme.SYSTEM)
|
||||||
mgr = ThemeManager(app, cfg)
|
mgr = ThemeManager(app, cfg)
|
||||||
mgr.apply(cfg.theme)
|
mgr.apply(cfg.theme)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_themes(theme):
|
||||||
|
app = QApplication.instance()
|
||||||
|
return ThemeManager(app, ThemeConfig(theme=theme))
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_and_restyle_calendar_and_overlay(qtbot):
|
||||||
|
themes = _make_themes(Theme.DARK)
|
||||||
|
cal = QCalendarWidget()
|
||||||
|
ov = QWidget()
|
||||||
|
ov.setObjectName("LockOverlay")
|
||||||
|
qtbot.addWidget(cal)
|
||||||
|
qtbot.addWidget(ov)
|
||||||
|
|
||||||
|
themes.register_calendar(cal)
|
||||||
|
themes.register_lock_overlay(ov)
|
||||||
|
|
||||||
|
# Force a restyle pass (covers the "is not None" branches)
|
||||||
|
themes._restyle_registered()
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_dark_styles_cover_css_paths(qtbot):
|
||||||
|
themes = _make_themes(Theme.DARK)
|
||||||
|
cal = QCalendarWidget()
|
||||||
|
ov = QWidget()
|
||||||
|
ov.setObjectName("LockOverlay")
|
||||||
|
qtbot.addWidget(cal)
|
||||||
|
qtbot.addWidget(ov)
|
||||||
|
|
||||||
|
themes.register_calendar(cal) # drives _apply_calendar_theme (dark path)
|
||||||
|
themes.register_lock_overlay(ov) # drives _apply_lock_overlay_theme (dark path)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue