412 lines
13 KiB
Python
412 lines
13 KiB
Python
import pytest
|
|
import bouquin.main_window as mwmod
|
|
from bouquin.main_window import MainWindow
|
|
from bouquin.theme import Theme, ThemeConfig, ThemeManager
|
|
from bouquin.settings import get_settings
|
|
from bouquin.key_prompt import KeyPrompt
|
|
from PySide6.QtCore import QEvent, QDate, QTimer
|
|
from PySide6.QtWidgets import QTableView, QApplication
|
|
|
|
|
|
@pytest.mark.gui
|
|
def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db):
|
|
s = get_settings()
|
|
s.setValue("db/path", str(tmp_db_cfg.path))
|
|
s.setValue("db/key", tmp_db_cfg.key)
|
|
s.setValue("ui/idle_minutes", 0)
|
|
s.setValue("ui/theme", "light")
|
|
s.setValue("ui/move_todos", True)
|
|
|
|
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
|
w = MainWindow(themes=themes)
|
|
qtbot.addWidget(w)
|
|
w.show()
|
|
|
|
date = QDate.currentDate().toString("yyyy-MM-dd")
|
|
w._load_selected_date(date)
|
|
w.editor.from_markdown("hello **world**")
|
|
w._on_text_changed()
|
|
qtbot.wait(5500) # let the 5s autosave QTimer fire
|
|
assert "world" in fresh_db.get_entry(date)
|
|
|
|
w.search.search.setText("world")
|
|
qtbot.wait(50)
|
|
assert not w.search.results.isHidden()
|
|
|
|
w._sync_toolbar()
|
|
w._adjust_day(-1) # previous day
|
|
w._adjust_day(+1) # next day
|
|
|
|
# Auto-accept the unlock KeyPrompt with the correct key
|
|
def _auto_accept_keyprompt():
|
|
for wdg in QApplication.topLevelWidgets():
|
|
if isinstance(wdg, KeyPrompt):
|
|
wdg.edit.setText(tmp_db_cfg.key)
|
|
wdg.accept()
|
|
|
|
w._enter_lock()
|
|
QTimer.singleShot(0, _auto_accept_keyprompt)
|
|
w._on_unlock_clicked()
|
|
qtbot.wait(50) # let the nested event loop process the acceptance
|
|
|
|
|
|
def test_load_yesterday_todos_moves_items(qtbot, app, tmp_db_cfg, fresh_db):
|
|
s = get_settings()
|
|
s.setValue("db/path", str(tmp_db_cfg.path))
|
|
s.setValue("db/key", tmp_db_cfg.key)
|
|
s.setValue("ui/move_todos", True)
|
|
s.setValue("ui/theme", "light")
|
|
|
|
y = QDate.currentDate().addDays(-1).toString("yyyy-MM-dd")
|
|
fresh_db.save_new_version(y, "- [ ] carry me\n- [x] done", "seed")
|
|
|
|
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
|
|
|
w = MainWindow(themes=themes)
|
|
qtbot.addWidget(w)
|
|
w.show()
|
|
|
|
w._load_yesterday_todos()
|
|
|
|
assert "carry me" in w.editor.to_markdown()
|
|
y_txt = fresh_db.get_entry(y)
|
|
assert "carry me" not in y_txt or "- [ ]" not in y_txt
|
|
|
|
|
|
@pytest.mark.gui
|
|
def test_open_docs_and_bugs_warning(qtbot, app, fresh_db, tmp_path, monkeypatch):
|
|
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
|
w = MainWindow(themes=themes)
|
|
qtbot.addWidget(w)
|
|
w.show()
|
|
|
|
# Force QDesktopServices.openUrl to fail so the warning path executes
|
|
called = {"docs": False, "bugs": False}
|
|
|
|
def fake_open(url):
|
|
# return False to force warning path
|
|
return False
|
|
|
|
mwmod.QDesktopServices.openUrl = fake_open # minimal monkeypatch
|
|
|
|
class DummyMB:
|
|
@staticmethod
|
|
def warning(parent, title, text, *rest):
|
|
t = str(text)
|
|
if "wiki" in t:
|
|
called["docs"] = True
|
|
if "forms/mig5/contact" in t or "contact" in t:
|
|
called["bugs"] = True
|
|
return 0
|
|
|
|
monkeypatch.setattr(mwmod, "QMessageBox", DummyMB, raising=True) # capture warnings
|
|
|
|
# Trigger both actions
|
|
w._open_docs()
|
|
w._open_bugs()
|
|
assert called["docs"] and called["bugs"]
|
|
|
|
|
|
@pytest.mark.gui
|
|
def test_export_success_and_error(qtbot, app, fresh_db, tmp_path, monkeypatch):
|
|
# Seed some content
|
|
fresh_db.save_new_version("2001-01-01", "alpha", "n1")
|
|
fresh_db.save_new_version("2001-01-02", "beta", "n2")
|
|
|
|
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
|
from bouquin.settings import get_settings
|
|
|
|
s = get_settings()
|
|
s.setValue("db/path", str(fresh_db.cfg.path))
|
|
s.setValue("db/key", fresh_db.cfg.key)
|
|
s.setValue("ui/idle_minutes", 0)
|
|
s.setValue("ui/theme", "light")
|
|
w = MainWindow(themes=themes)
|
|
qtbot.addWidget(w)
|
|
w.show()
|
|
|
|
# Save as Markdown without extension -> should append .md and write file
|
|
dest1 = tmp_path / "export_one" # no suffix
|
|
|
|
def fake_save1(*a, **k):
|
|
return str(dest1), "Markdown (*.md)"
|
|
|
|
monkeypatch.setattr(mwmod.QFileDialog, "getSaveFileName", staticmethod(fake_save1))
|
|
|
|
info_log = {"ok": False}
|
|
|
|
# Auto-accept the warning dialog
|
|
monkeypatch.setattr(
|
|
mwmod.QMessageBox, "exec", lambda self: mwmod.QMessageBox.Yes, raising=False
|
|
)
|
|
info_log = {"ok": False}
|
|
monkeypatch.setattr(
|
|
mwmod.QMessageBox,
|
|
"information",
|
|
staticmethod(lambda *a, **k: info_log.__setitem__("ok", True) or 0),
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
mwmod.QMessageBox,
|
|
"critical",
|
|
staticmethod(
|
|
lambda *a, **k: (_ for _ in ()).throw(AssertionError("Unexpected critical"))
|
|
),
|
|
raising=False,
|
|
)
|
|
w._export()
|
|
assert dest1.with_suffix(".md").exists()
|
|
assert info_log["ok"]
|
|
|
|
# Now force an exception during export to hit error branch (patch the window's DB)
|
|
def boom():
|
|
raise RuntimeError("explode")
|
|
|
|
monkeypatch.setattr(w.db, "get_all_entries", boom, raising=False)
|
|
|
|
# Different filename to avoid overwriting
|
|
dest2 = tmp_path / "export_two"
|
|
|
|
def fake_save2(*a, **k):
|
|
return str(dest2), "CSV (*.csv)"
|
|
|
|
monkeypatch.setattr(mwmod.QFileDialog, "getSaveFileName", staticmethod(fake_save2))
|
|
|
|
errs = {"hit": False}
|
|
# Auto-accept the warning dialog and capture the error message
|
|
monkeypatch.setattr(
|
|
mwmod.QMessageBox, "exec", lambda self: mwmod.QMessageBox.Yes, raising=False
|
|
)
|
|
monkeypatch.setattr(
|
|
mwmod.QMessageBox, "information", staticmethod(lambda *a, **k: 0), raising=False
|
|
)
|
|
monkeypatch.setattr(
|
|
mwmod.QMessageBox,
|
|
"critical",
|
|
staticmethod(lambda *a, **k: errs.__setitem__("hit", True) or 0),
|
|
raising=False,
|
|
)
|
|
w._export()
|
|
assert errs["hit"]
|
|
|
|
|
|
@pytest.mark.gui
|
|
def test_backup_path(qtbot, app, fresh_db, tmp_path, monkeypatch):
|
|
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
|
|
|
# wire DB settings the window reads
|
|
from bouquin.settings import get_settings
|
|
|
|
s = get_settings()
|
|
s.setValue("db/path", str(fresh_db.cfg.path))
|
|
s.setValue("db/key", fresh_db.cfg.key)
|
|
s.setValue("ui/idle_minutes", 0)
|
|
s.setValue("ui/theme", "light")
|
|
|
|
w = MainWindow(themes=themes)
|
|
qtbot.addWidget(w)
|
|
w.show()
|
|
|
|
# Pretend user picked a filename with no suffix -> .db should be appended
|
|
dest = tmp_path / "backupfile"
|
|
monkeypatch.setattr(
|
|
mwmod.QFileDialog,
|
|
"getSaveFileName",
|
|
staticmethod(lambda *a, **k: (str(dest), "SQLCipher (*.db)")),
|
|
raising=False,
|
|
)
|
|
|
|
# Avoid any modal dialogs and record the success message
|
|
hit = {"info": False, "text": None}
|
|
monkeypatch.setattr(
|
|
mwmod.QMessageBox,
|
|
"information",
|
|
staticmethod(
|
|
lambda parent, title, text, *a, **k: (
|
|
hit.__setitem__("info", True),
|
|
hit.__setitem__("text", str(text)),
|
|
0,
|
|
)[-1]
|
|
),
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
mwmod.QMessageBox, "critical", staticmethod(lambda *a, **k: 0), raising=False
|
|
)
|
|
|
|
# Stub the *export* itself to be instant and non-blocking
|
|
called = {"path": None}
|
|
monkeypatch.setattr(
|
|
w.db, "export_sqlcipher", lambda p: called.__setitem__("path", p), raising=False
|
|
)
|
|
|
|
w._backup()
|
|
|
|
# Assertions: suffix added, export invoked, success toast shown
|
|
assert called["path"] == str(dest.with_suffix(".db"))
|
|
assert hit["info"]
|
|
assert str(dest.with_suffix(".db")) in hit["text"]
|
|
|
|
|
|
@pytest.mark.gui
|
|
def test_close_tab_edges_and_autosave(qtbot, app, fresh_db, monkeypatch):
|
|
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
|
from bouquin.settings import get_settings
|
|
|
|
s = get_settings()
|
|
s.setValue("db/path", str(fresh_db.cfg.path))
|
|
s.setValue("db/key", fresh_db.cfg.key)
|
|
s.setValue("ui/idle_minutes", 0)
|
|
s.setValue("ui/theme", "light")
|
|
w = MainWindow(themes=themes)
|
|
qtbot.addWidget(w)
|
|
w.show()
|
|
|
|
# Create exactly one extra tab (there is already one from __init__)
|
|
d1 = QDate(2020, 1, 1)
|
|
w._open_date_in_tab(d1)
|
|
assert w.tab_widget.count() == 2
|
|
|
|
# Close one tab: should call _save_editor_content on its editor
|
|
saved = {"called": False}
|
|
|
|
def fake_save_editor(editor):
|
|
saved["called"] = True
|
|
|
|
monkeypatch.setattr(w, "_save_editor_content", fake_save_editor, raising=True)
|
|
w._close_tab(0)
|
|
assert saved["called"]
|
|
# Now only one tab remains; closing should no-op
|
|
count_before = w.tab_widget.count()
|
|
w._close_tab(0)
|
|
assert w.tab_widget.count() == count_before
|
|
monkeypatch.delattr(w, "_save_editor_content", raising=False)
|
|
|
|
|
|
@pytest.mark.gui
|
|
def test_restore_window_position_variants(qtbot, app, fresh_db, monkeypatch):
|
|
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
|
w = MainWindow(themes=themes)
|
|
qtbot.addWidget(w)
|
|
w.show()
|
|
|
|
# Case A: no geometry stored -> should call _move_to_cursor_screen_center
|
|
moved = {"hit": False}
|
|
monkeypatch.setattr(
|
|
w,
|
|
"_move_to_cursor_screen_center",
|
|
lambda: moved.__setitem__("hit", True),
|
|
raising=True,
|
|
)
|
|
# clear any stored geometry
|
|
w.settings.remove("main/geometry")
|
|
w.settings.remove("main/windowState")
|
|
w.settings.remove("main/maximized")
|
|
w._restore_window_position()
|
|
assert moved["hit"]
|
|
|
|
# Case B: geometry present but off-screen -> fallback to move_to_cursor
|
|
moved["hit"] = False
|
|
# Save a valid geometry then lie that it's offscreen
|
|
geom = w.saveGeometry()
|
|
w.settings.setValue("main/geometry", geom)
|
|
w.settings.setValue("main/windowState", w.saveState())
|
|
w.settings.setValue("main/maximized", False)
|
|
monkeypatch.setattr(w, "_rect_on_any_screen", lambda r: False, raising=True)
|
|
w._restore_window_position()
|
|
assert moved["hit"]
|
|
|
|
# Case C: was_max True triggers showMaximized via QTimer.singleShot
|
|
called = {"max": False}
|
|
monkeypatch.setattr(
|
|
w, "showMaximized", lambda: called.__setitem__("max", True), raising=True
|
|
)
|
|
monkeypatch.setattr(
|
|
mwmod.QTimer, "singleShot", staticmethod(lambda _ms, f: f()), raising=False
|
|
)
|
|
w.settings.setValue("main/maximized", True)
|
|
w._restore_window_position()
|
|
assert called["max"]
|
|
|
|
|
|
@pytest.mark.gui
|
|
def test_calendar_pos_mapping_and_context_menu(qtbot, app, fresh_db, monkeypatch):
|
|
# Seed DB so refresh marks does something
|
|
fresh_db.save_new_version("2021-08-15", "note", "")
|
|
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
|
w = MainWindow(themes=themes)
|
|
qtbot.addWidget(w)
|
|
w.show()
|
|
|
|
# Show a month with leading days
|
|
qd = QDate(2021, 8, 15)
|
|
w.calendar.setSelectedDate(qd)
|
|
|
|
# Grab the internal table view and pick a couple of indices
|
|
view = w.calendar.findChild(QTableView, "qt_calendar_calendarview")
|
|
model = view.model()
|
|
|
|
# Find the first index belonging to current month (day == 1)
|
|
first_idx = None
|
|
for r in range(model.rowCount()):
|
|
for c in range(model.columnCount()):
|
|
if model.index(r, c).data() == 1:
|
|
first_idx = model.index(r, c)
|
|
break
|
|
if first_idx:
|
|
break
|
|
|
|
assert first_idx is not None
|
|
|
|
# A cell before 'first_idx' should map to previous month
|
|
col0 = 0 if first_idx.column() > 0 else 1
|
|
idx_prev = model.index(first_idx.row(), col0)
|
|
vp_pos = view.visualRect(idx_prev).center()
|
|
global_pos = view.viewport().mapToGlobal(vp_pos)
|
|
cal_pos = w.calendar.mapFromGlobal(global_pos)
|
|
date_prev = w._date_from_calendar_pos(cal_pos)
|
|
assert isinstance(date_prev, QDate) and date_prev.isValid()
|
|
|
|
# A cell after the last day should map to next month
|
|
last_day = QDate(qd.year(), qd.month(), 1).addMonths(1).addDays(-1).day()
|
|
last_idx = None
|
|
for r in range(model.rowCount() - 1, -1, -1):
|
|
for c in range(model.columnCount() - 1, -1, -1):
|
|
if model.index(r, c).data() == last_day:
|
|
last_idx = model.index(r, c)
|
|
break
|
|
if last_idx:
|
|
break
|
|
assert last_idx is not None
|
|
|
|
c_next = min(model.columnCount() - 1, last_idx.column() + 1)
|
|
idx_next = model.index(last_idx.row(), c_next)
|
|
vp_pos2 = view.visualRect(idx_next).center()
|
|
global_pos2 = view.viewport().mapToGlobal(vp_pos2)
|
|
cal_pos2 = w.calendar.mapFromGlobal(global_pos2)
|
|
date_next = w._date_from_calendar_pos(cal_pos2)
|
|
assert isinstance(date_next, QDate) and date_next.isValid()
|
|
|
|
# Context menu path: return the "Open in New Tab" action
|
|
class DummyMenu:
|
|
def __init__(self, parent=None):
|
|
self._action = object()
|
|
|
|
def addAction(self, text):
|
|
return self._action
|
|
|
|
def exec_(self, *args, **kwargs):
|
|
return self._action
|
|
|
|
monkeypatch.setattr(mwmod, "QMenu", DummyMenu, raising=True)
|
|
w._show_calendar_context_menu(cal_pos)
|
|
|
|
|
|
@pytest.mark.gui
|
|
def test_event_filter_keypress_starts_idle_timer(qtbot, app):
|
|
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
|
|
w = MainWindow(themes=themes)
|
|
qtbot.addWidget(w)
|
|
w.show()
|
|
ev = QEvent(QEvent.KeyPress)
|
|
w.eventFilter(w, ev)
|