"""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"