odoo-timesheets/tests/test_output.py
Jef Roosens 2d60624e0e
feat(stories): add stories subcommand
- Add stories subcommand listing stories worked on, grouped by project
- Preserve story_raw in parser row dicts alongside the stripped story,
  so markdown links are available for display
- print_stories() filters to rows with a non-empty story field,
  deduplicates by stripped story text (preferring the linked version),
  sums hours per story, and outputs an indented Markdown list
- Project names resolved through project_map (same as csv/summary)
- -w/--weekly flag aggregates stories across the full week
- Add tests for print_stories covering deduplication, link preservation,
  grouping, empty rows, and story-less row exclusion
- Fix flex daily target in status: use projected hours per prior day
  rather than fixed 8h when computing remaining hours for today
2026-06-02 09:31:09 +02:00

192 lines
5.9 KiB
Python

import csv
import io
import pytest
from timesheets.output import print_stories, print_summary, 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},
]
# ---------------------------------------------------------------------------
# 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 == ""