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)