Many changes and new features:
All checks were successful
CI / test (push) Successful in 5m17s
Lint / test (push) Successful in 32s
Trivy / test (push) Successful in 25s

* Make reminders be its own dataset rather than tied to current string.
 * Add support for repeated reminders
 * Make reminders be a feature that can be turned on and off
 * Add syntax highlighting for code blocks (right-click to set it)
 * Add a Pomodoro-style timer for measuring time spent on a task (stopping the timer offers to log it to Time Log)
 * Add ability to create markdown tables. Right-click to edit the table in a friendlier table dialog
This commit is contained in:
Miguel Jacq 2025-11-25 14:52:26 +11:00
parent 26737fbfb2
commit e0169db52a
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
28 changed files with 4191 additions and 17 deletions

View file

@ -36,7 +36,16 @@ def tmp_db_cfg(tmp_path):
default_db = tmp_path / "notebook.db"
key = "test-secret-key"
return DBConfig(
path=default_db, key=key, idle_minutes=0, theme="light", move_todos=True
path=default_db,
key=key,
idle_minutes=0,
theme="light",
move_todos=True,
tags=True,
time_log=True,
reminders=True,
locale="en",
font_size=11,
)

View file

@ -0,0 +1,398 @@
from bouquin.code_highlighter import CodeHighlighter, CodeBlockMetadata
from PySide6.QtGui import QTextCharFormat, QFont
def test_get_language_patterns_python(app):
"""Test getting highlighting patterns for Python."""
patterns = CodeHighlighter.get_language_patterns("python")
assert len(patterns) > 0
# Should have comment pattern
assert any("#" in p[0] for p in patterns)
# Should have string patterns
assert any('"' in p[0] for p in patterns)
# Should have keyword patterns
assert any("keyword" == p[1] for p in patterns)
def test_get_language_patterns_javascript(app):
"""Test getting highlighting patterns for JavaScript."""
patterns = CodeHighlighter.get_language_patterns("javascript")
assert len(patterns) > 0
# Should have // comment pattern
assert any("//" in p[0] for p in patterns)
# Should have /* */ comment pattern (with escaped asterisks in regex)
assert any(r"/\*" in p[0] for p in patterns)
def test_get_language_patterns_php(app):
"""Test getting highlighting patterns for PHP."""
patterns = CodeHighlighter.get_language_patterns("php")
assert len(patterns) > 0
# Should have # comment pattern
assert any("#" in p[0] for p in patterns)
# Should have // comment pattern
assert any("//" in p[0] for p in patterns)
# Should have /* */ comment pattern (with escaped asterisks in regex)
assert any(r"/\*" in p[0] for p in patterns)
def test_get_language_patterns_bash(app):
"""Test getting highlighting patterns for Bash."""
patterns = CodeHighlighter.get_language_patterns("bash")
assert len(patterns) > 0
# Should have # comment pattern
assert any("#" in p[0] for p in patterns)
# Should have bash keywords
keyword_patterns = [p for p in patterns if p[1] == "keyword"]
assert len(keyword_patterns) > 0
def test_get_language_patterns_html(app):
"""Test getting highlighting patterns for HTML."""
patterns = CodeHighlighter.get_language_patterns("html")
assert len(patterns) > 0
# Should have tag pattern
assert any("tag" == p[1] for p in patterns)
# Should have HTML comment pattern
assert any("<!--" in p[0] for p in patterns)
def test_get_language_patterns_css(app):
"""Test getting highlighting patterns for CSS."""
patterns = CodeHighlighter.get_language_patterns("css")
assert len(patterns) > 0
# Should have // comment pattern
assert any("//" in p[0] for p in patterns)
# Should have CSS properties as keywords
keyword_patterns = [p for p in patterns if p[1] == "keyword"]
assert len(keyword_patterns) > 0
def test_get_language_patterns_unknown_language(app):
"""Test getting patterns for an unknown language."""
patterns = CodeHighlighter.get_language_patterns("unknown-lang")
# Should still return basic patterns (strings, numbers)
assert len(patterns) > 0
assert any("string" == p[1] for p in patterns)
assert any("number" == p[1] for p in patterns)
def test_get_language_patterns_case_insensitive(app):
"""Test that language matching is case insensitive."""
patterns_lower = CodeHighlighter.get_language_patterns("python")
patterns_upper = CodeHighlighter.get_language_patterns("PYTHON")
patterns_mixed = CodeHighlighter.get_language_patterns("PyThOn")
assert len(patterns_lower) == len(patterns_upper)
assert len(patterns_lower) == len(patterns_mixed)
def test_get_format_for_type_keyword(app):
"""Test getting format for keyword type."""
base_format = QTextCharFormat()
fmt = CodeHighlighter.get_format_for_type("keyword", base_format)
assert fmt.fontWeight() == QFont.Weight.Bold
assert fmt.foreground().color().blue() > 0 # Should have blue-ish color
def test_get_format_for_type_string(app):
"""Test getting format for string type."""
base_format = QTextCharFormat()
fmt = CodeHighlighter.get_format_for_type("string", base_format)
# Should have orangish color
color = fmt.foreground().color()
assert color.red() > 100
def test_get_format_for_type_comment(app):
"""Test getting format for comment type."""
base_format = QTextCharFormat()
fmt = CodeHighlighter.get_format_for_type("comment", base_format)
assert fmt.fontItalic() is True
# Should have greenish color
color = fmt.foreground().color()
assert color.green() > 0
def test_get_format_for_type_number(app):
"""Test getting format for number type."""
base_format = QTextCharFormat()
fmt = CodeHighlighter.get_format_for_type("number", base_format)
# Should have some color
color = fmt.foreground().color()
assert color.isValid()
def test_get_format_for_type_tag(app):
"""Test getting format for HTML tag type."""
base_format = QTextCharFormat()
fmt = CodeHighlighter.get_format_for_type("tag", base_format)
# Should have cyan-ish color
color = fmt.foreground().color()
assert color.green() > 0
assert color.blue() > 0
def test_get_format_for_type_unknown(app):
"""Test getting format for unknown type."""
base_format = QTextCharFormat()
fmt = CodeHighlighter.get_format_for_type("unknown", base_format)
# Should return a valid format (based on base_format)
assert fmt is not None
def test_code_block_metadata_init(app):
"""Test CodeBlockMetadata initialization."""
metadata = CodeBlockMetadata()
assert len(metadata._block_languages) == 0
def test_code_block_metadata_set_get_language(app):
"""Test setting and getting language for a block."""
metadata = CodeBlockMetadata()
metadata.set_language(0, "python")
metadata.set_language(5, "javascript")
assert metadata.get_language(0) == "python"
assert metadata.get_language(5) == "javascript"
assert metadata.get_language(10) is None
def test_code_block_metadata_set_language_case_normalization(app):
"""Test that language is normalized to lowercase."""
metadata = CodeBlockMetadata()
metadata.set_language(0, "PYTHON")
metadata.set_language(1, "JavaScript")
assert metadata.get_language(0) == "python"
assert metadata.get_language(1) == "javascript"
def test_code_block_metadata_serialize_empty(app):
"""Test serializing empty metadata."""
metadata = CodeBlockMetadata()
result = metadata.serialize()
assert result == ""
def test_code_block_metadata_serialize(app):
"""Test serializing metadata."""
metadata = CodeBlockMetadata()
metadata.set_language(0, "python")
metadata.set_language(3, "javascript")
result = metadata.serialize()
assert "<!-- code-langs:" in result
assert "0:python" in result
assert "3:javascript" in result
assert "-->" in result
def test_code_block_metadata_serialize_sorted(app):
"""Test that serialized metadata is sorted by block number."""
metadata = CodeBlockMetadata()
metadata.set_language(5, "python")
metadata.set_language(2, "javascript")
metadata.set_language(8, "bash")
result = metadata.serialize()
# Find positions in string
pos_2 = result.find("2:")
pos_5 = result.find("5:")
pos_8 = result.find("8:")
# Should be in order
assert pos_2 < pos_5 < pos_8
def test_code_block_metadata_deserialize(app):
"""Test deserializing metadata."""
metadata = CodeBlockMetadata()
text = (
"Some content\n<!-- code-langs: 0:python,3:javascript,5:bash -->\nMore content"
)
metadata.deserialize(text)
assert metadata.get_language(0) == "python"
assert metadata.get_language(3) == "javascript"
assert metadata.get_language(5) == "bash"
def test_code_block_metadata_deserialize_empty(app):
"""Test deserializing from text without metadata."""
metadata = CodeBlockMetadata()
metadata.set_language(0, "python") # Set some initial data
text = "Just some regular text with no metadata"
metadata.deserialize(text)
# Should clear existing data
assert len(metadata._block_languages) == 0
def test_code_block_metadata_deserialize_invalid_format(app):
"""Test deserializing with invalid format."""
metadata = CodeBlockMetadata()
text = "<!-- code-langs: invalid,format,here -->"
metadata.deserialize(text)
# Should handle gracefully, resulting in empty or minimal data
# Pairs without ':' should be skipped
assert len(metadata._block_languages) == 0
def test_code_block_metadata_deserialize_invalid_block_number(app):
"""Test deserializing with invalid block number."""
metadata = CodeBlockMetadata()
text = "<!-- code-langs: abc:python,3:javascript -->"
metadata.deserialize(text)
# Should skip invalid block number 'abc'
assert metadata.get_language(3) == "javascript"
assert "abc" not in str(metadata._block_languages)
def test_code_block_metadata_round_trip(app):
"""Test serializing and deserializing preserves data."""
metadata1 = CodeBlockMetadata()
metadata1.set_language(0, "python")
metadata1.set_language(2, "javascript")
metadata1.set_language(7, "bash")
serialized = metadata1.serialize()
metadata2 = CodeBlockMetadata()
metadata2.deserialize(serialized)
assert metadata2.get_language(0) == "python"
assert metadata2.get_language(2) == "javascript"
assert metadata2.get_language(7) == "bash"
def test_python_keywords_present(app):
"""Test that Python keywords are defined."""
keywords = CodeHighlighter.KEYWORDS.get("python", [])
assert "def" in keywords
assert "class" in keywords
assert "if" in keywords
assert "for" in keywords
assert "import" in keywords
def test_javascript_keywords_present(app):
"""Test that JavaScript keywords are defined."""
keywords = CodeHighlighter.KEYWORDS.get("javascript", [])
assert "function" in keywords
assert "const" in keywords
assert "let" in keywords
assert "var" in keywords
assert "class" in keywords
def test_php_keywords_present(app):
"""Test that PHP keywords are defined."""
keywords = CodeHighlighter.KEYWORDS.get("php", [])
assert "function" in keywords
assert "class" in keywords
assert "echo" in keywords
assert "require" in keywords
def test_bash_keywords_present(app):
"""Test that Bash keywords are defined."""
keywords = CodeHighlighter.KEYWORDS.get("bash", [])
assert "if" in keywords
assert "then" in keywords
assert "fi" in keywords
assert "for" in keywords
def test_html_keywords_present(app):
"""Test that HTML keywords are defined."""
keywords = CodeHighlighter.KEYWORDS.get("html", [])
assert "div" in keywords
assert "span" in keywords
assert "body" in keywords
assert "html" in keywords
def test_css_keywords_present(app):
"""Test that CSS keywords are defined."""
keywords = CodeHighlighter.KEYWORDS.get("css", [])
assert "color" in keywords
assert "background" in keywords
assert "margin" in keywords
assert "padding" in keywords
def test_all_patterns_have_string_and_number(app):
"""Test that all languages have string and number patterns."""
languages = ["python", "javascript", "php", "bash", "html", "css"]
for lang in languages:
patterns = CodeHighlighter.get_language_patterns(lang)
pattern_types = [p[1] for p in patterns]
assert "string" in pattern_types, f"{lang} should have string pattern"
assert "number" in pattern_types, f"{lang} should have number pattern"
def test_patterns_have_regex_format(app):
"""Test that patterns are in regex format."""
patterns = CodeHighlighter.get_language_patterns("python")
for pattern, pattern_type in patterns:
# Each pattern should be a string (regex pattern)
assert isinstance(pattern, str)
# Each type should be a string
assert isinstance(pattern_type, str)
def test_code_block_metadata_update_language(app):
"""Test updating language for existing block."""
metadata = CodeBlockMetadata()
metadata.set_language(0, "python")
assert metadata.get_language(0) == "python"
metadata.set_language(0, "javascript")
assert metadata.get_language(0) == "javascript"
def test_get_format_preserves_base_format_properties(app):
"""Test that get_format_for_type preserves base format properties."""
base_format = QTextCharFormat()
base_format.setFontPointSize(14)
fmt = CodeHighlighter.get_format_for_type("keyword", base_format)
# Should be based on the base_format
assert isinstance(fmt, QTextCharFormat)

View file

@ -25,6 +25,11 @@ def test_main_window_loads_and_saves(qtbot, app, tmp_db_cfg, fresh_db):
s.setValue("ui/idle_minutes", 0)
s.setValue("ui/theme", "light")
s.setValue("ui/move_todos", True)
s.setValue("ui/tags", True)
s.setValue("ui/time_log", True)
s.setValue("ui/reminders", True)
s.setValue("ui/locale", "en")
s.setValue("ui/font_size", 11)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
w = MainWindow(themes=themes)

View file

@ -0,0 +1,354 @@
from unittest.mock import Mock, patch
from bouquin.pomodoro_timer import PomodoroTimer, PomodoroManager
def test_pomodoro_timer_init(qtbot, app, fresh_db):
"""Test PomodoroTimer initialization."""
task_text = "Write unit tests"
timer = PomodoroTimer(task_text)
qtbot.addWidget(timer)
assert timer._task_text == task_text
assert timer._elapsed_seconds == 0
assert timer._running is False
assert timer.time_label.text() == "00:00:00"
assert timer.stop_btn.isEnabled() is False
def test_pomodoro_timer_start(qtbot, app):
"""Test starting the timer."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
timer._toggle_timer()
assert timer._running is True
assert timer.stop_btn.isEnabled() is True
def test_pomodoro_timer_pause(qtbot, app):
"""Test pausing the timer."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
# Start the timer
timer._toggle_timer()
assert timer._running is True
# Pause the timer
timer._toggle_timer()
assert timer._running is False
def test_pomodoro_timer_resume(qtbot, app):
"""Test resuming the timer after pause."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
# Start, pause, then resume
timer._toggle_timer() # Start
timer._toggle_timer() # Pause
timer._toggle_timer() # Resume
assert timer._running is True
def test_pomodoro_timer_tick(qtbot, app):
"""Test timer tick increments elapsed time."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
initial_time = timer._elapsed_seconds
timer._tick()
assert timer._elapsed_seconds == initial_time + 1
def test_pomodoro_timer_display_update(qtbot, app):
"""Test display updates with various elapsed times."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
# Test 0 seconds
timer._elapsed_seconds = 0
timer._update_display()
assert timer.time_label.text() == "00:00:00"
# Test 65 seconds (1 min 5 sec)
timer._elapsed_seconds = 65
timer._update_display()
assert timer.time_label.text() == "00:01:05"
# Test 3665 seconds (1 hour 1 min 5 sec)
timer._elapsed_seconds = 3665
timer._update_display()
assert timer.time_label.text() == "01:01:05"
# Test 3600 seconds (1 hour exactly)
timer._elapsed_seconds = 3600
timer._update_display()
assert timer.time_label.text() == "01:00:00"
def test_pomodoro_timer_stop_and_log_running(qtbot, app):
"""Test stopping the timer while it's running."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
# Start the timer
timer._toggle_timer()
timer._elapsed_seconds = 100
# Connect a mock to the signal
signal_received = []
timer.timerStopped.connect(lambda s, t: signal_received.append((s, t)))
timer._stop_and_log()
assert timer._running is False
assert len(signal_received) == 1
assert signal_received[0][0] == 100 # elapsed seconds
assert signal_received[0][1] == "Test task"
def test_pomodoro_timer_stop_and_log_paused(qtbot, app):
"""Test stopping the timer when it's paused."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
timer._elapsed_seconds = 50
signal_received = []
timer.timerStopped.connect(lambda s, t: signal_received.append((s, t)))
timer._stop_and_log()
assert len(signal_received) == 1
assert signal_received[0][0] == 50
def test_pomodoro_timer_multiple_ticks(qtbot, app):
"""Test multiple timer ticks."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
for i in range(10):
timer._tick()
assert timer._elapsed_seconds == 10
assert "00:00:10" in timer.time_label.text()
def test_pomodoro_timer_modal_state(qtbot, app):
"""Test that timer is non-modal."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
assert timer.isModal() is False
def test_pomodoro_timer_window_title(qtbot, app):
"""Test timer window title."""
timer = PomodoroTimer("Test task")
qtbot.addWidget(timer)
# Window title should contain some reference to timer/pomodoro
assert len(timer.windowTitle()) > 0
def test_pomodoro_manager_init(app, fresh_db):
"""Test PomodoroManager initialization."""
parent = Mock()
manager = PomodoroManager(fresh_db, parent)
assert manager._db is fresh_db
assert manager._parent is parent
assert manager._active_timer is None
def test_pomodoro_manager_start_timer(qtbot, app, fresh_db):
"""Test starting a timer through the manager."""
from PySide6.QtWidgets import QWidget
parent = QWidget()
qtbot.addWidget(parent)
manager = PomodoroManager(fresh_db, parent)
line_text = "Important task"
date_iso = "2024-01-15"
manager.start_timer_for_line(line_text, date_iso)
assert manager._active_timer is not None
assert manager._active_timer._task_text == line_text
qtbot.addWidget(manager._active_timer)
def test_pomodoro_manager_replace_active_timer(qtbot, app, fresh_db):
"""Test that starting a new timer closes the previous one."""
from PySide6.QtWidgets import QWidget
parent = QWidget()
qtbot.addWidget(parent)
manager = PomodoroManager(fresh_db, parent)
# Start first timer
manager.start_timer_for_line("Task 1", "2024-01-15")
first_timer = manager._active_timer
qtbot.addWidget(first_timer)
first_timer.show()
# Start second timer
manager.start_timer_for_line("Task 2", "2024-01-16")
second_timer = manager._active_timer
qtbot.addWidget(second_timer)
assert first_timer is not second_timer
assert second_timer._task_text == "Task 2"
def test_pomodoro_manager_on_timer_stopped_minimum_hours(
qtbot, app, fresh_db, monkeypatch
):
"""Test that timer stopped with very short time logs minimum hours."""
parent = Mock()
manager = PomodoroManager(fresh_db, parent)
# Mock TimeLogDialog to avoid actually showing it
mock_dialog = Mock()
mock_dialog.hours_spin = Mock()
mock_dialog.note = Mock()
mock_dialog.exec = Mock()
with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog):
manager._on_timer_stopped(10, "Quick task", "2024-01-15")
# Should set minimum of 0.25 hours
mock_dialog.hours_spin.setValue.assert_called_once()
hours_set = mock_dialog.hours_spin.setValue.call_args[0][0]
assert hours_set >= 0.25
def test_pomodoro_manager_on_timer_stopped_rounding(qtbot, app, fresh_db, monkeypatch):
"""Test that elapsed time is properly rounded to decimal hours."""
parent = Mock()
manager = PomodoroManager(fresh_db, parent)
mock_dialog = Mock()
mock_dialog.hours_spin = Mock()
mock_dialog.note = Mock()
mock_dialog.exec = Mock()
with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog):
# Test with 1800 seconds (30 minutes)
manager._on_timer_stopped(1800, "Task", "2024-01-15")
mock_dialog.hours_spin.setValue.assert_called_once()
hours_set = mock_dialog.hours_spin.setValue.call_args[0][0]
# Should round up and be a multiple of 0.25
assert hours_set > 0
assert hours_set * 4 == int(hours_set * 4) # Multiple of 0.25
def test_pomodoro_manager_on_timer_stopped_prefills_note(
qtbot, app, fresh_db, monkeypatch
):
"""Test that timer stopped pre-fills the note in time log dialog."""
parent = Mock()
manager = PomodoroManager(fresh_db, parent)
mock_dialog = Mock()
mock_dialog.hours_spin = Mock()
mock_dialog.note = Mock()
mock_dialog.exec = Mock()
task_text = "Write documentation"
with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog):
manager._on_timer_stopped(3600, task_text, "2024-01-15")
mock_dialog.note.setText.assert_called_once_with(task_text)
def test_pomodoro_manager_timer_stopped_signal_connection(
qtbot, app, fresh_db, monkeypatch
):
"""Test that timer stopped signal is properly connected."""
from PySide6.QtWidgets import QWidget
parent = QWidget()
qtbot.addWidget(parent)
manager = PomodoroManager(fresh_db, parent)
# Mock TimeLogDialog
mock_dialog = Mock()
mock_dialog.hours_spin = Mock()
mock_dialog.note = Mock()
mock_dialog.exec = Mock()
with patch("bouquin.pomodoro_timer.TimeLogDialog", return_value=mock_dialog):
manager.start_timer_for_line("Task", "2024-01-15")
timer = manager._active_timer
qtbot.addWidget(timer)
# Simulate timer stopped
timer._elapsed_seconds = 1000
timer._stop_and_log()
# TimeLogDialog should have been created
assert mock_dialog.exec.called
def test_pomodoro_timer_accepts_parent(qtbot, app):
"""Test that timer accepts a parent widget."""
from PySide6.QtWidgets import QWidget
parent = QWidget()
qtbot.addWidget(parent)
timer = PomodoroTimer("Task", parent)
qtbot.addWidget(timer)
assert timer.parent() is parent
def test_pomodoro_manager_no_active_timer_initially(app, fresh_db):
"""Test that manager starts with no active timer."""
parent = Mock()
manager = PomodoroManager(fresh_db, parent)
assert manager._active_timer is None
def test_pomodoro_timer_start_stop_cycle(qtbot, app):
"""Test a complete start-stop cycle."""
timer = PomodoroTimer("Complete cycle")
qtbot.addWidget(timer)
signal_received = []
timer.timerStopped.connect(lambda s, t: signal_received.append((s, t)))
# Start
timer._toggle_timer()
assert timer._running is True
# Simulate some ticks
for _ in range(5):
timer._tick()
# Stop
timer._stop_and_log()
assert timer._running is False
assert len(signal_received) == 1
assert signal_received[0][0] == 5
def test_pomodoro_timer_long_elapsed_time(qtbot, app):
"""Test display with very long elapsed time."""
timer = PomodoroTimer("Long task")
qtbot.addWidget(timer)
# Set to 2 hours, 34 minutes, 56 seconds
timer._elapsed_seconds = 2 * 3600 + 34 * 60 + 56
timer._update_display()
assert timer.time_label.text() == "02:34:56"

657
tests/test_reminders.py Normal file
View file

@ -0,0 +1,657 @@
from unittest.mock import patch
from bouquin.reminders import (
Reminder,
ReminderType,
ReminderDialog,
UpcomingRemindersWidget,
ManageRemindersDialog,
)
from PySide6.QtCore import QDate, QTime
from PySide6.QtWidgets import QDialog, QMessageBox
def test_reminder_type_enum(app):
"""Test ReminderType enum values."""
assert ReminderType.ONCE is not None
assert ReminderType.DAILY is not None
assert ReminderType.WEEKDAYS is not None
assert ReminderType.WEEKLY is not None
def test_reminder_dataclass_creation(app):
"""Test creating a Reminder instance."""
reminder = Reminder(
id=1,
text="Test reminder",
time_str="10:30",
reminder_type=ReminderType.DAILY,
weekday=None,
active=True,
date_iso=None,
)
assert reminder.id == 1
assert reminder.text == "Test reminder"
assert reminder.time_str == "10:30"
assert reminder.reminder_type == ReminderType.DAILY
assert reminder.active is True
def test_reminder_dialog_init_new(qtbot, app, fresh_db):
"""Test ReminderDialog initialization for new reminder."""
dialog = ReminderDialog(fresh_db)
qtbot.addWidget(dialog)
assert dialog._db is fresh_db
assert dialog._reminder is None
assert dialog.text_edit.text() == ""
def test_reminder_dialog_init_existing(qtbot, app, fresh_db):
"""Test ReminderDialog initialization with existing reminder."""
reminder = Reminder(
id=1,
text="Existing reminder",
time_str="14:30",
reminder_type=ReminderType.WEEKLY,
weekday=2,
active=True,
)
dialog = ReminderDialog(fresh_db, reminder=reminder)
qtbot.addWidget(dialog)
assert dialog.text_edit.text() == "Existing reminder"
assert dialog.time_edit.time().hour() == 14
assert dialog.time_edit.time().minute() == 30
def test_reminder_dialog_type_changed(qtbot, app, fresh_db):
"""Test that weekday combo visibility changes with type."""
dialog = ReminderDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.show() # Show the dialog so child widgets can be visible
# Find weekly type in combo
for i in range(dialog.type_combo.count()):
if dialog.type_combo.itemData(i) == ReminderType.WEEKLY:
dialog.type_combo.setCurrentIndex(i)
break
qtbot.wait(10) # Wait for Qt event processing
assert dialog.weekday_combo.isVisible() is True
# Switch to daily
for i in range(dialog.type_combo.count()):
if dialog.type_combo.itemData(i) == ReminderType.DAILY:
dialog.type_combo.setCurrentIndex(i)
break
qtbot.wait(10) # Wait for Qt event processing
assert dialog.weekday_combo.isVisible() is False
def test_reminder_dialog_get_reminder_once(qtbot, app, fresh_db):
"""Test getting reminder with ONCE type."""
dialog = ReminderDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.text_edit.setText("Test task")
dialog.time_edit.setTime(QTime(10, 30))
# Set to ONCE type
for i in range(dialog.type_combo.count()):
if dialog.type_combo.itemData(i) == ReminderType.ONCE:
dialog.type_combo.setCurrentIndex(i)
break
reminder = dialog.get_reminder()
assert reminder.text == "Test task"
assert reminder.time_str == "10:30"
assert reminder.reminder_type == ReminderType.ONCE
assert reminder.date_iso is not None
def test_reminder_dialog_get_reminder_weekly(qtbot, app, fresh_db):
"""Test getting reminder with WEEKLY type."""
dialog = ReminderDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.text_edit.setText("Weekly meeting")
dialog.time_edit.setTime(QTime(15, 0))
# Set to WEEKLY type
for i in range(dialog.type_combo.count()):
if dialog.type_combo.itemData(i) == ReminderType.WEEKLY:
dialog.type_combo.setCurrentIndex(i)
break
dialog.weekday_combo.setCurrentIndex(1) # Tuesday
reminder = dialog.get_reminder()
assert reminder.text == "Weekly meeting"
assert reminder.reminder_type == ReminderType.WEEKLY
assert reminder.weekday == 1
def test_upcoming_reminders_widget_init(qtbot, app, fresh_db):
"""Test UpcomingRemindersWidget initialization."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
assert widget._db is fresh_db
assert widget.body.isVisible() is False
def test_upcoming_reminders_widget_toggle(qtbot, app, fresh_db):
"""Test toggling reminder list visibility."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
widget.show() # Show the widget so child widgets can be visible
# Initially hidden
assert widget.body.isVisible() is False
# Click toggle
widget.toggle_btn.click()
qtbot.wait(10) # Wait for Qt event processing
assert widget.body.isVisible() is True
def test_upcoming_reminders_widget_should_fire_on_date_once(qtbot, app, fresh_db):
"""Test should_fire_on_date for ONCE type."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
reminder = Reminder(
id=1,
text="Test",
time_str="10:00",
reminder_type=ReminderType.ONCE,
date_iso="2024-01-15",
)
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 15)) is True
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 16)) is False
def test_upcoming_reminders_widget_should_fire_on_date_daily(qtbot, app, fresh_db):
"""Test should_fire_on_date for DAILY type."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
reminder = Reminder(
id=1,
text="Test",
time_str="10:00",
reminder_type=ReminderType.DAILY,
)
# Should fire every day
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 15)) is True
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 16)) is True
def test_upcoming_reminders_widget_should_fire_on_date_weekdays(qtbot, app, fresh_db):
"""Test should_fire_on_date for WEEKDAYS type."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
reminder = Reminder(
id=1,
text="Test",
time_str="10:00",
reminder_type=ReminderType.WEEKDAYS,
)
# Monday (dayOfWeek = 1)
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 15)) is True
# Friday (dayOfWeek = 5)
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 19)) is True
# Saturday (dayOfWeek = 6)
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 20)) is False
# Sunday (dayOfWeek = 7)
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 21)) is False
def test_upcoming_reminders_widget_should_fire_on_date_weekly(qtbot, app, fresh_db):
"""Test should_fire_on_date for WEEKLY type."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
# Fire on Wednesday (weekday = 2)
reminder = Reminder(
id=1,
text="Test",
time_str="10:00",
reminder_type=ReminderType.WEEKLY,
weekday=2,
)
# Wednesday (dayOfWeek = 3, so weekday = 2)
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 17)) is True
# Thursday (dayOfWeek = 4, so weekday = 3)
assert widget._should_fire_on_date(reminder, QDate(2024, 1, 18)) is False
def test_upcoming_reminders_widget_refresh_no_db(qtbot, app):
"""Test refresh with no database connection."""
widget = UpcomingRemindersWidget(None)
qtbot.addWidget(widget)
# Should not crash
widget.refresh()
def test_upcoming_reminders_widget_refresh_with_reminders(qtbot, app, fresh_db):
"""Test refresh displays reminders."""
# Add a reminder to the database
reminder = Reminder(
id=None,
text="Test reminder",
time_str="23:59", # Late time so it's in the future
reminder_type=ReminderType.DAILY,
active=True,
)
fresh_db.save_reminder(reminder)
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
widget.refresh()
# Should have at least one item (or "No upcoming reminders")
assert widget.reminder_list.count() > 0
def test_upcoming_reminders_widget_add_reminder(qtbot, app, fresh_db):
"""Test adding a reminder through the widget."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted):
with patch.object(ReminderDialog, "get_reminder") as mock_get:
mock_get.return_value = Reminder(
id=None,
text="New reminder",
time_str="10:00",
reminder_type=ReminderType.DAILY,
)
widget._add_reminder()
# Reminder should be saved
reminders = fresh_db.get_all_reminders()
assert len(reminders) > 0
def test_upcoming_reminders_widget_edit_reminder(qtbot, app, fresh_db):
"""Test editing a reminder through the widget."""
# Add a reminder first
reminder = Reminder(
id=None,
text="Original",
time_str="10:00",
reminder_type=ReminderType.DAILY,
active=True,
)
fresh_db.save_reminder(reminder)
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
widget.refresh()
# Get the list item
if widget.reminder_list.count() > 0:
item = widget.reminder_list.item(0)
with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted):
with patch.object(ReminderDialog, "get_reminder") as mock_get:
updated = Reminder(
id=1,
text="Updated",
time_str="11:00",
reminder_type=ReminderType.DAILY,
)
mock_get.return_value = updated
widget._edit_reminder(item)
def test_upcoming_reminders_widget_delete_selected_single(qtbot, app, fresh_db):
"""Test deleting a single selected reminder."""
# Add a reminder
reminder = Reminder(
id=None,
text="To delete",
time_str="10:00",
reminder_type=ReminderType.DAILY,
active=True,
)
fresh_db.save_reminder(reminder)
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
widget.refresh()
if widget.reminder_list.count() > 0:
widget.reminder_list.setCurrentRow(0)
with patch.object(QMessageBox, "question", return_value=QMessageBox.Yes):
widget._delete_selected_reminders()
def test_upcoming_reminders_widget_delete_selected_multiple(qtbot, app, fresh_db):
"""Test deleting multiple selected reminders."""
# Add multiple reminders
for i in range(3):
reminder = Reminder(
id=None,
text=f"Reminder {i}",
time_str="23:59",
reminder_type=ReminderType.DAILY,
active=True,
)
fresh_db.save_reminder(reminder)
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
widget.refresh()
# Select all items
for i in range(widget.reminder_list.count()):
widget.reminder_list.item(i).setSelected(True)
with patch.object(QMessageBox, "question", return_value=QMessageBox.Yes):
widget._delete_selected_reminders()
def test_upcoming_reminders_widget_check_reminders_no_db(qtbot, app):
"""Test check_reminders with no database."""
widget = UpcomingRemindersWidget(None)
qtbot.addWidget(widget)
# Should not crash
widget._check_reminders()
def test_upcoming_reminders_widget_start_regular_timer(qtbot, app, fresh_db):
"""Test starting the regular check timer."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
widget._start_regular_timer()
# Timer should be running
assert widget._check_timer.isActive()
def test_manage_reminders_dialog_init(qtbot, app, fresh_db):
"""Test ManageRemindersDialog initialization."""
dialog = ManageRemindersDialog(fresh_db)
qtbot.addWidget(dialog)
assert dialog._db is fresh_db
assert dialog.table is not None
def test_manage_reminders_dialog_load_reminders(qtbot, app, fresh_db):
"""Test loading reminders into the table."""
# Add some reminders
for i in range(3):
reminder = Reminder(
id=None,
text=f"Reminder {i}",
time_str="10:00",
reminder_type=ReminderType.DAILY,
active=True,
)
fresh_db.save_reminder(reminder)
dialog = ManageRemindersDialog(fresh_db)
qtbot.addWidget(dialog)
assert dialog.table.rowCount() == 3
def test_manage_reminders_dialog_load_reminders_no_db(qtbot, app):
"""Test loading reminders with no database."""
dialog = ManageRemindersDialog(None)
qtbot.addWidget(dialog)
# Should not crash
dialog._load_reminders()
def test_manage_reminders_dialog_add_reminder(qtbot, app, fresh_db):
"""Test adding a reminder through the manage dialog."""
dialog = ManageRemindersDialog(fresh_db)
qtbot.addWidget(dialog)
initial_count = dialog.table.rowCount()
with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted):
with patch.object(ReminderDialog, "get_reminder") as mock_get:
mock_get.return_value = Reminder(
id=None,
text="New",
time_str="10:00",
reminder_type=ReminderType.DAILY,
)
dialog._add_reminder()
# Table should have one more row
assert dialog.table.rowCount() == initial_count + 1
def test_manage_reminders_dialog_edit_reminder(qtbot, app, fresh_db):
"""Test editing a reminder through the manage dialog."""
reminder = Reminder(
id=None,
text="Original",
time_str="10:00",
reminder_type=ReminderType.DAILY,
active=True,
)
fresh_db.save_reminder(reminder)
dialog = ManageRemindersDialog(fresh_db)
qtbot.addWidget(dialog)
with patch.object(ReminderDialog, "exec", return_value=QDialog.Accepted):
with patch.object(ReminderDialog, "get_reminder") as mock_get:
mock_get.return_value = Reminder(
id=1,
text="Updated",
time_str="11:00",
reminder_type=ReminderType.DAILY,
)
dialog._edit_reminder(reminder)
def test_manage_reminders_dialog_delete_reminder(qtbot, app, fresh_db):
"""Test deleting a reminder through the manage dialog."""
reminder = Reminder(
id=None,
text="To delete",
time_str="10:00",
reminder_type=ReminderType.DAILY,
active=True,
)
fresh_db.save_reminder(reminder)
saved_reminders = fresh_db.get_all_reminders()
reminder_to_delete = saved_reminders[0]
dialog = ManageRemindersDialog(fresh_db)
qtbot.addWidget(dialog)
initial_count = dialog.table.rowCount()
with patch.object(QMessageBox, "question", return_value=QMessageBox.Yes):
dialog._delete_reminder(reminder_to_delete)
# Table should have one fewer row
assert dialog.table.rowCount() == initial_count - 1
def test_manage_reminders_dialog_delete_reminder_declined(qtbot, app, fresh_db):
"""Test declining to delete a reminder."""
reminder = Reminder(
id=None,
text="Keep me",
time_str="10:00",
reminder_type=ReminderType.DAILY,
active=True,
)
fresh_db.save_reminder(reminder)
saved_reminders = fresh_db.get_all_reminders()
reminder_to_keep = saved_reminders[0]
dialog = ManageRemindersDialog(fresh_db)
qtbot.addWidget(dialog)
initial_count = dialog.table.rowCount()
with patch.object(QMessageBox, "question", return_value=QMessageBox.No):
dialog._delete_reminder(reminder_to_keep)
# Table should have same number of rows
assert dialog.table.rowCount() == initial_count
def test_manage_reminders_dialog_weekly_reminder_display(qtbot, app, fresh_db):
"""Test that weekly reminders display the day name."""
reminder = Reminder(
id=None,
text="Weekly",
time_str="10:00",
reminder_type=ReminderType.WEEKLY,
weekday=2, # Wednesday
active=True,
)
fresh_db.save_reminder(reminder)
dialog = ManageRemindersDialog(fresh_db)
qtbot.addWidget(dialog)
# Check that the type column shows the day
type_item = dialog.table.item(0, 2)
assert "Wed" in type_item.text()
def test_reminder_dialog_accept(qtbot, app, fresh_db):
"""Test accepting the reminder dialog."""
dialog = ReminderDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.text_edit.setText("Test")
dialog.accept()
def test_reminder_dialog_reject(qtbot, app, fresh_db):
"""Test rejecting the reminder dialog."""
dialog = ReminderDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.reject()
def test_upcoming_reminders_widget_signal_emitted(qtbot, app, fresh_db):
"""Test that reminderTriggered signal is emitted."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
signal_received = []
widget.reminderTriggered.connect(lambda text: signal_received.append(text))
# Manually emit for testing
widget.reminderTriggered.emit("Test reminder")
assert len(signal_received) == 1
assert signal_received[0] == "Test reminder"
def test_upcoming_reminders_widget_no_upcoming_message(qtbot, app, fresh_db):
"""Test that 'No upcoming reminders' message is shown when appropriate."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
widget.refresh()
# Should show message when no reminders
if widget.reminder_list.count() > 0:
item = widget.reminder_list.item(0)
if "No upcoming" in item.text():
assert True
def test_upcoming_reminders_widget_manage_button(qtbot, app, fresh_db):
"""Test clicking the manage button."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
with patch.object(ManageRemindersDialog, "exec"):
widget._manage_reminders()
def test_reminder_dialog_time_format(qtbot, app, fresh_db):
"""Test that time is formatted correctly."""
dialog = ReminderDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.time_edit.setTime(QTime(9, 5))
reminder = dialog.get_reminder()
assert reminder.time_str == "09:05"
def test_upcoming_reminders_widget_past_reminders_filtered(qtbot, app, fresh_db):
"""Test that past reminders are not shown in upcoming list."""
# Create a reminder that's in the past
reminder = Reminder(
id=None,
text="Past reminder",
time_str="00:01", # Very early morning
reminder_type=ReminderType.DAILY,
active=True,
)
fresh_db.save_reminder(reminder)
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
# Current time should be past 00:01
from PySide6.QtCore import QTime
if QTime.currentTime().hour() > 0:
widget.refresh()
# The past reminder for today should be filtered out
# but tomorrow's occurrence should be shown
def test_reminder_with_inactive_status(qtbot, app, fresh_db):
"""Test that inactive reminders are not displayed."""
reminder = Reminder(
id=None,
text="Inactive",
time_str="23:59",
reminder_type=ReminderType.DAILY,
active=False,
)
fresh_db.save_reminder(reminder)
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
widget.refresh()
# Should not show inactive reminder
for i in range(widget.reminder_list.count()):
item = widget.reminder_list.item(i)
assert "Inactive" not in item.text() or "No upcoming" in item.text()

View file

@ -15,7 +15,11 @@ def _clear_db_settings():
"ui/idle_minutes",
"ui/theme",
"ui/move_todos",
"ui/tags",
"ui/time_log",
"ui/reminders",
"ui/locale",
"ui/font_size",
]:
s.remove(k)
@ -29,7 +33,11 @@ def test_load_and_save_db_config_roundtrip(app, tmp_path):
idle_minutes=7,
theme="dark",
move_todos=True,
tags=True,
time_log=True,
reminders=True,
locale="en",
font_size=11,
)
save_db_config(cfg)
@ -39,7 +47,11 @@ def test_load_and_save_db_config_roundtrip(app, tmp_path):
assert loaded.idle_minutes == cfg.idle_minutes
assert loaded.theme == cfg.theme
assert loaded.move_todos == cfg.move_todos
assert loaded.tags == cfg.tags
assert loaded.time_log == cfg.time_log
assert loaded.reminders == cfg.reminders
assert loaded.locale == cfg.locale
assert loaded.font_size == cfg.font_size
def test_load_db_config_migrates_legacy_db_path(app, tmp_path):

View file

@ -22,6 +22,7 @@ def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db):
dlg.move_todos.setChecked(True)
dlg.tags.setChecked(False)
dlg.time_log.setChecked(False)
dlg.reminders.setChecked(False)
# Auto-accept the modal QMessageBox that _compact_btn_clicked() shows
def _auto_accept_msgbox():
@ -39,6 +40,7 @@ def test_settings_dialog_config_roundtrip(qtbot, tmp_db_cfg, fresh_db):
assert cfg.move_todos is True
assert cfg.tags is False
assert cfg.time_log is False
assert cfg.reminders is False
assert cfg.theme in ("light", "dark", "system")

384
tests/test_table_editor.py Normal file
View file

@ -0,0 +1,384 @@
from bouquin.table_editor import TableEditorDialog, find_table_at_cursor, _is_table_line
def test_table_editor_init_simple_table(qtbot, app):
"""Test initialization with a simple markdown table."""
table_text = """| Header1 | Header2 | Header3 |
| --- | --- | --- |
| Cell1 | Cell2 | Cell3 |
| Cell4 | Cell5 | Cell6 |"""
dialog = TableEditorDialog(table_text)
qtbot.addWidget(dialog)
assert dialog.table_widget.columnCount() == 3
assert dialog.table_widget.rowCount() == 2
assert dialog.table_widget.horizontalHeaderItem(0).text() == "Header1"
assert dialog.table_widget.horizontalHeaderItem(1).text() == "Header2"
assert dialog.table_widget.item(0, 0).text() == "Cell1"
assert dialog.table_widget.item(1, 2).text() == "Cell6"
def test_table_editor_no_separator_line(qtbot, app):
"""Test parsing table without separator line."""
table_text = """| Header1 | Header2 |
| Cell1 | Cell2 |
| Cell3 | Cell4 |"""
dialog = TableEditorDialog(table_text)
qtbot.addWidget(dialog)
assert dialog.table_widget.columnCount() == 2
assert dialog.table_widget.rowCount() == 2
assert dialog.table_widget.item(0, 0).text() == "Cell1"
def test_table_editor_empty_table(qtbot, app):
"""Test initialization with empty table text."""
dialog = TableEditorDialog("")
qtbot.addWidget(dialog)
# Should have no columns/rows
assert dialog.table_widget.columnCount() == 0 or dialog.table_widget.rowCount() == 0
def test_table_editor_single_header_line(qtbot, app):
"""Test table with only header line."""
table_text = "| Header1 | Header2 | Header3 |"
dialog = TableEditorDialog(table_text)
qtbot.addWidget(dialog)
assert dialog.table_widget.columnCount() == 3
assert dialog.table_widget.rowCount() == 0
def test_add_row(qtbot, app):
"""Test adding a row to the table."""
table_text = """| H1 | H2 |
| --- | --- |
| A | B |"""
dialog = TableEditorDialog(table_text)
qtbot.addWidget(dialog)
initial_rows = dialog.table_widget.rowCount()
dialog._add_row()
assert dialog.table_widget.rowCount() == initial_rows + 1
# New row should have empty items
assert dialog.table_widget.item(initial_rows, 0).text() == ""
assert dialog.table_widget.item(initial_rows, 1).text() == ""
def test_add_column(qtbot, app):
"""Test adding a column to the table."""
table_text = """| H1 | H2 |
| --- | --- |
| A | B |"""
dialog = TableEditorDialog(table_text)
qtbot.addWidget(dialog)
initial_cols = dialog.table_widget.columnCount()
dialog._add_column()
assert dialog.table_widget.columnCount() == initial_cols + 1
# New column should have header and empty items
assert "Column" in dialog.table_widget.horizontalHeaderItem(initial_cols).text()
assert dialog.table_widget.item(0, initial_cols).text() == ""
def test_delete_row(qtbot, app):
"""Test deleting a row from the table."""
table_text = """| H1 | H2 |
| --- | --- |
| A | B |
| C | D |"""
dialog = TableEditorDialog(table_text)
qtbot.addWidget(dialog)
initial_rows = dialog.table_widget.rowCount()
dialog.table_widget.setCurrentCell(0, 0)
dialog._delete_row()
assert dialog.table_widget.rowCount() == initial_rows - 1
def test_delete_row_no_selection(qtbot, app):
"""Test deleting a row when nothing is selected."""
table_text = """| H1 | H2 |
| --- | --- |
| A | B |"""
dialog = TableEditorDialog(table_text)
qtbot.addWidget(dialog)
initial_rows = dialog.table_widget.rowCount()
dialog.table_widget.setCurrentCell(-1, -1) # No selection
dialog._delete_row()
# Row count should remain the same
assert dialog.table_widget.rowCount() == initial_rows
def test_delete_column(qtbot, app):
"""Test deleting a column from the table."""
table_text = """| H1 | H2 | H3 |
| --- | --- | --- |
| A | B | C |"""
dialog = TableEditorDialog(table_text)
qtbot.addWidget(dialog)
initial_cols = dialog.table_widget.columnCount()
dialog.table_widget.setCurrentCell(0, 1)
dialog._delete_column()
assert dialog.table_widget.columnCount() == initial_cols - 1
def test_delete_column_no_selection(qtbot, app):
"""Test deleting a column when nothing is selected."""
table_text = """| H1 | H2 |
| --- | --- |
| A | B |"""
dialog = TableEditorDialog(table_text)
qtbot.addWidget(dialog)
initial_cols = dialog.table_widget.columnCount()
dialog.table_widget.setCurrentCell(-1, -1) # No selection
dialog._delete_column()
# Column count should remain the same
assert dialog.table_widget.columnCount() == initial_cols
def test_get_markdown_table(qtbot, app):
"""Test converting table back to markdown."""
table_text = """| Name | Age | City |
| --- | --- | --- |
| Alice | 30 | NYC |
| Bob | 25 | LA |"""
dialog = TableEditorDialog(table_text)
qtbot.addWidget(dialog)
result = dialog.get_markdown_table()
assert "| Name | Age | City |" in result
assert "| --- | --- | --- |" in result
assert "| Alice | 30 | NYC |" in result
assert "| Bob | 25 | LA |" in result
def test_get_markdown_table_empty(qtbot, app):
"""Test getting markdown from empty table."""
dialog = TableEditorDialog("")
qtbot.addWidget(dialog)
result = dialog.get_markdown_table()
assert result == ""
def test_get_markdown_table_with_modifications(qtbot, app):
"""Test getting markdown after modifying table."""
table_text = """| H1 | H2 |
| --- | --- |
| A | B |"""
dialog = TableEditorDialog(table_text)
qtbot.addWidget(dialog)
# Modify a cell
dialog.table_widget.item(0, 0).setText("Modified")
result = dialog.get_markdown_table()
assert "Modified" in result
def test_find_table_at_cursor_middle_of_table(qtbot, app):
"""Test finding table when cursor is in the middle."""
text = """Some text before
| H1 | H2 |
| --- | --- |
| A | B |
| C | D |
Some text after"""
# Cursor position in the middle of the table
cursor_pos = text.find("| A |") + 2
result = find_table_at_cursor(text, cursor_pos)
assert result is not None
start, end, table_text = result
assert "| H1 | H2 |" in table_text
assert "| A | B |" in table_text
assert "Some text before" not in table_text
assert "Some text after" not in table_text
def test_find_table_at_cursor_first_line(qtbot, app):
"""Test finding table when cursor is on the first line."""
text = """| H1 | H2 |
| --- | --- |
| A | B |"""
cursor_pos = 5 # In the first line
result = find_table_at_cursor(text, cursor_pos)
assert result is not None
start, end, table_text = result
assert "| H1 | H2 |" in table_text
def test_find_table_at_cursor_not_in_table(qtbot, app):
"""Test finding table when cursor is not in a table."""
text = """Just some regular text
No tables here
| H1 | H2 |
| --- | --- |
| A | B |"""
cursor_pos = 10 # In "Just some regular text"
result = find_table_at_cursor(text, cursor_pos)
assert result is None
def test_find_table_at_cursor_empty_text(qtbot, app):
"""Test finding table in empty text."""
result = find_table_at_cursor("", 0)
assert result is None
def test_find_table_at_cursor_multiple_tables(qtbot, app):
"""Test finding correct table when there are multiple tables."""
text = """| Table1 | H1 |
| --- | --- |
Some text
| Table2 | H2 |
| --- | --- |
| Data | Here |"""
# Cursor in second table
cursor_pos = text.find("| Data |")
result = find_table_at_cursor(text, cursor_pos)
assert result is not None
start, end, table_text = result
assert "Table2" in table_text
assert "Table1" not in table_text
def test_is_table_line_valid(qtbot, app):
"""Test identifying valid table lines."""
assert _is_table_line("| Header | Header2 |") is True
assert _is_table_line("| --- | --- |") is True
assert _is_table_line("| Cell | Cell2 | Cell3 |") is True
def test_is_table_line_invalid(qtbot, app):
"""Test identifying invalid table lines."""
assert _is_table_line("Just regular text") is False
assert _is_table_line("") is False
assert _is_table_line(" ") is False
assert _is_table_line("| Only one pipe") is False
assert _is_table_line("Only one pipe |") is False
assert _is_table_line("No pipes at all") is False
def test_is_table_line_edge_cases(qtbot, app):
"""Test edge cases for table line detection."""
assert _is_table_line("| | |") is True # Minimal valid table
assert (
_is_table_line(" | Header | Data | ") is True
) # With leading/trailing spaces
def test_table_with_alignment_indicators(qtbot, app):
"""Test parsing table with alignment indicators."""
table_text = """| Left | Center | Right |
| :--- | :---: | ---: |
| L1 | C1 | R1 |"""
dialog = TableEditorDialog(table_text)
qtbot.addWidget(dialog)
assert dialog.table_widget.columnCount() == 3
assert dialog.table_widget.rowCount() == 1
assert dialog.table_widget.item(0, 0).text() == "L1"
def test_accept_dialog(qtbot, app):
"""Test accepting the dialog."""
table_text = "| H1 | H2 |\n| --- | --- |\n| A | B |"
dialog = TableEditorDialog(table_text)
qtbot.addWidget(dialog)
# Find and click the OK button
for child in dialog.findChildren(type(dialog.findChild(type(None)))):
if hasattr(child, "text") and callable(child.text):
try:
if "ok" in child.text().lower() or "OK" in child.text():
child.click()
break
except:
pass
def test_reject_dialog(qtbot, app):
"""Test rejecting the dialog."""
table_text = "| H1 | H2 |\n| --- | --- |\n| A | B |"
dialog = TableEditorDialog(table_text)
qtbot.addWidget(dialog)
# Find and click the Cancel button
for child in dialog.findChildren(type(dialog.findChild(type(None)))):
if hasattr(child, "text") and callable(child.text):
try:
if "cancel" in child.text().lower():
child.click()
break
except:
pass
def test_table_with_uneven_columns(qtbot, app):
"""Test parsing table with uneven number of columns in rows."""
table_text = """| H1 | H2 | H3 |
| --- | --- | --- |
| A | B |
| C | D | E | F |"""
dialog = TableEditorDialog(table_text)
qtbot.addWidget(dialog)
# Should handle gracefully
assert dialog.table_widget.columnCount() == 3
assert dialog.table_widget.rowCount() == 2
def test_table_with_empty_cells(qtbot, app):
"""Test parsing table with empty cells."""
table_text = """| H1 | H2 | H3 |
| --- | --- | --- |
| | B | |
| C | | E |"""
dialog = TableEditorDialog(table_text)
qtbot.addWidget(dialog)
assert dialog.table_widget.item(0, 0).text() == ""
assert dialog.table_widget.item(0, 1).text() == "B"
assert dialog.table_widget.item(0, 2).text() == ""
assert dialog.table_widget.item(1, 0).text() == "C"
assert dialog.table_widget.item(1, 1).text() == ""
assert dialog.table_widget.item(1, 2).text() == "E"

512
tests/test_version_check.py Normal file
View file

@ -0,0 +1,512 @@
import pytest
from unittest.mock import Mock, patch
import subprocess
from bouquin.version_check import VersionChecker
from PySide6.QtWidgets import QMessageBox, QWidget
from PySide6.QtGui import QPixmap
def test_version_checker_init(app):
"""Test VersionChecker initialization."""
parent = QWidget()
checker = VersionChecker(parent)
assert checker._parent is parent
def test_version_checker_init_no_parent(app):
"""Test VersionChecker initialization without parent."""
checker = VersionChecker()
assert checker._parent is None
def test_current_version_returns_version(app):
"""Test getting current version."""
checker = VersionChecker()
with patch("importlib.metadata.version", return_value="1.2.3"):
version = checker.current_version()
assert version == "1.2.3"
def test_current_version_fallback_on_error(app):
"""Test current version fallback when package not found."""
checker = VersionChecker()
import importlib.metadata
with patch(
"importlib.metadata.version",
side_effect=importlib.metadata.PackageNotFoundError("Not found"),
):
version = checker.current_version()
assert version == "0.0.0"
def test_parse_version_simple(app):
"""Test parsing simple version string."""
result = VersionChecker._parse_version("1.2.3")
assert result == (1, 2, 3)
def test_parse_version_complex(app):
"""Test parsing complex version string with extra text."""
result = VersionChecker._parse_version("v1.2.3-beta")
assert result == (1, 2, 3)
def test_parse_version_no_numbers(app):
"""Test parsing version string with no numbers."""
result = VersionChecker._parse_version("invalid")
assert result == (0,)
def test_parse_version_single_number(app):
"""Test parsing version with single number."""
result = VersionChecker._parse_version("5")
assert result == (5,)
def test_is_newer_version_true(app):
"""Test detecting newer version."""
checker = VersionChecker()
assert checker._is_newer_version("1.2.3", "1.2.2") is True
assert checker._is_newer_version("2.0.0", "1.9.9") is True
assert checker._is_newer_version("1.3.0", "1.2.9") is True
def test_is_newer_version_false(app):
"""Test detecting same or older version."""
checker = VersionChecker()
assert checker._is_newer_version("1.2.3", "1.2.3") is False
assert checker._is_newer_version("1.2.2", "1.2.3") is False
assert checker._is_newer_version("0.9.9", "1.0.0") is False
def test_logo_pixmap(app):
"""Test generating logo pixmap."""
checker = VersionChecker()
pixmap = checker._logo_pixmap(96)
assert isinstance(pixmap, QPixmap)
assert not pixmap.isNull()
def test_logo_pixmap_different_sizes(app):
"""Test generating logo pixmap with different sizes."""
checker = VersionChecker()
pixmap_small = checker._logo_pixmap(48)
pixmap_large = checker._logo_pixmap(128)
assert not pixmap_small.isNull()
assert not pixmap_large.isNull()
def test_show_version_dialog(qtbot, app):
"""Test showing version dialog."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
with patch.object(QMessageBox, "exec") as mock_exec:
with patch("importlib.metadata.version", return_value="1.0.0"):
checker.show_version_dialog()
# Dialog should have been shown
assert mock_exec.called
def test_check_for_updates_network_error(qtbot, app):
"""Test check for updates when network request fails."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
with patch("requests.get", side_effect=Exception("Network error")):
with patch.object(QMessageBox, "warning") as mock_warning:
checker.check_for_updates()
# Should show warning
assert mock_warning.called
def test_check_for_updates_empty_response(qtbot, app):
"""Test check for updates with empty version string."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
mock_response = Mock()
mock_response.text = " "
mock_response.raise_for_status = Mock()
with patch("requests.get", return_value=mock_response):
with patch.object(QMessageBox, "warning") as mock_warning:
checker.check_for_updates()
# Should show warning about empty version
assert mock_warning.called
def test_check_for_updates_already_latest(qtbot, app):
"""Test check for updates when already on latest version."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
mock_response = Mock()
mock_response.text = "1.0.0"
mock_response.raise_for_status = Mock()
with patch("requests.get", return_value=mock_response):
with patch("importlib.metadata.version", return_value="1.0.0"):
with patch.object(QMessageBox, "information") as mock_info:
checker.check_for_updates()
# Should show info that we're on latest
assert mock_info.called
def test_check_for_updates_new_version_available_declined(qtbot, app):
"""Test check for updates when new version is available but user declines."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
mock_response = Mock()
mock_response.text = "2.0.0"
mock_response.raise_for_status = Mock()
with patch("requests.get", return_value=mock_response):
with patch("importlib.metadata.version", return_value="1.0.0"):
with patch.object(QMessageBox, "question", return_value=QMessageBox.No):
# Should not proceed to download
checker.check_for_updates()
def test_check_for_updates_new_version_available_accepted(qtbot, app):
"""Test check for updates when new version is available and user accepts."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
mock_response = Mock()
mock_response.text = "2.0.0"
mock_response.raise_for_status = Mock()
with patch("requests.get", return_value=mock_response):
with patch("importlib.metadata.version", return_value="1.0.0"):
with patch.object(QMessageBox, "question", return_value=QMessageBox.Yes):
with patch.object(
checker, "_download_and_verify_appimage"
) as mock_download:
checker.check_for_updates()
# Should call download
mock_download.assert_called_once_with("2.0.0")
def test_download_file_success(qtbot, app, tmp_path):
"""Test downloading a file successfully."""
checker = VersionChecker()
mock_response = Mock()
mock_response.headers = {"Content-Length": "1000"}
mock_response.iter_content = Mock(return_value=[b"data" * 25]) # 100 bytes
mock_response.raise_for_status = Mock()
dest_path = tmp_path / "test_file.bin"
with patch("requests.get", return_value=mock_response):
checker._download_file("http://example.com/file", dest_path)
assert dest_path.exists()
def test_download_file_with_progress(qtbot, app, tmp_path):
"""Test downloading a file with progress dialog."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
mock_response = Mock()
mock_response.headers = {"Content-Length": "1000"}
mock_response.iter_content = Mock(return_value=[b"x" * 100, b"y" * 100])
mock_response.raise_for_status = Mock()
dest_path = tmp_path / "test_file.bin"
from PySide6.QtWidgets import QProgressDialog
mock_progress = Mock(spec=QProgressDialog)
mock_progress.wasCanceled = Mock(return_value=False)
mock_progress.value = Mock(return_value=0)
with patch("requests.get", return_value=mock_response):
checker._download_file(
"http://example.com/file", dest_path, progress=mock_progress
)
# Progress should have been updated
assert mock_progress.setValue.called
def test_download_file_cancelled(qtbot, app, tmp_path):
"""Test cancelling a file download."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
mock_response = Mock()
mock_response.headers = {"Content-Length": "1000"}
mock_response.iter_content = Mock(return_value=[b"x" * 100])
mock_response.raise_for_status = Mock()
dest_path = tmp_path / "test_file.bin"
from PySide6.QtWidgets import QProgressDialog
mock_progress = Mock(spec=QProgressDialog)
mock_progress.wasCanceled = Mock(return_value=True)
mock_progress.value = Mock(return_value=0)
with patch("requests.get", return_value=mock_response):
with pytest.raises(RuntimeError):
checker._download_file(
"http://example.com/file", dest_path, progress=mock_progress
)
def test_download_file_no_content_length(qtbot, app, tmp_path):
"""Test downloading file without Content-Length header."""
checker = VersionChecker()
mock_response = Mock()
mock_response.headers = {}
mock_response.iter_content = Mock(return_value=[b"data"])
mock_response.raise_for_status = Mock()
dest_path = tmp_path / "test_file.bin"
with patch("requests.get", return_value=mock_response):
checker._download_file("http://example.com/file", dest_path)
assert dest_path.exists()
def test_download_and_verify_appimage_download_cancelled(qtbot, app, tmp_path):
"""Test AppImage download when user cancels."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
with patch(
"bouquin.version_check.QStandardPaths.writableLocation",
return_value=str(tmp_path),
):
with patch.object(
checker, "_download_file", side_effect=RuntimeError("Download cancelled")
):
with patch.object(QMessageBox, "information") as mock_info:
checker._download_and_verify_appimage("2.0.0")
# Should show cancellation message
assert mock_info.called
def test_download_and_verify_appimage_download_error(qtbot, app, tmp_path):
"""Test AppImage download when download fails."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
with patch(
"bouquin.version_check.QStandardPaths.writableLocation",
return_value=str(tmp_path),
):
with patch.object(
checker, "_download_file", side_effect=Exception("Network error")
):
with patch.object(QMessageBox, "critical") as mock_critical:
checker._download_and_verify_appimage("2.0.0")
# Should show error message
assert mock_critical.called
def test_download_and_verify_appimage_gpg_key_error(qtbot, app, tmp_path):
"""Test AppImage verification when GPG key cannot be read."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
with patch(
"bouquin.version_check.QStandardPaths.writableLocation",
return_value=str(tmp_path),
):
with patch.object(checker, "_download_file"):
with patch(
"importlib.resources.files", side_effect=Exception("Key not found")
):
with patch.object(QMessageBox, "critical") as mock_critical:
checker._download_and_verify_appimage("2.0.0")
# Should show error about GPG key
assert mock_critical.called
def test_download_and_verify_appimage_gpg_not_found(qtbot, app, tmp_path):
"""Test AppImage verification when GPG is not installed."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
mock_files = Mock()
mock_files.read_bytes = Mock(return_value=b"fake key data")
with patch(
"bouquin.version_check.QStandardPaths.writableLocation",
return_value=str(tmp_path),
):
with patch.object(checker, "_download_file"):
with patch("importlib.resources.files", return_value=mock_files):
with patch(
"subprocess.run", side_effect=FileNotFoundError("gpg not found")
):
with patch.object(QMessageBox, "critical") as mock_critical:
checker._download_and_verify_appimage("2.0.0")
# Should show error about GPG not found
assert mock_critical.called
def test_download_and_verify_appimage_verification_failed(qtbot, app, tmp_path):
"""Test AppImage verification when signature verification fails."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
mock_files = Mock()
mock_files.read_bytes = Mock(return_value=b"fake key data")
with patch(
"bouquin.version_check.QStandardPaths.writableLocation",
return_value=str(tmp_path),
):
with patch.object(checker, "_download_file"):
with patch("importlib.resources.files", return_value=mock_files):
# First subprocess call (import) succeeds, second (verify) fails
mock_error = subprocess.CalledProcessError(1, "gpg")
mock_error.stderr = b"Verification failed"
with patch("subprocess.run", side_effect=[None, mock_error]):
with patch.object(QMessageBox, "critical") as mock_critical:
checker._download_and_verify_appimage("2.0.0")
# Should show error about verification
assert mock_critical.called
def test_download_and_verify_appimage_success(qtbot, app, tmp_path):
"""Test successful AppImage download and verification."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
mock_files = Mock()
mock_files.read_bytes = Mock(return_value=b"fake key data")
with patch(
"bouquin.version_check.QStandardPaths.writableLocation",
return_value=str(tmp_path),
):
with patch.object(checker, "_download_file"):
with patch("importlib.resources.files", return_value=mock_files):
with patch("subprocess.run"): # Both calls succeed
with patch.object(QMessageBox, "information") as mock_info:
checker._download_and_verify_appimage("2.0.0")
# Should show success message
assert mock_info.called
def test_version_comparison_edge_cases(app):
"""Test version comparison with edge cases."""
checker = VersionChecker()
# Different lengths
assert checker._is_newer_version("1.0.0.1", "1.0.0") is True
assert checker._is_newer_version("1.0", "1.0.0") is False
# Large numbers
assert checker._is_newer_version("10.0.0", "9.9.9") is True
assert checker._is_newer_version("1.100.0", "1.99.0") is True
def test_download_file_creates_parent_directory(qtbot, app, tmp_path):
"""Test that download creates parent directory if needed."""
checker = VersionChecker()
mock_response = Mock()
mock_response.headers = {}
mock_response.iter_content = Mock(return_value=[b"data"])
mock_response.raise_for_status = Mock()
dest_path = tmp_path / "subdir" / "nested" / "test_file.bin"
with patch("requests.get", return_value=mock_response):
checker._download_file("http://example.com/file", dest_path)
assert dest_path.exists()
assert dest_path.parent.exists()
def test_show_version_dialog_check_button_clicked(qtbot, app):
"""Test clicking 'Check for updates' button in version dialog."""
parent = QWidget()
qtbot.addWidget(parent)
checker = VersionChecker(parent)
mock_box = Mock(spec=QMessageBox)
check_button = Mock()
mock_box.clickedButton = Mock(return_value=check_button)
mock_box.addButton = Mock(return_value=check_button)
with patch("importlib.metadata.version", return_value="1.0.0"):
with patch("bouquin.version_check.QMessageBox", return_value=mock_box):
with patch.object(checker, "check_for_updates") as mock_check:
checker.show_version_dialog()
# check_for_updates should be called when button is clicked
if mock_box.clickedButton() is check_button:
assert mock_check.called
def test_parse_version_with_letters(app):
"""Test parsing version strings with letters."""
result = VersionChecker._parse_version("1.2.3rc1")
assert 1 in result
assert 2 in result
assert 3 in result
def test_download_file_invalid_content_length(qtbot, app, tmp_path):
"""Test downloading file with invalid Content-Length header."""
checker = VersionChecker()
mock_response = Mock()
mock_response.headers = {"Content-Length": "invalid"}
mock_response.iter_content = Mock(return_value=[b"data"])
mock_response.raise_for_status = Mock()
dest_path = tmp_path / "test_file.bin"
with patch("requests.get", return_value=mock_response):
# Should handle gracefully
checker._download_file("http://example.com/file", dest_path)
assert dest_path.exists()