Files
datatools-dev/tests/test_cli_reconcile.py
Michael 6627895a10 test: fix v3 branding drift, add reconcile CLI + registry coverage
GUI/lang-pack tests were asserting against pre-v3 strings ("Data
Cleaning Mastery", "Maestría en limpieza…") that the brand refresh
replaced with "UNALOGIX DataTools" + "Clean. Normalize. Transform."
Updated assertions to the current copy and switched the findings
panel tests to the redesigned flat-list layout (per-finding "Open
Tool →" buttons instead of per-tool expanders).

New coverage:
- tests/test_cli_reconcile.py (13) — preview/apply, tolerance flags,
  sign inversion, key flags, error paths, Excel input.
- tests/test_tools_registry.py (27) — unique tool_ids, page_slug →
  real file, valid sections/tiers, localized accessor fallbacks,
  explicit pins for PDF Extractor + Reconciler entries.
- tests/test_reconcile.py — one-side-empty, key-pass tagging,
  additional validation cases, input-DataFrame immutability.
- tests/gui/test_smoke.py — PAGE_SLUGS now includes 10_PDF_Extractor
  and 11_Reconciler in both en/es.
- tests/gui/test_workflows.py — TestPdfExtractorWorkflow and
  TestReconcilerWorkflow render checks.

Net: 2317 passed → 2418 passed, 0 failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 19:30:02 +00:00

285 lines
10 KiB
Python

"""Tests for src.cli_reconcile — Typer CLI for two-source reconciliation.
The reconciliation engine itself is covered by ``test_reconcile.py``;
this file exercises the CLI surface around it: argument parsing
(comma-separated keys, optional dates), preview vs. apply modes, the
four output files, and error paths for bad inputs.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pandas as pd
import pytest
from typer.testing import CliRunner
from src.cli_reconcile import app
runner = CliRunner()
def _write_bank(path: Path) -> None:
"""Bank-feed-shaped CSV with two transactions."""
path.write_text(
"date,amount,desc\n"
"2026-01-05,100.00,ACME\n"
"2026-01-06,250.00,WIDGET CO\n"
)
def _write_ledger(path: Path) -> None:
"""Ledger-shaped CSV with the same two transactions under
different column names — exercises the rename-on-match path."""
path.write_text(
"posted,amt,memo\n"
"2026-01-05,100.00,Acme Inc\n"
"2026-01-06,250.00,Widget\n"
)
class TestPreviewMode:
"""Default mode (no ``--apply``): print stats only, write nothing."""
def test_basic_preview_succeeds(self, tmp_path):
bank = tmp_path / "bank.csv"
ledger = tmp_path / "ledger.csv"
_write_bank(bank)
_write_ledger(ledger)
result = runner.invoke(app, [
str(bank), str(ledger),
"--left-amount", "amount", "--right-amount", "amt",
"--left-date", "date", "--right-date", "posted",
])
assert result.exit_code == 0, result.stdout
assert "Matched:" in result.stdout
assert "Unmatched left:" in result.stdout
# Two-of-two match in the fixture.
assert "Matched: 2" in result.stdout
# The reminder banner is part of the preview UX.
assert "Add --apply" in result.stdout
def test_preview_does_not_write_files(self, tmp_path):
bank = tmp_path / "bank.csv"
ledger = tmp_path / "ledger.csv"
_write_bank(bank)
_write_ledger(ledger)
runner.invoke(app, [
str(bank), str(ledger),
"--left-amount", "amount", "--right-amount", "amt",
"--left-date", "date", "--right-date", "posted",
])
# None of the four output suffixes should land beside the input.
for suffix in ("matched", "unmatched_left", "unmatched_right", "review"):
assert not (tmp_path / f"bank_{suffix}.csv").exists()
class TestApplyMode:
"""``--apply``: write the four output files beside the LEFT input."""
def test_apply_writes_four_files(self, tmp_path):
bank = tmp_path / "bank.csv"
ledger = tmp_path / "ledger.csv"
_write_bank(bank)
_write_ledger(ledger)
result = runner.invoke(app, [
str(bank), str(ledger),
"--left-amount", "amount", "--right-amount", "amt",
"--left-date", "date", "--right-date", "posted",
"--apply",
])
assert result.exit_code == 0, result.stdout
# All four output files land beside the left input, sharing
# its stem.
for suffix in ("matched", "unmatched_left", "unmatched_right", "review"):
out = tmp_path / f"bank_{suffix}.csv"
assert out.exists(), f"missing {out.name}"
# Matched.csv carries the two pairs.
matched = pd.read_csv(tmp_path / "bank_matched.csv")
assert len(matched) == 2
def test_apply_with_unmatched_rows(self, tmp_path):
bank = tmp_path / "bank.csv"
ledger = tmp_path / "ledger.csv"
bank.write_text(
"date,amount,desc\n"
"2026-01-05,100.00,ACME\n"
"2026-01-07,99.99,LEFT-ONLY\n"
)
ledger.write_text(
"posted,amt,memo\n"
"2026-01-05,100.00,Acme\n"
"2026-01-08,500.00,RIGHT-ONLY\n"
)
result = runner.invoke(app, [
str(bank), str(ledger),
"--left-amount", "amount", "--right-amount", "amt",
"--left-date", "date", "--right-date", "posted",
"--apply",
])
assert result.exit_code == 0
unmatched_l = pd.read_csv(tmp_path / "bank_unmatched_left.csv")
unmatched_r = pd.read_csv(tmp_path / "bank_unmatched_right.csv")
assert "LEFT-ONLY" in unmatched_l["desc"].tolist()
assert "RIGHT-ONLY" in unmatched_r["memo"].tolist()
class TestToleranceFlags:
def test_amount_tolerance_absorbs_rounding(self, tmp_path):
bank = tmp_path / "bank.csv"
ledger = tmp_path / "ledger.csv"
bank.write_text(
"date,amount,desc\n"
"2026-01-05,100.00,ACME\n"
)
ledger.write_text(
"posted,amt,memo\n"
"2026-01-05,100.02,Acme\n"
)
# Without tolerance: no match.
result_no_tol = runner.invoke(app, [
str(bank), str(ledger),
"--left-amount", "amount", "--right-amount", "amt",
"--left-date", "date", "--right-date", "posted",
])
assert "Matched: 0" in result_no_tol.stdout
# With tolerance: one match.
result_with_tol = runner.invoke(app, [
str(bank), str(ledger),
"--left-amount", "amount", "--right-amount", "amt",
"--left-date", "date", "--right-date", "posted",
"--amount-tolerance", "0.05",
])
assert "Matched: 1" in result_with_tol.stdout
def test_date_tolerance_allows_drift(self, tmp_path):
bank = tmp_path / "bank.csv"
ledger = tmp_path / "ledger.csv"
bank.write_text(
"date,amount,desc\n"
"2026-01-05,100.00,ACME\n"
)
ledger.write_text(
"posted,amt,memo\n"
"2026-01-07,100.00,Acme\n" # 2-day drift
)
result = runner.invoke(app, [
str(bank), str(ledger),
"--left-amount", "amount", "--right-amount", "amt",
"--left-date", "date", "--right-date", "posted",
"--date-tolerance", "3",
])
assert "Matched: 1" in result.stdout
class TestSignInversion:
def test_invert_right_sign(self, tmp_path):
bank = tmp_path / "bank.csv"
ledger = tmp_path / "ledger.csv"
bank.write_text(
"date,amount,desc\n"
"2026-01-05,100.00,ACME\n"
)
ledger.write_text(
"posted,amt,memo\n"
"2026-01-05,-100.00,Acme\n" # sign convention flipped
)
result = runner.invoke(app, [
str(bank), str(ledger),
"--left-amount", "amount", "--right-amount", "amt",
"--left-date", "date", "--right-date", "posted",
"--invert-right-sign",
])
assert "Matched: 1" in result.stdout
class TestKeyFlags:
def test_comma_separated_keys_pair_off(self, tmp_path):
# Same check number, mismatched posting dates — the date-only
# pass would miss but the key match catches.
bank = tmp_path / "bank.csv"
ledger = tmp_path / "ledger.csv"
bank.write_text(
"date,amount,desc,check_no\n"
"2026-01-05,100.00,ACME,1042\n"
)
ledger.write_text(
"posted,amt,memo,ref\n"
"2026-01-12,100.00,Acme,1042\n" # 7-day drift
)
result = runner.invoke(app, [
str(bank), str(ledger),
"--left-amount", "amount", "--right-amount", "amt",
"--left-date", "date", "--right-date", "posted",
"--left-keys", "check_no",
"--right-keys", "ref",
])
assert "Matched: 1" in result.stdout
class TestErrorPaths:
def test_missing_left_file(self, tmp_path):
ledger = tmp_path / "ledger.csv"
_write_ledger(ledger)
result = runner.invoke(app, [
str(tmp_path / "nope.csv"), str(ledger),
"--left-amount", "amount", "--right-amount", "amt",
])
assert result.exit_code != 0
assert "not found" in result.stdout.lower() or "not found" in (result.stderr or "").lower()
def test_missing_right_file(self, tmp_path):
bank = tmp_path / "bank.csv"
_write_bank(bank)
result = runner.invoke(app, [
str(bank), str(tmp_path / "nope.csv"),
"--left-amount", "amount", "--right-amount", "amt",
])
assert result.exit_code != 0
def test_unknown_amount_column_surfaces_value_error(self, tmp_path):
# The reconcile engine raises ValueError on unknown column names;
# the CLI catches it and exits 1 with a readable banner.
bank = tmp_path / "bank.csv"
ledger = tmp_path / "ledger.csv"
_write_bank(bank)
_write_ledger(ledger)
result = runner.invoke(app, [
str(bank), str(ledger),
"--left-amount", "NOT_A_COLUMN", "--right-amount", "amt",
])
assert result.exit_code == 1
# Banner format: "Error: <message>"
assert "Error" in result.stdout or "Error" in (result.stderr or "")
def test_help_renders(self):
# ``--help`` must work — examples in docstrings reference it.
result = runner.invoke(app, ["--help"])
assert result.exit_code == 0
assert "reconcile" in result.stdout.lower()
class TestExcelInput:
"""Input may be CSV, TSV, or Excel — read_file dispatches by suffix."""
def test_excel_left_file_reads(self, tmp_path):
bank = tmp_path / "bank.xlsx"
df = pd.DataFrame({
"date": ["2026-01-05"],
"amount": [100.00],
"desc": ["ACME"],
})
df.to_excel(bank, index=False)
ledger = tmp_path / "ledger.csv"
_write_ledger(ledger)
result = runner.invoke(app, [
str(bank), str(ledger),
"--left-amount", "amount", "--right-amount", "amt",
"--left-date", "date", "--right-date", "posted",
])
assert result.exit_code == 0, result.stdout
# 1 of 1 left rows matched against the 2-row right ledger.
assert "Matched: 1" in result.stdout