Prefix the project name or note column with ~ to mark an entry as count-but-don't-export. Marked entries are included in summary and status totals but omitted from all csv output (both --raw and aggregated, single-day and weekly). | 09:00 | 17:00 | 8:00 | ~Leave | | Day off | | 09:00 | 17:00 | 8:00 | Leave | | ~Day off | The ~ is stripped from whichever field carries it before any downstream processing, so project map resolution is unaffected. Implementation: - parse_table sets skip_csv=True on marked rows and strips the ~ - new filter_skip_csv() helper in parser.py - to_csv_entries() skips skip_csv rows - _cmd_csv calls filter_skip_csv() before aggregate_rows()
332 lines
10 KiB
Python
332 lines
10 KiB
Python
import csv
|
|
import io
|
|
|
|
import pytest
|
|
|
|
from timesheets.output import (
|
|
print_stories,
|
|
print_summary,
|
|
to_csv_entries,
|
|
write_csv,
|
|
write_csv_weekly,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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_skips_skip_csv_rows(self):
|
|
row = {**_raw_row("Leave", "", "Day off", 8.0), "skip_csv": True}
|
|
assert to_csv_entries([row]) == []
|
|
|
|
def test_skip_csv_row_mixed_with_normal(self):
|
|
rows = [
|
|
{**_raw_row("Leave", "", "Day off", 8.0), "skip_csv": True},
|
|
_raw_row("bugs", "ticket 1", "", 1.0),
|
|
]
|
|
entries = to_csv_entries(rows)
|
|
assert len(entries) == 1
|
|
assert entries[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"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# write_csv_weekly
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
DAY_SECTIONS = [
|
|
(
|
|
"22/03/26",
|
|
[
|
|
{"project": "bugs", "description": "ticket 1", "quantity": 1.0},
|
|
{"project": "scrum", "description": "dsu", "quantity": 0.25},
|
|
],
|
|
),
|
|
(
|
|
"23/03/26",
|
|
[
|
|
{"project": "bugs", "description": "ticket 2", "quantity": 0.5},
|
|
],
|
|
),
|
|
]
|
|
|
|
|
|
class TestWriteCsvWeekly:
|
|
def _run(self, day_sections=None, project_map=None):
|
|
buf = io.StringIO()
|
|
write_csv_weekly(
|
|
DAY_SECTIONS if day_sections is None else day_sections,
|
|
buf,
|
|
project_map or {},
|
|
)
|
|
buf.seek(0)
|
|
return list(csv.reader(buf))
|
|
|
|
def test_header_written_once(self):
|
|
rows = self._run()
|
|
assert rows[0] == ["Date*", "Project*", "Task", "Description", "Quantity"]
|
|
assert sum(1 for r in rows if r[0] == "Date*") == 1
|
|
|
|
def test_row_count(self):
|
|
rows = self._run()
|
|
assert len(rows) == 1 + 3 # header + 3 entries across 2 days
|
|
|
|
def test_correct_date_per_row(self):
|
|
rows = self._run()
|
|
assert rows[1][0] == "22/03/26"
|
|
assert rows[2][0] == "22/03/26"
|
|
assert rows[3][0] == "23/03/26"
|
|
|
|
def test_empty_sections_produce_header_only(self):
|
|
rows = self._run(day_sections=[])
|
|
assert rows == [["Date*", "Project*", "Task", "Description", "Quantity"]]
|
|
|
|
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_quantity_format(self):
|
|
rows = self._run()
|
|
assert rows[1][4] == "1.00"
|
|
assert rows[3][4] == "0.50"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 == ""
|