import csv import io import pytest from timesheets.output import print_stories, print_summary, to_csv_entries, write_csv # --------------------------------------------------------------------------- # Shared fixtures # --------------------------------------------------------------------------- PROJECT_MAP = { "bugs": {"Project": "[Factry] Historian", "Task": "[Historian] Bugs"}, } AGGREGATED = [ {"project": "bugs", "description": "ticket 1", "quantity": 1.0}, {"project": "bugs", "description": "ticket 2", "quantity": 0.5}, {"project": "scrum", "description": "dsu", "quantity": 0.25}, ] # --------------------------------------------------------------------------- # to_csv_entries # --------------------------------------------------------------------------- def _raw_row(project, story, note, duration_hours): return { "project": project, "story": story, "story_raw": story, "note": note, "start": "09:00", "end": "10:00" if duration_hours is not None else None, "duration_hours": duration_hours, } class TestToCsvEntries: def test_basic_conversion(self): rows = [_raw_row("bugs", "ticket 1", "", 1.0)] assert to_csv_entries(rows) == [ {"project": "bugs", "description": "ticket 1", "quantity": 1.0} ] def test_skips_open_entries(self): rows = [_raw_row("bugs", "ticket 1", "", None)] assert to_csv_entries(rows) == [] def test_does_not_aggregate(self): rows = [ _raw_row("bugs", "ticket 1", "", 0.5), _raw_row("bugs", "ticket 1", "", 0.5), ] entries = to_csv_entries(rows) assert len(entries) == 2 assert entries[0]["quantity"] == 0.5 assert entries[1]["quantity"] == 0.5 def test_description_combines_story_and_note(self): rows = [_raw_row("bugs", "ticket 1", "fix", 1.0)] assert to_csv_entries(rows)[0]["description"] == "ticket 1 - fix" def test_project_stripped(self): rows = [_raw_row(" bugs ", "", "dsu", 0.25)] assert to_csv_entries(rows)[0]["project"] == "bugs" def test_mixed_open_and_closed(self): rows = [ _raw_row("bugs", "ticket 1", "", 1.0), _raw_row("bugs", "ticket 2", "", None), _raw_row("scrum", "", "dsu", 0.25), ] entries = to_csv_entries(rows) assert len(entries) == 2 assert entries[0]["description"] == "ticket 1" assert entries[1]["description"] == "dsu" # --------------------------------------------------------------------------- # write_csv # --------------------------------------------------------------------------- class TestWriteCsv: def _run(self, aggregated=None, date_str="22/03/26", project_map=None): buf = io.StringIO() write_csv( aggregated or AGGREGATED, buf, date_str, project_map or {}, ) buf.seek(0) return list(csv.reader(buf)) def test_header_row(self): rows = self._run() assert rows[0] == ["Date*", "Project*", "Task", "Description", "Quantity"] def test_row_count(self): rows = self._run() assert len(rows) == 1 + len(AGGREGATED) def test_date_column(self): rows = self._run(date_str="01/01/26") assert all(r[0] == "01/01/26" for r in rows[1:]) def test_quantity_format(self): rows = self._run() assert rows[1][4] == "1.00" assert rows[2][4] == "0.50" def test_project_map_applied(self): rows = self._run(project_map=PROJECT_MAP) assert rows[1][1] == "[Factry] Historian" assert rows[1][2] == "[Historian] Bugs" def test_unmapped_project_fallback(self): rows = self._run(project_map=PROJECT_MAP) # "scrum" is not in the map scrum_row = next(r for r in rows[1:] if "dsu" in r) assert scrum_row[1] == "scrum" assert scrum_row[2] == "" def test_description_column(self): rows = self._run() assert rows[1][3] == "ticket 1" # --------------------------------------------------------------------------- # print_summary # --------------------------------------------------------------------------- class TestPrintSummary: def _run(self, aggregated=None, project_map=None, capsys=None): print_summary(aggregated or AGGREGATED, project_map or {}) return capsys.readouterr().out def test_contains_project_label(self, capsys): out = self._run(capsys=capsys) assert "scrum" in out def test_contains_mapped_project_label(self, capsys): out = self._run(project_map=PROJECT_MAP, capsys=capsys) assert "[Factry] Historian" in out assert "[Historian] Bugs" in out def test_contains_description(self, capsys): out = self._run(capsys=capsys) assert "ticket 1" in out assert "dsu" in out def test_contains_total(self, capsys): out = self._run(capsys=capsys) assert "TOTAL" in out # 1.0 + 0.5 + 0.25 = 1.75 hours = 01:45 assert "01:45" in out def test_project_subtotal(self, capsys): out = self._run(capsys=capsys) # bugs total = 1.0 + 0.5 = 1.5 hours = 01:30 assert "01:30" in out def test_hhmm_durations_shown(self, capsys): out = self._run(capsys=capsys) assert "01:00" in out assert "00:30" in out assert "00:15" in out # --------------------------------------------------------------------------- # print_stories # --------------------------------------------------------------------------- def _story_row(project, story, story_raw, hours): return { "project": project, "story": story, "story_raw": story_raw, "note": "", "duration_hours": hours, } class TestPrintStories: def test_basic_output(self, capsys): rows = [_story_row("bugs", "ticket 1", "ticket 1", 1.0)] print_stories(rows) out = capsys.readouterr().out assert "- bugs" in out assert " - ticket 1" in out assert "01:00" in out def test_rows_without_story_excluded(self, capsys): rows = [ _story_row("bugs", "ticket 1", "ticket 1", 1.0), { "project": "scrum", "story": "", "story_raw": "", "note": "dsu", "duration_hours": 0.25, }, ] print_stories(rows) out = capsys.readouterr().out assert "scrum" not in out assert "dsu" not in out def test_deduplication_sums_hours(self, capsys): rows = [ _story_row("bugs", "ticket 1", "ticket 1", 0.5), _story_row("bugs", "ticket 1", "ticket 1", 0.5), ] print_stories(rows) out = capsys.readouterr().out assert out.count("ticket 1") == 1 assert "01:00" in out def test_markdown_link_preserved(self, capsys): rows = [ _story_row("bugs", "ticket 1", "ticket 1", 0.5), _story_row("bugs", "ticket 1", "[ticket 1](:/abc123)", 0.5), ] print_stories(rows) out = capsys.readouterr().out assert "[ticket 1](:/abc123)" in out def test_grouped_by_project(self, capsys): rows = [ _story_row("bugs", "ticket 1", "ticket 1", 1.0), _story_row("rate", "story A", "story A", 2.0), ] print_stories(rows) out = capsys.readouterr().out assert "- bugs" in out assert "- rate" in out bugs_line = next(l for l in out.splitlines() if "bugs" in l) rate_line = next(l for l in out.splitlines() if "rate" in l) assert not bugs_line.startswith(" ") # top-level assert not rate_line.startswith(" ") def test_empty_rows(self, capsys): print_stories([]) out = capsys.readouterr().out assert out == ""