Compare commits

...

4 commits

Author SHA1 Message Date
c853be5eff
More tests
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-12-23 17:53:26 +11:00
5f18b6daec
prep changelog for debian package 2025-12-23 17:33:19 +11:00
ab1ed55830
README.md clarifications 2025-12-23 17:29:12 +11:00
4ae9797588
Remove unneeded tests-debian-packaging.sh, we have it in CI now 2025-12-23 17:26:36 +11:00
7 changed files with 260 additions and 30 deletions

View file

@ -86,9 +86,10 @@ report from within the app, or optionally to check for new versions to upgrade t
## How to install ## How to install
Make sure you have `libxcb-cursor0` installed (on Debian-based distributions) or `xcb-util-cursor` (RedHat/Fedora-based distributions). Unless you are using the Debian option below:
If downloading from my Forgejo's Releases page, you may wish to verify the GPG signatures with my [GPG key](https://mig5.net/static/mig5.asc). * Make sure you have `libxcb-cursor0` installed (on Debian-based distributions) or `xcb-util-cursor` (RedHat/Fedora-based distributions).
* If downloading from my Forgejo's Releases page, you may wish to verify the GPG signatures with my [GPG key](https://mig5.net/static/mig5.asc).
### Debian 13 ('Trixie') ### Debian 13 ('Trixie')

13
debian/changelog vendored
View file

@ -1,3 +1,16 @@
bouquin (0.8.0) unstable; urgency=medium
* Add .desktop file for Debian
* Fix Pomodoro timer rounding so it rounds up to 0.25, but rounds to closest quarter (up or down)
* Allow setting a code block on a line that already has text (it will start a newline for the codeblock)
* Retain indentation when tab is used to indent a line, unless enter is pressed twice or user deletes the indentation
* Add ability to collapse/expand sections of text.
* Add 'Last Month' date range for timesheet reports
* Add missing strings (for English and French)
* Don't offer to download latest AppImage unless we are running as an AppImage already
-- Miguel Jacq <mig@mig5.net> Tue, 23 Dec 2025 17:30:00 +1100
bouquin (0.7.5) unstable; urgency=medium bouquin (0.7.5) unstable; urgency=medium
* Add libxcb-cursor0 dependency * Add libxcb-cursor0 dependency

View file

@ -1,25 +0,0 @@
#!/bin/bash
set -eou pipefail
DISTS=(
debian:trixie
)
for dist in ${DISTS[@]}; do
release=$(echo ${dist} | cut -d: -f2)
mkdir -p dist/${release}
docker build -f Dockerfile.debbuild -t bouquin-deb:${release} \
--no-cache \
--progress=plain \
--build-arg BASE_IMAGE=${dist} .
docker run --rm \
-e SUITE="${release}" \
-v "$PWD":/src \
-v "$PWD/dist/${release}":/out \
bouquin-deb:${release}
debfile=$(ls -1 dist/${release}/*.deb)
done

View file

@ -3,8 +3,8 @@ from bouquin.code_block_editor_dialog import (
CodeBlockEditorDialog, CodeBlockEditorDialog,
CodeEditorWithLineNumbers, CodeEditorWithLineNumbers,
) )
from PySide6.QtCore import QRect, QSize from PySide6.QtCore import QRect, QSize, Qt
from PySide6.QtGui import QFont, QPaintEvent from PySide6.QtGui import QFont, QPaintEvent, QTextCursor
from PySide6.QtWidgets import QPushButton from PySide6.QtWidgets import QPushButton
@ -323,3 +323,42 @@ def test_code_editor_viewport_margins(qtbot, app):
assert margins.top() == 0 assert margins.top() == 0
assert margins.right() == 0 assert margins.right() == 0
assert margins.bottom() == 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() == ""

View file

@ -150,6 +150,53 @@ def test_enter_on_nonempty_list_continues(qtbot, editor):
assert "\n\u2022 " in txt 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): def test_enter_on_empty_list_marks_empty(qtbot, editor):
qtbot.addWidget(editor) qtbot.addWidget(editor)
editor.show() editor.show()
@ -181,6 +228,116 @@ def test_triple_backtick_triggers_code_dialog_but_no_block_on_empty_code(editor,
assert t == "" 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() == "<!-- bouquin:collapse:end -->"
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): def test_down_escapes_from_last_code_line(editor, qtbot):
editor.from_markdown("```\nLINE\n```\n") editor.from_markdown("```\nLINE\n```\n")
# Put caret at end of "LINE" # Put caret at end of "LINE"

View file

@ -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): 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) parent = DummyMainWindow(app)
qtbot.addWidget(parent) qtbot.addWidget(parent)
qtbot.addWidget(parent.time_log) 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) 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( def test_pomodoro_manager_on_timer_stopped_prefills_note(
qtbot, app, fresh_db, monkeypatch qtbot, app, fresh_db, monkeypatch
): ):

View file

@ -1211,6 +1211,26 @@ def test_time_report_dialog_default_date_range(qtbot, fresh_db):
assert dialog.to_date.date() == today 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): def test_time_report_dialog_run_report(qtbot, fresh_db):
"""Run a time report.""" """Run a time report."""
strings.load_strings("en") strings.load_strings("en")