init
This commit is contained in:
commit
ac93e6074b
38 changed files with 7162 additions and 0 deletions
124
tests/test_bulk_edit.py
Normal file
124
tests/test_bulk_edit.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"""Pure-Python tests for the bulk-edit data path. The Textual modal itself
|
||||
is exercised manually; here we lock in the semantics of the per-row diff
|
||||
overlay so a regression in the loop is caught."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from common_cents.db import Database
|
||||
from common_cents.screens._spending_bulk_form import _split_tags
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path: Path) -> Database:
|
||||
return Database(tmp_path / "test.db")
|
||||
|
||||
|
||||
def _seed(db: Database, *rows) -> list[int]:
|
||||
for r in rows:
|
||||
db.add_spending(*r)
|
||||
return [row["id"] for row in db.get_spending()]
|
||||
|
||||
|
||||
def _apply_diff(db: Database, ids: list[int], diff: dict) -> int:
|
||||
"""Mirror BulkEditScreen._write so the overlay logic can be tested headlessly."""
|
||||
tag_ids: list[int] | None = None
|
||||
if "tag_names" in diff:
|
||||
tag_ids = [db.get_or_create_metadata("tag", n) for n in diff["tag_names"]]
|
||||
updated = 0
|
||||
for sid in ids:
|
||||
row = db.get_spending_by_id(sid)
|
||||
if row is None:
|
||||
continue
|
||||
new_date = diff.get("date", row["date"])
|
||||
new_category = diff.get("category", row["category_name"])
|
||||
new_merchant = diff["merchant"] if "merchant" in diff else row["merchant_name"]
|
||||
new_notes = diff["notes"] if "notes" in diff else row["notes"]
|
||||
new_tags = (
|
||||
tag_ids
|
||||
if tag_ids is not None
|
||||
else db.get_spending_metadata_ids(sid, "tag")
|
||||
)
|
||||
db.update_spending(
|
||||
sid,
|
||||
new_date,
|
||||
row["cents"],
|
||||
new_category,
|
||||
new_merchant,
|
||||
new_notes,
|
||||
new_tags,
|
||||
)
|
||||
updated += 1
|
||||
return updated
|
||||
|
||||
|
||||
def test_split_tags_strips_and_drops_empty():
|
||||
assert _split_tags("a, b , ,c") == ["a", "b", "c"]
|
||||
assert _split_tags("") == []
|
||||
assert _split_tags(" ") == []
|
||||
|
||||
|
||||
def test_bulk_recategorize_preserves_other_fields(db):
|
||||
ids = _seed(
|
||||
db,
|
||||
("2026-05-01", 1500, "Food", "Whole Foods", "lunch", []),
|
||||
("2026-05-02", 2500, "Food", "Trader Joes", None, []),
|
||||
)
|
||||
_apply_diff(db, ids, {"category": "Groceries"})
|
||||
rows = db.get_spending()
|
||||
assert {r["category"] for r in rows} == {"Groceries"}
|
||||
# merchants and notes preserved
|
||||
by_date = {r["date"]: r for r in rows}
|
||||
assert by_date["2026-05-01"]["merchant"] == "Whole Foods"
|
||||
assert by_date["2026-05-01"]["notes"] == "lunch"
|
||||
assert by_date["2026-05-02"]["merchant"] == "Trader Joes"
|
||||
|
||||
|
||||
def test_bulk_clear_merchant_and_notes(db):
|
||||
ids = _seed(
|
||||
db,
|
||||
("2026-05-01", 1500, "Food", "X", "n1", []),
|
||||
("2026-05-02", 2500, "Food", "Y", "n2", []),
|
||||
)
|
||||
_apply_diff(db, ids, {"merchant": None, "notes": None})
|
||||
for row in db.get_spending():
|
||||
assert row["merchant"] is None
|
||||
assert row["notes"] is None
|
||||
|
||||
|
||||
def test_bulk_replace_tags_overwrites_existing(db):
|
||||
food_id = db.add_metadata("tag", "lunch")
|
||||
ids = _seed(
|
||||
db,
|
||||
("2026-05-01", 1500, "Food", None, None, [food_id]),
|
||||
("2026-05-02", 2500, "Food", None, None, []),
|
||||
)
|
||||
_apply_diff(db, ids, {"tag_names": ["work", "client"]})
|
||||
for sid in ids:
|
||||
names = sorted(
|
||||
t["name"]
|
||||
for t in db.get_metadata("tag")
|
||||
if t["id"] in db.get_spending_metadata_ids(sid, "tag")
|
||||
)
|
||||
assert names == ["client", "work"]
|
||||
|
||||
|
||||
def test_bulk_clear_tags(db):
|
||||
work_id = db.add_metadata("tag", "work")
|
||||
ids = _seed(
|
||||
db,
|
||||
("2026-05-01", 1500, "Food", None, None, [work_id]),
|
||||
)
|
||||
_apply_diff(db, ids, {"tag_names": []})
|
||||
assert db.get_spending_metadata_ids(ids[0], "tag") == []
|
||||
|
||||
|
||||
def test_bulk_skips_missing_id(db):
|
||||
ids = _seed(
|
||||
db,
|
||||
("2026-05-01", 1500, "Food", None, None, []),
|
||||
)
|
||||
n = _apply_diff(db, [*ids, 99999], {"category": "Travel"})
|
||||
assert n == 1 # the bogus id is skipped, not raised
|
||||
assert db.get_spending()[0]["category"] == "Travel"
|
||||
64
tests/test_category_tree.py
Normal file
64
tests/test_category_tree.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
from common_cents.category_tree import build_category_tree
|
||||
|
||||
|
||||
def _by_name(nodes):
|
||||
return {n.name: n for n in nodes}
|
||||
|
||||
|
||||
def test_flat_paths():
|
||||
nodes = build_category_tree({"Food": 1000, "Travel": 500})
|
||||
by_name = _by_name(nodes)
|
||||
assert set(by_name) == {"Food", "Travel"}
|
||||
assert by_name["Food"].self_total == 1000
|
||||
assert by_name["Food"].total() == 1000
|
||||
|
||||
|
||||
def test_nested_paths_roll_up():
|
||||
nodes = build_category_tree(
|
||||
{"Food": 1000, "Food:Restaurants": 2000, "Food:Restaurants:Sushi": 500}
|
||||
)
|
||||
food = _by_name(nodes)["Food"]
|
||||
assert food.self_total == 1000
|
||||
assert food.total() == 3500
|
||||
rest = food.children["Restaurants"]
|
||||
assert rest.self_total == 2000
|
||||
assert rest.total() == 2500
|
||||
|
||||
|
||||
def test_intermediate_ancestors_have_zero_self_total():
|
||||
nodes = build_category_tree({"A:B:C": 100})
|
||||
a = _by_name(nodes)["A"]
|
||||
assert a.self_total == 0
|
||||
assert a.total() == 100
|
||||
assert a.children["B"].self_total == 0
|
||||
assert a.children["B"].children["C"].self_total == 100
|
||||
|
||||
|
||||
def test_prev_totals_separate_from_current():
|
||||
nodes = build_category_tree({"Food": 100}, prev_totals={"Food": 200, "Travel": 50})
|
||||
by_name = _by_name(nodes)
|
||||
assert by_name["Food"].self_total == 100
|
||||
assert by_name["Food"].self_prev_total == 200
|
||||
# Travel exists only in prev — appears in tree.
|
||||
assert by_name["Travel"].self_total == 0
|
||||
assert by_name["Travel"].self_prev_total == 50
|
||||
|
||||
|
||||
def test_extra_paths_create_empty_nodes():
|
||||
nodes = build_category_tree({}, extra_paths=["Food:Restaurants"])
|
||||
food = _by_name(nodes)["Food"]
|
||||
assert food.total() == 0
|
||||
assert "Restaurants" in food.children
|
||||
|
||||
|
||||
def test_empty_inputs():
|
||||
assert build_category_tree({}) == []
|
||||
assert build_category_tree({}, {}) == []
|
||||
|
||||
|
||||
def test_blank_segments_skipped():
|
||||
"""A path like ``::A`` should not create empty-named nodes."""
|
||||
nodes = build_category_tree({"::Food": 100})
|
||||
by_name = _by_name(nodes)
|
||||
assert "" not in by_name
|
||||
assert "Food" in by_name
|
||||
57
tests/test_config.py
Normal file
57
tests/test_config.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from common_cents import config
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""Point XDG_CONFIG_HOME at a temp dir so tests don't touch the real config."""
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_config_path_uses_xdg(isolate_config: Path) -> None:
|
||||
assert config.config_path() == isolate_config / "common-cents" / "config.json"
|
||||
|
||||
|
||||
def test_load_returns_empty_when_missing() -> None:
|
||||
assert config.load_config() == {}
|
||||
|
||||
|
||||
def test_load_returns_empty_on_corrupt_json(isolate_config: Path) -> None:
|
||||
p = config.config_path()
|
||||
p.parent.mkdir(parents=True)
|
||||
p.write_text("{not valid json")
|
||||
assert config.load_config() == {}
|
||||
|
||||
|
||||
def test_set_and_get_active_db_path(tmp_path: Path) -> None:
|
||||
target = tmp_path / "elsewhere" / "my.db"
|
||||
config.set_active_db_path(target)
|
||||
assert config.get_active_db_path() == target
|
||||
|
||||
|
||||
def test_get_active_db_path_returns_none_when_unset() -> None:
|
||||
assert config.get_active_db_path() is None
|
||||
|
||||
|
||||
def test_clear_active_db_path(tmp_path: Path) -> None:
|
||||
target = tmp_path / "x.db"
|
||||
config.set_active_db_path(target)
|
||||
config.clear_active_db_path()
|
||||
assert config.get_active_db_path() is None
|
||||
|
||||
|
||||
def test_clear_when_unset_is_noop() -> None:
|
||||
config.clear_active_db_path()
|
||||
assert config.get_active_db_path() is None
|
||||
|
||||
|
||||
def test_set_preserves_other_keys() -> None:
|
||||
config.save_config({"theme": "dark"})
|
||||
config.set_active_db_path(Path("/tmp/x.db"))
|
||||
data = config.load_config()
|
||||
assert data["theme"] == "dark"
|
||||
assert data["db_path"] == "/tmp/x.db"
|
||||
82
tests/test_csv.py
Normal file
82
tests/test_csv.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
from pathlib import Path
|
||||
|
||||
from common_cents.csv_export import write_csv
|
||||
from common_cents.csv_import import parse_csv
|
||||
|
||||
|
||||
def _row(date, cents, cat, merch=None, notes=None, tags=None):
|
||||
return {
|
||||
"date": date,
|
||||
"cents": cents,
|
||||
"category": cat,
|
||||
"merchant": merch,
|
||||
"notes": notes,
|
||||
"tags": tags or [],
|
||||
}
|
||||
|
||||
|
||||
def test_export_then_import_roundtrip(tmp_path: Path):
|
||||
rows = [
|
||||
_row("2026-01-01", 100, "A", "M", None, ["x", "y"]),
|
||||
_row("2026-01-02", 200, "B", None, 'Hello, "world"'),
|
||||
_row("2026-01-03", 300, "C:D", None, None, ["t1", "t2", "t3"]),
|
||||
]
|
||||
p = tmp_path / "out.csv"
|
||||
write_csv(p, rows)
|
||||
result = parse_csv(p)
|
||||
assert result.errors == []
|
||||
assert len(result.rows) == 3
|
||||
assert result.rows[0].tags == ["x", "y"]
|
||||
assert result.rows[1].notes == 'Hello, "world"'
|
||||
assert result.rows[2].tags == ["t1", "t2", "t3"]
|
||||
|
||||
|
||||
def test_parse_csv_missing_required(tmp_path: Path):
|
||||
p = tmp_path / "bad.csv"
|
||||
p.write_text("DATE,MERCHANT\n2026-01-01,Joe\n")
|
||||
result = parse_csv(p)
|
||||
assert result.errors
|
||||
assert "CATEGORY" in result.errors[0]
|
||||
assert "CENTS" in result.errors[0]
|
||||
|
||||
|
||||
def test_parse_csv_missing_optional_warns(tmp_path: Path):
|
||||
p = tmp_path / "ok.csv"
|
||||
p.write_text("DATE,CENTS,CATEGORY,MERCHANT\n2026-01-01,100,Food,Joe\n")
|
||||
result = parse_csv(p)
|
||||
assert result.errors == []
|
||||
assert any("NOTES" in w and "TAGS" in w for w in result.warnings)
|
||||
assert len(result.rows) == 1
|
||||
|
||||
|
||||
def test_parse_csv_invalid_date(tmp_path: Path):
|
||||
p = tmp_path / "bad.csv"
|
||||
p.write_text("DATE,CENTS,CATEGORY,MERCHANT\nnot-a-date,100,Food,\n")
|
||||
result = parse_csv(p)
|
||||
assert any("invalid date" in e for e in result.errors)
|
||||
assert result.rows == []
|
||||
|
||||
|
||||
def test_parse_csv_zero_cents_rejected(tmp_path: Path):
|
||||
p = tmp_path / "bad.csv"
|
||||
p.write_text("DATE,CENTS,CATEGORY,MERCHANT\n2026-01-01,0,Food,\n")
|
||||
result = parse_csv(p)
|
||||
assert any("positive" in e for e in result.errors)
|
||||
|
||||
|
||||
def test_parse_csv_comma_formatted_cents(tmp_path: Path):
|
||||
p = tmp_path / "ok.csv"
|
||||
p.write_text(
|
||||
'DATE,CENTS,CATEGORY,MERCHANT\n2026-01-01,"14,901",Food,\n'
|
||||
)
|
||||
result = parse_csv(p)
|
||||
assert result.errors == []
|
||||
assert result.rows[0].cents == 14901
|
||||
|
||||
|
||||
def test_export_sorts_nothing_within_row(tmp_path: Path):
|
||||
"""write_csv preserves caller's tag order; sorting is the DB layer's job."""
|
||||
p = tmp_path / "out.csv"
|
||||
write_csv(p, [_row("2026-01-01", 100, "A", None, None, ["zeta", "alpha"])])
|
||||
text = p.read_text()
|
||||
assert '"zeta,alpha"' in text
|
||||
240
tests/test_db.py
Normal file
240
tests/test_db.py
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from common_cents.db import Database
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path: Path) -> Database:
|
||||
return Database(tmp_path / "test.db")
|
||||
|
||||
|
||||
def test_add_metadata_basic(db):
|
||||
cat_id = db.add_metadata("category", "Food")
|
||||
assert cat_id > 0
|
||||
rows = db.get_metadata("category")
|
||||
assert [r["name"] for r in rows] == ["Food"]
|
||||
|
||||
|
||||
def test_add_metadata_duplicate(db):
|
||||
db.add_metadata("category", "Food")
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
db.add_metadata("category", "Food")
|
||||
|
||||
|
||||
def test_add_metadata_duplicate_case_insensitive(db):
|
||||
db.add_metadata("category", "Food")
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
db.add_metadata("category", "food")
|
||||
|
||||
|
||||
def test_add_metadata_invalid_kind(db):
|
||||
with pytest.raises(ValueError, match="Invalid kind"):
|
||||
db.add_metadata("bogus", "X")
|
||||
|
||||
|
||||
def test_add_metadata_rejects_comma_in_name(db):
|
||||
with pytest.raises(ValueError, match="cannot contain commas"):
|
||||
db.add_metadata("tag", "has,comma")
|
||||
|
||||
|
||||
def test_update_metadata_unknown_id(db):
|
||||
with pytest.raises(ValueError, match="No metadata row"):
|
||||
db.update_metadata(99999, "Anything")
|
||||
|
||||
|
||||
def test_update_metadata_rejects_comma(db):
|
||||
cat_id = db.add_metadata("category", "Food")
|
||||
with pytest.raises(ValueError, match="cannot contain commas"):
|
||||
db.update_metadata(cat_id, "Foo,Bar")
|
||||
|
||||
|
||||
def test_delete_metadata_blocked_when_in_use(db):
|
||||
db.add_spending("2026-01-01", 100, "Food", None, None, [])
|
||||
cat = db.get_metadata("category")[0]
|
||||
with pytest.raises(ValueError, match="Cannot delete"):
|
||||
db.delete_metadata(cat["id"])
|
||||
|
||||
|
||||
def test_delete_metadata_succeeds_when_unused(db):
|
||||
cat_id = db.add_metadata("category", "Food")
|
||||
db.delete_metadata(cat_id)
|
||||
assert db.get_metadata("category") == []
|
||||
|
||||
|
||||
def test_add_spending_creates_metadata_inline(db):
|
||||
db.add_spending("2026-05-01", 1500, "Food:Restaurants", "Joe's", "lunch", [])
|
||||
rows = db.get_spending()
|
||||
assert len(rows) == 1
|
||||
r = rows[0]
|
||||
assert r["category"] == "Food:Restaurants"
|
||||
assert r["merchant"] == "Joe's"
|
||||
assert r["notes"] == "lunch"
|
||||
assert r["cents"] == 1500
|
||||
|
||||
|
||||
def test_add_spending_rejects_comma_in_category(db):
|
||||
with pytest.raises(ValueError, match="cannot contain commas"):
|
||||
db.add_spending("2026-05-01", 100, "a,b", None, None, [])
|
||||
|
||||
|
||||
def test_update_spending(db):
|
||||
db.add_spending("2026-05-01", 1500, "Food", None, None, [])
|
||||
sid = db.get_spending()[0]["id"]
|
||||
db.update_spending(sid, "2026-05-02", 2000, "Travel", "Delta", "flight", [])
|
||||
r = db.get_spending_by_id(sid)
|
||||
assert r["date"] == "2026-05-02"
|
||||
assert r["cents"] == 2000
|
||||
assert r["category_name"] == "Travel"
|
||||
assert r["merchant_name"] == "Delta"
|
||||
|
||||
|
||||
def test_delete_spending_cascades_links(db):
|
||||
db.add_spending("2026-05-01", 1500, "Food", None, None, [])
|
||||
sid = db.get_spending()[0]["id"]
|
||||
db.delete_spending(sid)
|
||||
assert db.get_spending() == []
|
||||
|
||||
|
||||
def test_budget_set_get_clear(db):
|
||||
cat_id = db.add_metadata("category", "Food")
|
||||
assert db.get_budget(cat_id) is None
|
||||
db.set_budget(cat_id, 50000)
|
||||
assert db.get_budget(cat_id) == 50000
|
||||
db.set_budget(cat_id, 60000) # upsert
|
||||
assert db.get_budget(cat_id) == 60000
|
||||
db.clear_budget(cat_id)
|
||||
assert db.get_budget(cat_id) is None
|
||||
|
||||
|
||||
def test_budget_rejects_non_positive(db):
|
||||
cat_id = db.add_metadata("category", "Food")
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
db.set_budget(cat_id, 0)
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
db.set_budget(cat_id, -100)
|
||||
|
||||
|
||||
def test_find_duplicate_indices_against_existing(db):
|
||||
db.add_spending("2026-01-15", 1500, "Food", "TJ", None, [])
|
||||
rows = [
|
||||
{
|
||||
"date": "2026-01-15",
|
||||
"cents": 1500,
|
||||
"category": "Food",
|
||||
"merchant": "TJ",
|
||||
"notes": None,
|
||||
"tags": [],
|
||||
},
|
||||
{
|
||||
"date": "2026-01-16",
|
||||
"cents": 1500,
|
||||
"category": "Food",
|
||||
"merchant": "TJ",
|
||||
"notes": None,
|
||||
"tags": [],
|
||||
},
|
||||
]
|
||||
assert db.find_duplicate_indices(rows) == {0}
|
||||
|
||||
|
||||
def test_find_duplicate_indices_within_batch(db):
|
||||
rows = [
|
||||
{
|
||||
"date": "2026-01-15",
|
||||
"cents": 1500,
|
||||
"category": "Food",
|
||||
"merchant": None,
|
||||
"notes": None,
|
||||
"tags": [],
|
||||
},
|
||||
{
|
||||
"date": "2026-01-15",
|
||||
"cents": 1500,
|
||||
"category": "Food",
|
||||
"merchant": None,
|
||||
"notes": None,
|
||||
"tags": [],
|
||||
},
|
||||
]
|
||||
assert db.find_duplicate_indices(rows) == {1}
|
||||
|
||||
|
||||
def test_import_spending_skips_duplicates(db):
|
||||
db.add_spending("2026-01-15", 1500, "Food", None, None, [])
|
||||
inserted, skipped = db.import_spending(
|
||||
[
|
||||
{
|
||||
"date": "2026-01-15",
|
||||
"cents": 1500,
|
||||
"category": "Food",
|
||||
"merchant": None,
|
||||
"notes": None,
|
||||
"tags": [],
|
||||
},
|
||||
{
|
||||
"date": "2026-01-16",
|
||||
"cents": 1500,
|
||||
"category": "Food",
|
||||
"merchant": None,
|
||||
"notes": None,
|
||||
"tags": [],
|
||||
},
|
||||
]
|
||||
)
|
||||
assert (inserted, skipped) == (1, 1)
|
||||
|
||||
|
||||
def test_get_spending_for_export_sorts_tags(db):
|
||||
db.add_spending(
|
||||
"2026-01-01",
|
||||
100,
|
||||
"Food",
|
||||
None,
|
||||
None,
|
||||
[
|
||||
db.get_or_create_metadata("tag", "zeta"),
|
||||
db.get_or_create_metadata("tag", "alpha"),
|
||||
],
|
||||
)
|
||||
rows = db.get_spending_for_export()
|
||||
assert rows[0]["tags"] == ["alpha", "zeta"]
|
||||
|
||||
|
||||
def test_get_yearly_totals(db):
|
||||
db.add_spending("2026-01-15", 1000, "Food", None, None, [])
|
||||
db.add_spending("2026-01-20", 500, "Food", None, None, [])
|
||||
db.add_spending("2026-03-01", 2000, "Food", None, None, [])
|
||||
totals = db.get_yearly_totals(2026)
|
||||
assert totals == {1: 1500, 3: 2000}
|
||||
|
||||
|
||||
def test_get_category_totals_for_month(db):
|
||||
db.add_spending("2026-05-01", 1000, "Food", None, None, [])
|
||||
db.add_spending("2026-05-15", 500, "Food:Restaurants", None, None, [])
|
||||
db.add_spending("2026-04-01", 9999, "Food", None, None, [])
|
||||
totals = db.get_category_totals_for_month("2026-05")
|
||||
assert totals == {"Food": 1000, "Food:Restaurants": 500}
|
||||
|
||||
|
||||
def test_get_spending_by_category_tag_filter(db):
|
||||
work_id = db.add_metadata("tag", "work")
|
||||
travel_id = db.add_metadata("tag", "travel")
|
||||
db.add_spending("2026-05-01", 1000, "Food", None, None, [work_id])
|
||||
db.add_spending("2026-05-02", 2000, "Food", None, None, [travel_id])
|
||||
db.add_spending("2026-05-03", 500, "Travel", None, None, [travel_id])
|
||||
|
||||
# Substring match, single tag
|
||||
rows = db.get_spending_by_category("2026-05-01", "2026-05-31", tag="work")
|
||||
assert {(r["name"], r["total"]) for r in rows} == {("Food", 1000)}
|
||||
|
||||
# Tag + category filter combine (AND)
|
||||
rows = db.get_spending_by_category(
|
||||
"2026-05-01", "2026-05-31", category="Travel", tag="travel"
|
||||
)
|
||||
assert {(r["name"], r["total"]) for r in rows} == {("Travel", 500)}
|
||||
|
||||
# Empty tag means "no tag filter" (back-compat with default)
|
||||
rows = db.get_spending_by_category("2026-05-01", "2026-05-31", tag="")
|
||||
assert {r["name"] for r in rows} == {"Food", "Travel"}
|
||||
77
tests/test_money.py
Normal file
77
tests/test_money.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import pytest
|
||||
|
||||
from common_cents.money import (
|
||||
format_cents,
|
||||
format_cents_input,
|
||||
parse_cents_csv,
|
||||
parse_dollars,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw, expected",
|
||||
[
|
||||
("1", 100),
|
||||
("1.5", 150),
|
||||
("1.50", 150),
|
||||
("$1,234.56", 123456),
|
||||
(".50", 50),
|
||||
("5.", 500),
|
||||
("5.999", 599), # truncate, not round (string-based)
|
||||
("1.005", 100),
|
||||
("-5.00", -500),
|
||||
("-.50", -50),
|
||||
("", None),
|
||||
(" ", None),
|
||||
("abc", None),
|
||||
("$", None),
|
||||
("-", None),
|
||||
("-$5", None),
|
||||
],
|
||||
)
|
||||
def test_parse_dollars(raw, expected):
|
||||
assert parse_dollars(raw) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw, expected",
|
||||
[
|
||||
("100", 100),
|
||||
("14,901", 14901),
|
||||
(" 50 ", 50),
|
||||
],
|
||||
)
|
||||
def test_parse_cents_csv(raw, expected):
|
||||
assert parse_cents_csv(raw) == expected
|
||||
|
||||
|
||||
def test_parse_cents_csv_invalid():
|
||||
with pytest.raises(ValueError):
|
||||
parse_cents_csv("abc")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cents, expected",
|
||||
[
|
||||
(0, "$0.00"),
|
||||
(100, "$1.00"),
|
||||
(1234, "$12.34"),
|
||||
(123456, "$1,234.56"),
|
||||
(-500, "-$5.00"),
|
||||
(-12345, "-$123.45"),
|
||||
],
|
||||
)
|
||||
def test_format_cents(cents, expected):
|
||||
assert format_cents(cents) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cents, expected",
|
||||
[
|
||||
(100, "1.00"),
|
||||
(1234, "12.34"),
|
||||
(5, "0.05"),
|
||||
],
|
||||
)
|
||||
def test_format_cents_input(cents, expected):
|
||||
assert format_cents_input(cents) == expected
|
||||
97
tests/test_reports.py
Normal file
97
tests/test_reports.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
from datetime import date
|
||||
|
||||
from common_cents.screens.reports import (
|
||||
_date_range,
|
||||
_month_options,
|
||||
_months_in_range,
|
||||
)
|
||||
|
||||
|
||||
def test_months_in_range_same_month():
|
||||
assert _months_in_range("2026-05-01", "2026-05-31") == 1
|
||||
|
||||
|
||||
def test_months_in_range_multi_month():
|
||||
assert _months_in_range("2026-01-01", "2026-12-31") == 12
|
||||
|
||||
|
||||
def test_months_in_range_year_crossover():
|
||||
assert _months_in_range("2025-11-01", "2026-02-15") == 4
|
||||
|
||||
|
||||
def test_date_range_this_month():
|
||||
import calendar
|
||||
|
||||
start, end, label = _date_range("this-month")
|
||||
today = date.today()
|
||||
last_day = calendar.monthrange(today.year, today.month)[1]
|
||||
assert start == today.replace(day=1).isoformat()
|
||||
assert end == today.replace(day=last_day).isoformat()
|
||||
assert "This Month" in label
|
||||
|
||||
|
||||
def test_date_range_last_month():
|
||||
start, end, _label = _date_range("last-month")
|
||||
today = date.today()
|
||||
end_d = date.fromisoformat(end)
|
||||
# last day of previous month
|
||||
assert end_d < today.replace(day=1)
|
||||
assert date.fromisoformat(start).day == 1
|
||||
assert end_d.month == date.fromisoformat(start).month
|
||||
|
||||
|
||||
def test_date_range_ytd():
|
||||
start, end, _label = _date_range("ytd")
|
||||
today = date.today()
|
||||
assert start == date(today.year, 1, 1).isoformat()
|
||||
assert end == today.isoformat()
|
||||
|
||||
|
||||
def test_date_range_last_12_months():
|
||||
"""Trailing 12 months = current month + 11 prior, so spans 12 distinct months."""
|
||||
start, end, _label = _date_range("last-12")
|
||||
assert _months_in_range(start, end) == 12
|
||||
|
||||
|
||||
def test_date_range_custom_month_bounds():
|
||||
"""Custom range expands YYYY-MM to first/last day of the chosen months."""
|
||||
start, end, label = _date_range("custom", "2026-03", "2026-05")
|
||||
assert start == "2026-03-01"
|
||||
assert end == "2026-05-31"
|
||||
assert "Mar 2026" in label and "May 2026" in label
|
||||
|
||||
|
||||
def test_date_range_custom_single_month():
|
||||
start, end, label = _date_range("custom", "2026-02", "2026-02")
|
||||
assert start == "2026-02-01"
|
||||
assert end == "2026-02-28"
|
||||
assert label == "Custom (Feb 2026)"
|
||||
|
||||
|
||||
def test_date_range_custom_swaps_when_inverted():
|
||||
start, end, _label = _date_range("custom", "2026-12", "2026-01")
|
||||
assert start == "2026-01-01"
|
||||
assert end == "2026-12-31"
|
||||
|
||||
|
||||
def test_date_range_custom_falls_back_to_current_month_when_blank():
|
||||
start, end, _label = _date_range("custom", "", "")
|
||||
today = date.today()
|
||||
assert start.startswith(today.strftime("%Y-%m"))
|
||||
assert start.endswith("-01")
|
||||
assert end.startswith(today.strftime("%Y-%m"))
|
||||
|
||||
|
||||
def test_month_options_descending_and_inclusive():
|
||||
opts = _month_options(date(2026, 3, 1), date(2026, 5, 15))
|
||||
# latest first, three months, label = abbr month + year, value = YYYY-MM
|
||||
assert opts == [
|
||||
("May 2026", "2026-05"),
|
||||
("Apr 2026", "2026-04"),
|
||||
("Mar 2026", "2026-03"),
|
||||
]
|
||||
|
||||
|
||||
def test_month_options_year_crossover():
|
||||
opts = _month_options(date(2025, 11, 1), date(2026, 1, 31))
|
||||
assert [v for _, v in opts] == ["2026-01", "2025-12", "2025-11"]
|
||||
Loading…
Add table
Add a link
Reference in a new issue