import pytest from timesheets.parser import ( aggregate_rows, build_description, detect_has_duration_column, parse_table, ) # --------------------------------------------------------------------------- # Fixtures / shared data # --------------------------------------------------------------------------- WITH_DURATION = [ "| Start | End | Duration | Project | Story | Note |", "|-------|-------|----------|---------|-------------|---------|", "| 08:00 | 08:30 | 00:30 | bugs | story one | |", "| 08:30 | 09:00 | 00:30 | bugs | story one | |", "| 09:00 | 09:15 | 00:15 | scrum | | dsu |", ] WITHOUT_DURATION = [ "| Start | End | Project | Story | Note |", "|-------|-------|---------|-------------|---------|", "| 08:00 | 08:30 | bugs | story one | |", "| 08:30 | 09:15 | scrum | | dsu |", ] # --------------------------------------------------------------------------- # detect_has_duration_column # --------------------------------------------------------------------------- class TestDetectHasDurationColumn: def test_with_duration(self): assert detect_has_duration_column(WITH_DURATION) is True def test_without_duration(self): assert detect_has_duration_column(WITHOUT_DURATION) is False def test_no_header_defaults_to_true(self): assert detect_has_duration_column(["no table here"]) is True def test_case_insensitive(self): lines = ["| Start | End | DURATION | Project | Story | Note |"] assert detect_has_duration_column(lines) is True # --------------------------------------------------------------------------- # parse_table # --------------------------------------------------------------------------- class TestParseTable: def test_with_duration_column(self): rows = parse_table(WITH_DURATION, has_duration_col=True) assert len(rows) == 3 assert rows[0]["project"] == "bugs" assert rows[0]["duration_hours"] == 0.5 assert rows[2]["project"] == "scrum" assert rows[2]["note"] == "dsu" def test_without_duration_column(self): rows = parse_table(WITHOUT_DURATION, has_duration_col=False) assert len(rows) == 2 assert rows[0]["duration_hours"] == 0.5 # 08:00–08:30 assert rows[1]["duration_hours"] == 0.75 # 08:30–09:15 def test_header_row_skipped(self): rows = parse_table(WITH_DURATION) assert all(r["start"] != "Start" for r in rows) def test_separator_row_skipped(self): rows = parse_table(WITH_DURATION) assert all(r["start"] != "---" for r in rows) def test_markdown_link_stripped_in_story(self): lines = [ "| Start | End | Duration | Project | Story | Note |", "|-------|-------|----------|---------|----------------------------|------|", "| 08:00 | 08:30 | 00:30 | bugs | [ticket 1](:/abc123) | |", ] rows = parse_table(lines) assert rows[0]["story"] == "ticket 1" def test_invalid_duration_row_skipped(self): lines = [ "| Start | End | Duration | Project | Story | Note |", "|-------|-------|----------|---------|-------|------|", "| 08:00 | 08:30 | bad | bugs | | |", ] assert parse_table(lines) == [] def test_empty_input(self): assert parse_table([]) == [] def test_non_table_lines_ignored(self): lines = ["# My Timesheet", "", "Some prose."] + WITH_DURATION rows = parse_table(lines) assert len(rows) == 3 # --------------------------------------------------------------------------- # build_description # --------------------------------------------------------------------------- class TestBuildDescription: def test_story_and_note(self): assert build_description("story", "note") == "story - note" def test_story_only(self): assert build_description("story", "") == "story" def test_note_only(self): assert build_description("", "note") == "note" def test_both_empty(self): assert build_description("", "") == "/" def test_strips_whitespace(self): assert build_description(" story ", " note ") == "story - note" # --------------------------------------------------------------------------- # aggregate_rows # --------------------------------------------------------------------------- class TestAggregateRows: def test_same_project_story_summed(self): rows = parse_table(WITH_DURATION) aggregated = aggregate_rows(rows) bugs = next(e for e in aggregated if e["project"] == "bugs") assert bugs["quantity"] == 1.0 # 00:30 + 00:30 def test_distinct_entries_preserved(self): rows = parse_table(WITH_DURATION) aggregated = aggregate_rows(rows) assert len(aggregated) == 2 # bugs/story-one and scrum/dsu def test_insertion_order_preserved(self): rows = parse_table(WITH_DURATION) aggregated = aggregate_rows(rows) assert aggregated[0]["project"] == "bugs" assert aggregated[1]["project"] == "scrum" def test_empty_input(self): assert aggregate_rows([]) == []