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>
285 lines
10 KiB
Python
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
|