feat: set up modularized version of project with testing
This commit is contained in:
commit
7bea08ddac
19 changed files with 1138 additions and 0 deletions
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
113
tests/test_output.py
Normal file
113
tests/test_output.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import csv
|
||||
import io
|
||||
|
||||
import pytest
|
||||
|
||||
from timesheets.output import 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
|
||||
150
tests/test_parser.py
Normal file
150
tests/test_parser.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
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([]) == []
|
||||
67
tests/test_projects.py
Normal file
67
tests/test_projects.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import pytest
|
||||
|
||||
from timesheets.projects import load_project_map, resolve_project_task
|
||||
|
||||
PROJECT_MAP = {
|
||||
"bugs": {"Project": "[Factry] Historian", "Task": "[Historian] Bugs"},
|
||||
"internal": {"Project": "[Factry] Internal", "Task": ""},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_project_map
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoadProjectMap:
|
||||
def test_returns_empty_for_none(self):
|
||||
assert load_project_map(None) == {}
|
||||
|
||||
def test_loads_valid_file(self, tmp_path):
|
||||
f = tmp_path / "map.json"
|
||||
f.write_text('{"bugs": {"Project": "X", "Task": "Y"}}')
|
||||
result = load_project_map(str(f))
|
||||
assert result == {"bugs": {"Project": "X", "Task": "Y"}}
|
||||
|
||||
def test_missing_file_returns_empty(self, tmp_path, capsys):
|
||||
result = load_project_map(str(tmp_path / "nonexistent.json"))
|
||||
assert result == {}
|
||||
assert "Warning" in capsys.readouterr().err
|
||||
|
||||
def test_invalid_json_returns_empty(self, tmp_path, capsys):
|
||||
f = tmp_path / "bad.json"
|
||||
f.write_text("not json{{{")
|
||||
result = load_project_map(str(f))
|
||||
assert result == {}
|
||||
assert "Warning" in capsys.readouterr().err
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_project_task
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveProjectTask:
|
||||
def test_exact_key_match(self):
|
||||
project, task = resolve_project_task("bugs", PROJECT_MAP)
|
||||
assert project == "[Factry] Historian"
|
||||
assert task == "[Historian] Bugs"
|
||||
|
||||
def test_case_insensitive_lookup(self):
|
||||
project, task = resolve_project_task("BUGS", PROJECT_MAP)
|
||||
assert project == "[Factry] Historian"
|
||||
|
||||
def test_missing_key_returns_raw(self):
|
||||
project, task = resolve_project_task("unknown", PROJECT_MAP)
|
||||
assert project == "unknown"
|
||||
assert task == ""
|
||||
|
||||
def test_empty_task_in_map(self):
|
||||
project, task = resolve_project_task("internal", PROJECT_MAP)
|
||||
assert project == "[Factry] Internal"
|
||||
assert task == ""
|
||||
|
||||
def test_empty_map(self):
|
||||
project, task = resolve_project_task("bugs", {})
|
||||
assert project == "bugs"
|
||||
assert task == ""
|
||||
100
tests/test_utils.py
Normal file
100
tests/test_utils.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from timesheets.utils import (
|
||||
decimal_to_hhmm,
|
||||
duration_from_start_end,
|
||||
format_date,
|
||||
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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue