- Add parse_date_arg() to utils.py supporting YYYY-MM-DD, MM-DD, and DD-MM formats with either - or / as separator - Add AmbiguousDateError for two-part dates valid as both MM-DD and DD-MM - Replace --day flag with a positional optional argument (defaults to today) - Remove old _parse_date() helper from cli.py
174 lines
5.5 KiB
Python
174 lines
5.5 KiB
Python
from datetime import date
|
|
|
|
import pytest
|
|
|
|
from timesheets.utils import (
|
|
AmbiguousDateError,
|
|
decimal_to_hhmm,
|
|
duration_from_start_end,
|
|
format_date,
|
|
parse_date_arg,
|
|
parse_duration,
|
|
strip_markdown_link,
|
|
)
|
|
|
|
|
|
class TestParseDuration:
|
|
def test_basic(self):
|
|
assert parse_duration("01:30") == 1.5
|
|
|
|
def test_zero(self):
|
|
assert parse_duration("00:00") == 0.0
|
|
|
|
def test_minutes_only(self):
|
|
assert parse_duration("00:15") == 0.25
|
|
|
|
def test_multidigit_hours(self):
|
|
assert parse_duration("10:00") == 10.0
|
|
|
|
def test_strips_whitespace(self):
|
|
assert parse_duration(" 01:00 ") == 1.0
|
|
|
|
@pytest.mark.parametrize("bad", ["1:5", "abc", "1:00:00", ""])
|
|
def test_invalid_raises(self, bad):
|
|
with pytest.raises(ValueError):
|
|
parse_duration(bad)
|
|
|
|
|
|
class TestDurationFromStartEnd:
|
|
def test_basic(self):
|
|
assert duration_from_start_end("08:00", "09:00") == 1.0
|
|
|
|
def test_partial_hour(self):
|
|
assert duration_from_start_end("08:15", "08:30") == 0.25
|
|
|
|
def test_midnight_rollover(self):
|
|
assert duration_from_start_end("23:45", "00:15") == 0.5
|
|
|
|
def test_same_time(self):
|
|
assert duration_from_start_end("09:00", "09:00") == 0.0
|
|
|
|
@pytest.mark.parametrize("bad_start,bad_end", [("9:0", "10:00"), ("08:00", "10:0")])
|
|
def test_invalid_raises(self, bad_start, bad_end):
|
|
with pytest.raises(ValueError):
|
|
duration_from_start_end(bad_start, bad_end)
|
|
|
|
|
|
class TestDecimalToHhmm:
|
|
@pytest.mark.parametrize(
|
|
"hours,expected",
|
|
[
|
|
(1.0, "01:00"),
|
|
(1.5, "01:30"),
|
|
(0.25, "00:15"),
|
|
(0.0, "00:00"),
|
|
(10.0, "10:00"),
|
|
# rounding: 0.1666... hours = 10 minutes
|
|
(1 / 6, "00:10"),
|
|
],
|
|
)
|
|
def test_conversion(self, hours, expected):
|
|
assert decimal_to_hhmm(hours) == expected
|
|
|
|
|
|
class TestStripMarkdownLink:
|
|
def test_strips_link(self):
|
|
assert strip_markdown_link("[foo bar](http://example.com)") == "foo bar"
|
|
|
|
def test_strips_joplin_style_link(self):
|
|
text = "[29497: collector auto update](:/0ce89020cd874f0281a71f62b9d7b75f)"
|
|
assert strip_markdown_link(text) == "29497: collector auto update"
|
|
|
|
def test_plain_text_unchanged(self):
|
|
assert strip_markdown_link("just plain text") == "just plain text"
|
|
|
|
def test_empty_string(self):
|
|
assert strip_markdown_link("") == ""
|
|
|
|
def test_multiple_links(self):
|
|
text = "[a](url1) and [b](url2)"
|
|
assert strip_markdown_link(text) == "a and b"
|
|
|
|
def test_partial_link_unchanged(self):
|
|
# Missing closing paren — not a valid link, should be left alone
|
|
assert strip_markdown_link("[foo](bar") == "[foo](bar"
|
|
|
|
|
|
class TestFormatDate:
|
|
def test_format(self):
|
|
assert format_date(date(2026, 3, 22)) == "22/03/26"
|
|
|
|
def test_single_digit_day_month(self):
|
|
assert format_date(date(2026, 1, 5)) == "05/01/26"
|
|
|
|
|
|
class TestParseDateArg:
|
|
# Fix the year used for two-part tests to avoid test brittleness
|
|
THIS_YEAR = 2026
|
|
|
|
def _parse(self, value: str) -> date:
|
|
# Patch date.today so two-part tests are year-stable
|
|
from unittest.mock import patch
|
|
|
|
with patch("timesheets.utils.date") as mock_date:
|
|
mock_date.today.return_value = date(self.THIS_YEAR, 6, 1)
|
|
mock_date.side_effect = lambda *a, **kw: date(*a, **kw)
|
|
return parse_date_arg(value)
|
|
|
|
# --- YYYY-MM-DD ---
|
|
def test_full_dash(self):
|
|
assert parse_date_arg("2026-05-22") == date(2026, 5, 22)
|
|
|
|
def test_full_slash(self):
|
|
assert parse_date_arg("2026/05/22") == date(2026, 5, 22)
|
|
|
|
def test_full_invalid_day(self):
|
|
with pytest.raises(ValueError):
|
|
parse_date_arg("2026-02-30")
|
|
|
|
# --- two-part unambiguous: only valid as MM-DD ---
|
|
def test_mm_dd_unambiguous(self):
|
|
# 01-22: valid as MM=1, DD=22; invalid as MM=22, DD=1
|
|
result = self._parse("01-22")
|
|
assert result == date(self.THIS_YEAR, 1, 22)
|
|
|
|
# --- two-part unambiguous: only valid as DD-MM ---
|
|
def test_dd_mm_unambiguous(self):
|
|
# 22-01: invalid as MM=22, DD=1 is actually valid... use 22-13
|
|
# 22-13: valid as DD=22, MM=13 is invalid; DD=13 MM=22 is invalid too
|
|
# Use 30-11: valid as DD=30,MM=11; invalid as MM=30,DD=11
|
|
result = self._parse("30-11")
|
|
assert result == date(self.THIS_YEAR, 11, 30)
|
|
|
|
def test_slash_separator(self):
|
|
result = self._parse("01/22")
|
|
assert result == date(self.THIS_YEAR, 1, 22)
|
|
|
|
# --- ambiguous ---
|
|
def test_ambiguous_raises(self):
|
|
# 05-06: valid as MM=5,DD=6 and as DD=5,MM=6
|
|
with pytest.raises(AmbiguousDateError, match="ambiguous"):
|
|
self._parse("05-06")
|
|
|
|
def test_ambiguous_slash_raises(self):
|
|
with pytest.raises(AmbiguousDateError):
|
|
self._parse("05/06")
|
|
|
|
# --- identical interpretations are not ambiguous ---
|
|
def test_same_day_month_not_ambiguous(self):
|
|
# 05-05: MM=5,DD=5 and DD=5,MM=5 are the same date
|
|
result = self._parse("05-05")
|
|
assert result == date(self.THIS_YEAR, 5, 5)
|
|
|
|
# --- fully invalid ---
|
|
def test_invalid_both_parts(self):
|
|
# 13-32 is invalid as both MM-DD and DD-MM
|
|
with pytest.raises(ValueError):
|
|
self._parse("13-32")
|
|
|
|
def test_unrecognised_format(self):
|
|
with pytest.raises(ValueError, match="Unrecognised"):
|
|
parse_date_arg("not-a-date")
|
|
|
|
def test_whitespace_stripped(self):
|
|
assert parse_date_arg(" 2026-05-22 ") == date(2026, 5, 22)
|