From c853be5eff764de94b9f8d4061c5279dc930c1aa Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Tue, 23 Dec 2025 17:53:26 +1100 Subject: [PATCH] More tests --- tests/test_code_block_editor_dialog.py | 43 ++++++- tests/test_markdown_editor.py | 157 +++++++++++++++++++++++++ tests/test_pomodoro_timer.py | 27 ++++- tests/test_time_log.py | 20 ++++ 4 files changed, 244 insertions(+), 3 deletions(-) diff --git a/tests/test_code_block_editor_dialog.py b/tests/test_code_block_editor_dialog.py index e64199b..1ced14c 100644 --- a/tests/test_code_block_editor_dialog.py +++ b/tests/test_code_block_editor_dialog.py @@ -3,8 +3,8 @@ from bouquin.code_block_editor_dialog import ( CodeBlockEditorDialog, CodeEditorWithLineNumbers, ) -from PySide6.QtCore import QRect, QSize -from PySide6.QtGui import QFont, QPaintEvent +from PySide6.QtCore import QRect, QSize, Qt +from PySide6.QtGui import QFont, QPaintEvent, QTextCursor from PySide6.QtWidgets import QPushButton @@ -323,3 +323,42 @@ def test_code_editor_viewport_margins(qtbot, app): assert margins.top() == 0 assert margins.right() == 0 assert margins.bottom() == 0 + + +def test_code_editor_retains_indentation_on_enter(qtbot, app): + """Pressing Enter on an indented line retains indentation in code editor.""" + editor = CodeEditorWithLineNumbers() + qtbot.addWidget(editor) + editor.setPlainText("\tfoo") + editor.show() + + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.End) + editor.setTextCursor(cursor) + + qtbot.keyPress(editor, Qt.Key_Return) + qtbot.wait(0) + + assert editor.toPlainText().endswith("\tfoo\n\t") + + +def test_code_editor_double_enter_on_empty_indent_resets(qtbot, app): + """Second Enter on an indentation-only line clears the indent in code editor.""" + editor = CodeEditorWithLineNumbers() + qtbot.addWidget(editor) + editor.setPlainText("\tfoo") + editor.show() + + cursor = editor.textCursor() + cursor.movePosition(QTextCursor.End) + editor.setTextCursor(cursor) + + qtbot.keyPress(editor, Qt.Key_Return) + qtbot.wait(0) + assert editor.toPlainText().endswith("\tfoo\n\t") + + qtbot.keyPress(editor, Qt.Key_Return) + qtbot.wait(0) + + assert editor.toPlainText().endswith("\tfoo\n\n") + assert editor.textCursor().block().text() == "" diff --git a/tests/test_markdown_editor.py b/tests/test_markdown_editor.py index dcacbc5..a36a09e 100644 --- a/tests/test_markdown_editor.py +++ b/tests/test_markdown_editor.py @@ -150,6 +150,53 @@ def test_enter_on_nonempty_list_continues(qtbot, editor): assert "\n\u2022 " in txt +def test_tab_indentation_is_retained_on_newline(editor, qtbot): + """Pressing Enter on an indented line should retain the indentation.""" + qtbot.addWidget(editor) + editor.show() + editor.setPlainText("\tfoo") + editor.moveCursor(QTextCursor.End) + + qtbot.keyPress(editor, Qt.Key_Return) + qtbot.wait(0) + + assert editor.toPlainText().endswith("\tfoo\n\t") + + +def test_double_enter_on_empty_indented_line_resets_indent(editor, qtbot): + """A second Enter on an indentation-only line should reset to column 0.""" + qtbot.addWidget(editor) + editor.show() + editor.setPlainText("\tfoo") + editor.moveCursor(QTextCursor.End) + + # First Enter inserts a new indented line + qtbot.keyPress(editor, Qt.Key_Return) + qtbot.wait(0) + assert editor.toPlainText().endswith("\tfoo\n\t") + + # Second Enter on the now-empty indented line removes the indent + qtbot.keyPress(editor, Qt.Key_Return) + qtbot.wait(0) + + assert editor.toPlainText().endswith("\tfoo\n\n") + # Cursor should be on a fresh unindented blank line + assert editor.textCursor().block().text() == "" + + +def test_nested_list_continuation_preserves_indentation(editor, qtbot): + """Enter on an indented bullet should keep indent + bullet prefix.""" + qtbot.addWidget(editor) + editor.show() + editor.from_markdown("\t- item") + editor.moveCursor(QTextCursor.End) + + qtbot.keyPress(editor, Qt.Key_Return) + qtbot.wait(0) + + assert "\n\t\u2022 " in editor.toPlainText() + + def test_enter_on_empty_list_marks_empty(qtbot, editor): qtbot.addWidget(editor) editor.show() @@ -181,6 +228,116 @@ def test_triple_backtick_triggers_code_dialog_but_no_block_on_empty_code(editor, assert t == "" +def _find_first_block(doc, predicate): + b = doc.begin() + while b.isValid(): + if predicate(b): + return b + b = b.next() + return None + + +def test_collapse_selection_wraps_and_hides_blocks(editor, qtbot): + """Collapsing a selection should insert header/end marker and hide content.""" + qtbot.addWidget(editor) + editor.show() + editor.setPlainText("a\nb\nc\n") + doc = editor.document() + + # Select lines b..c + b_block = doc.findBlockByNumber(1) + c_block = doc.findBlockByNumber(2) + cur = editor.textCursor() + cur.setPosition(b_block.position()) + cur.setPosition(c_block.position() + c_block.length() - 1, QTextCursor.KeepAnchor) + editor.setTextCursor(cur) + + editor.collapse_selection() + qtbot.wait(0) + + # Header and end marker should exist as their own blocks + header = _find_first_block(doc, lambda bl: bl.text().lstrip().startswith("▸")) + assert header is not None + assert "▸" in header.text() and "expand" in header.text() + + end_marker = _find_first_block(doc, lambda bl: "bouquin:collapse:end" in bl.text()) + assert end_marker is not None + + # Inner blocks should be hidden; end marker always hidden + inner1 = header.next() + inner2 = inner1.next() + assert inner1.text() == "b" + assert inner2.text() == "c" + assert inner1.isVisible() is False + assert inner2.isVisible() is False + assert end_marker.isVisible() is False + + +def test_toggle_collapse_expands_and_updates_header(editor, qtbot): + """Toggling a collapse header should reveal hidden blocks and flip label.""" + qtbot.addWidget(editor) + editor.show() + editor.setPlainText("a\nb\nc\n") + doc = editor.document() + + # Select b..c and collapse + b_block = doc.findBlockByNumber(1) + c_block = doc.findBlockByNumber(2) + cur = editor.textCursor() + cur.setPosition(b_block.position(), QTextCursor.MoveMode.MoveAnchor) + cur.setPosition( + c_block.position() + c_block.length() - 1, QTextCursor.MoveMode.KeepAnchor + ) + editor.setTextCursor(cur) + editor.collapse_selection() + qtbot.wait(0) + + header = _find_first_block(doc, lambda bl: bl.text().lstrip().startswith("▸")) + assert header is not None + + # Toggle to expand + editor._toggle_collapse_at_block(header) + qtbot.wait(0) + + header2 = doc.findBlock(header.position()) + assert "▾" in header2.text() and "collapse" in header2.text() + assert header2.next().isVisible() is True + assert header2.next().next().isVisible() is True + + +def test_collapse_selection_without_trailing_newline_keeps_marker_on_own_line( + editor, qtbot +): + """Selections reaching EOF without a trailing newline should still fold correctly.""" + qtbot.addWidget(editor) + editor.show() + editor.setPlainText("a\nb\nc") # no trailing newline + doc = editor.document() + + # Bottom-up selection of last two lines (c..b) + b_block = doc.findBlockByNumber(1) + c_block = doc.findBlockByNumber(2) + cur = editor.textCursor() + cur.setPosition(c_block.position() + len(c_block.text())) + cur.setPosition(b_block.position(), QTextCursor.KeepAnchor) + editor.setTextCursor(cur) + + editor.collapse_selection() + qtbot.wait(0) + + end_marker = _find_first_block(doc, lambda bl: "bouquin:collapse:end" in bl.text()) + assert end_marker is not None + + # End marker is its own block, and remains hidden + assert end_marker.text().strip() == "" + assert end_marker.isVisible() is False + + # The last content line should be hidden (folded) + header = _find_first_block(doc, lambda bl: bl.text().lstrip().startswith("▸")) + assert header is not None + assert header.next().isVisible() is False + + def test_down_escapes_from_last_code_line(editor, qtbot): editor.from_markdown("```\nLINE\n```\n") # Put caret at end of "LINE" diff --git a/tests/test_pomodoro_timer.py b/tests/test_pomodoro_timer.py index 1c2e450..1dd4d95 100644 --- a/tests/test_pomodoro_timer.py +++ b/tests/test_pomodoro_timer.py @@ -276,7 +276,7 @@ def test_pomodoro_manager_on_timer_stopped_minimum_hours( def test_pomodoro_manager_on_timer_stopped_rounding(qtbot, app, fresh_db, monkeypatch): - """Elapsed time should be rounded up to the nearest 0.25 hours.""" + """Elapsed time should be rounded to a 0.25-hour increment.""" parent = DummyMainWindow(app) qtbot.addWidget(parent) qtbot.addWidget(parent.time_log) @@ -300,6 +300,31 @@ def test_pomodoro_manager_on_timer_stopped_rounding(qtbot, app, fresh_db, monkey assert hours_set * 4 == int(hours_set * 4) +def test_seconds_to_logged_hours_nearest_quarter_rounding(): + """Seconds -> hours uses nearest-quarter rounding with a 15-min minimum.""" + # Import the pure conversion helper directly (no Qt required) + from bouquin.pomodoro_timer import PomodoroManager + + # <15 minutes always rounds up to 0.25 + assert PomodoroManager._seconds_to_logged_hours(1) == 0.25 + assert PomodoroManager._seconds_to_logged_hours(899) == 0.25 + + # 15 minutes exact + assert PomodoroManager._seconds_to_logged_hours(900) == 0.25 + + # Examples from the spec: closest quarter-hour + # 33 minutes -> closer to 0.50 than 0.75 + assert PomodoroManager._seconds_to_logged_hours(33 * 60) == 0.50 + # 40 minutes -> closer to 0.75 than 0.50 + assert PomodoroManager._seconds_to_logged_hours(40 * 60) == 0.75 + + # Halfway case: 22.5 min is exactly between 0.25 and 0.50 -> round up + assert PomodoroManager._seconds_to_logged_hours(int(22.5 * 60)) == 0.50 + + # Sanity: 1 hour stays 1.0 + assert PomodoroManager._seconds_to_logged_hours(60 * 60) == 1.00 + + def test_pomodoro_manager_on_timer_stopped_prefills_note( qtbot, app, fresh_db, monkeypatch ): diff --git a/tests/test_time_log.py b/tests/test_time_log.py index 45db626..b03029e 100644 --- a/tests/test_time_log.py +++ b/tests/test_time_log.py @@ -1211,6 +1211,26 @@ def test_time_report_dialog_default_date_range(qtbot, fresh_db): assert dialog.to_date.date() == today +def test_time_report_dialog_last_month_preset_sets_full_previous_month(qtbot, fresh_db): + """Selecting 'Last month' sets the date range to the previous calendar month.""" + strings.load_strings("en") + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + idx = dialog.range_preset.findData("last_month") + assert idx != -1 + + today = QDate.currentDate() + start_of_this_month = QDate(today.year(), today.month(), 1) + expected_start = start_of_this_month.addMonths(-1) + expected_end = start_of_this_month.addDays(-1) + + dialog.range_preset.setCurrentIndex(idx) + + assert dialog.from_date.date() == expected_start + assert dialog.to_date.date() == expected_end + + def test_time_report_dialog_run_report(qtbot, fresh_db): """Run a time report.""" strings.load_strings("en")