All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
Closes M3.1–M3.6. Parity safety net proving Go output matches Python
for every ported pure-domain function (M2.1–M2.9) and reconcile (M2.10).
Capture pipeline:
- scripts/capture_fixtures.py: calls each Python function with seeded
inputs, emits JSON fixtures to stdout (never writes files directly).
- scripts/scrub_fixtures.py: deterministic PII scrubber — SHA-256
pseudonyms for member names, digit-preserving hashes for VS/account/
bank_id, name-sweep in message text. Idempotent; no salt.
- scripts/_fixture_seeds.py: handcrafted seeds for all 11 functions;
synthetic names throughout (no real roster members).
- scripts/capture_all_fixtures.sh: convenience wrapper for full corpus
regeneration outside of make.
Fixture corpus (98 files, all PII-free):
- go/tests/fixtures/pure/<func>/<case>.json — 10 function directories.
- go/tests/fixtures/reconcile/<NN>_<case>.json — 10 branch-coverage
cases: greedy, overpayment credit, proportional remainder, even-split,
out-of-window, exception override, other: purpose, junior ?, multi-
person+month fan-out, unmatched.
Go parity tests (//go:build parity):
- go/tests/parity/parityio.go: generic LoadDir/RunAll helpers + typed
In/Out struct pairs for all 10 pure functions; Envelope decoder for
int/float/none disambiguation.
- 10 pure-function test packages + bespoke reconcile test with per-cell
float tolerance (math.Abs <= 0.01 for `paid` values).
Makefile: go-parity, go-test-all, capture-fixtures targets.
go/tests/fixtures/README.md: refresh workflow + PII audit guide.
Gate: make go-test green, make go-parity green (11/11 packages),
make go-lint clean (parity tag), make go-build clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
260 lines
8.5 KiB
Go
260 lines
8.5 KiB
Go
//go:build parity
|
|
|
|
// Package reconcile_parity_test drives the M3 characterization tests for
|
|
// domain/reconcile.Reconcile. The test is bespoke (not using RunAll) because
|
|
// reconcile output contains float `paid` values that need per-cell tolerance.
|
|
//
|
|
// Verify expected values against live Python:
|
|
//
|
|
// PYTHONPATH=scripts:. python3 -c "
|
|
// from match_payments import reconcile
|
|
// # ... build members/months/txs/exceptions...
|
|
// import json; print(json.dumps(reconcile(...)))"
|
|
package reconcile_parity_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"fuj-management/go/internal/domain/czech"
|
|
"fuj-management/go/internal/domain/reconcile"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixture JSON types (reconcile-specific)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type fixtureDoc struct {
|
|
Case string `json:"case"`
|
|
Input fixtureInput `json:"input"`
|
|
Output fixtureOutput `json:"output"`
|
|
}
|
|
|
|
// fixtureInput matches the JSON shape produced by capture_fixtures.py for reconcile.
|
|
type fixtureInput struct {
|
|
Members []json.RawMessage `json:"members"` // each: [name, tier, fees_dict]
|
|
SortedMonths []string `json:"sorted_months"`
|
|
Transactions []fixtureTx `json:"transactions"`
|
|
Exceptions []json.RawMessage `json:"exceptions"` // each: [name, period, amount, note] or []
|
|
DefaultYear int `json:"default_year"`
|
|
}
|
|
|
|
type fixtureTx struct {
|
|
Date string `json:"date"`
|
|
Amount json.Number `json:"amount"`
|
|
ManualFix string `json:"manual_fix"`
|
|
Person string `json:"person"`
|
|
Purpose string `json:"purpose"`
|
|
InferredAmount json.Number `json:"inferred_amount"`
|
|
Sender string `json:"sender"`
|
|
Message string `json:"message"`
|
|
BankID string `json:"bank_id"`
|
|
}
|
|
|
|
type fixtureOutput struct {
|
|
Members map[string]fixtureMemberResult `json:"members"`
|
|
Unmatched []json.RawMessage `json:"unmatched"`
|
|
Credits map[string]int `json:"credits"`
|
|
}
|
|
|
|
type fixtureMemberResult struct {
|
|
Tier string `json:"tier"`
|
|
Months map[string]fixtureMonth `json:"months"`
|
|
OtherTransactions []json.RawMessage `json:"other_transactions"`
|
|
TotalBalance int `json:"total_balance"`
|
|
}
|
|
|
|
type fixtureMonth struct {
|
|
Expected int `json:"expected"`
|
|
OriginalExpected int `json:"original_expected"`
|
|
AttendanceCount int `json:"attendance_count"`
|
|
Exception interface{} `json:"exception"`
|
|
Paid float64 `json:"paid"`
|
|
Transactions []json.RawMessage `json:"transactions"`
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Input decoding
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func decodeMember(raw json.RawMessage) (reconcile.Member, error) {
|
|
// Dict format: {"name": ..., "tier": ..., "fees": {"YYYY-MM": [fee, count]}}
|
|
var obj struct {
|
|
Name string `json:"name"`
|
|
Tier string `json:"tier"`
|
|
Fees map[string]json.RawMessage `json:"fees"`
|
|
}
|
|
if err := json.Unmarshal(raw, &obj); err != nil {
|
|
return reconcile.Member{}, err
|
|
}
|
|
fees := make(map[string]reconcile.FeeData, len(obj.Fees))
|
|
for month, v := range obj.Fees {
|
|
var arr [2]int
|
|
if err := json.Unmarshal(v, &arr); err == nil {
|
|
fees[month] = reconcile.FeeData{Expected: arr[0], Attendance: arr[1]}
|
|
} else {
|
|
var n int
|
|
if err2 := json.Unmarshal(v, &n); err2 != nil {
|
|
return reconcile.Member{}, fmt.Errorf("fees[%q]: %v", month, err2)
|
|
}
|
|
fees[month] = reconcile.FeeData{Expected: n}
|
|
}
|
|
}
|
|
return reconcile.Member{Name: obj.Name, Tier: obj.Tier, Fees: fees}, nil
|
|
}
|
|
|
|
func decodeTransaction(ft fixtureTx) reconcile.Transaction {
|
|
amount, _ := ft.Amount.Float64()
|
|
var inferredAmount *float64
|
|
if ia, err := ft.InferredAmount.Float64(); err == nil && ia != 0 {
|
|
inferredAmount = &ia
|
|
}
|
|
return reconcile.Transaction{
|
|
Date: ft.Date,
|
|
Amount: amount,
|
|
Person: ft.Person,
|
|
Purpose: ft.Purpose,
|
|
InferredAmount: inferredAmount,
|
|
Sender: ft.Sender,
|
|
Message: ft.Message,
|
|
}
|
|
}
|
|
|
|
func decodeExceptions(raws []json.RawMessage) map[reconcile.ExceptionKey]reconcile.Exception {
|
|
out := make(map[reconcile.ExceptionKey]reconcile.Exception)
|
|
for _, raw := range raws {
|
|
// Dict format: {"name": ..., "period": ..., "amount": ..., "note": ...}
|
|
var obj struct {
|
|
Name string `json:"name"`
|
|
Period string `json:"period"`
|
|
Amount int `json:"amount"`
|
|
Note string `json:"note"`
|
|
}
|
|
if err := json.Unmarshal(raw, &obj); err != nil {
|
|
continue
|
|
}
|
|
key := reconcile.ExceptionKey{
|
|
Name: czech.Normalize(obj.Name),
|
|
Period: czech.Normalize(obj.Period),
|
|
}
|
|
out[key] = reconcile.Exception{Amount: obj.Amount, Note: obj.Note}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Comparison helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const paidTolerance = 0.01
|
|
|
|
func comparePaid(want, got float64) bool {
|
|
return math.Abs(want-got) <= paidTolerance
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestReconcileParity(t *testing.T) {
|
|
t.Parallel()
|
|
dir := "../../fixtures/reconcile"
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Fatalf("cannot read fixture dir %q: %v", dir, err)
|
|
}
|
|
|
|
for _, e := range entries {
|
|
if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
|
|
continue
|
|
}
|
|
e := e
|
|
t.Run(e.Name(), func(t *testing.T) {
|
|
t.Parallel()
|
|
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
|
if err != nil {
|
|
t.Fatalf("read fixture: %v", err)
|
|
}
|
|
var doc fixtureDoc
|
|
if err := json.Unmarshal(data, &doc); err != nil {
|
|
t.Fatalf("decode fixture: %v", err)
|
|
}
|
|
|
|
// Decode input
|
|
members := make([]reconcile.Member, 0, len(doc.Input.Members))
|
|
for _, raw := range doc.Input.Members {
|
|
m, err := decodeMember(raw)
|
|
if err != nil {
|
|
t.Fatalf("decode member: %v", err)
|
|
}
|
|
members = append(members, m)
|
|
}
|
|
txs := make([]reconcile.Transaction, len(doc.Input.Transactions))
|
|
for i, ft := range doc.Input.Transactions {
|
|
txs[i] = decodeTransaction(ft)
|
|
}
|
|
exceptions := decodeExceptions(doc.Input.Exceptions)
|
|
|
|
// Run
|
|
got := reconcile.Reconcile(
|
|
members,
|
|
doc.Input.SortedMonths,
|
|
txs,
|
|
exceptions,
|
|
doc.Input.DefaultYear,
|
|
)
|
|
|
|
// Compare members
|
|
for name, wantMR := range doc.Output.Members {
|
|
gotMR, ok := got.Members[name]
|
|
if !ok {
|
|
t.Errorf("case %q: member %q missing from Go output", doc.Case, name)
|
|
continue
|
|
}
|
|
if gotMR.Tier != wantMR.Tier {
|
|
t.Errorf("case %q: member %q tier: want %q got %q", doc.Case, name, wantMR.Tier, gotMR.Tier)
|
|
}
|
|
if gotMR.TotalBalance != wantMR.TotalBalance {
|
|
t.Errorf("case %q: member %q total_balance: want %d got %d", doc.Case, name, wantMR.TotalBalance, gotMR.TotalBalance)
|
|
}
|
|
for month, wantMD := range wantMR.Months {
|
|
gotMD, ok := gotMR.Months[month]
|
|
if !ok {
|
|
t.Errorf("case %q: member %q month %q missing", doc.Case, name, month)
|
|
continue
|
|
}
|
|
if gotMD.Expected != wantMD.Expected {
|
|
t.Errorf("case %q: %q/%q expected: want %d got %d", doc.Case, name, month, wantMD.Expected, gotMD.Expected)
|
|
}
|
|
if gotMD.AttendanceCount != wantMD.AttendanceCount {
|
|
t.Errorf("case %q: %q/%q attendance_count: want %d got %d", doc.Case, name, month, wantMD.AttendanceCount, gotMD.AttendanceCount)
|
|
}
|
|
if !comparePaid(wantMD.Paid, gotMD.Paid) {
|
|
t.Errorf("case %q: %q/%q paid: want %.4f got %.4f (tol %.2f)", doc.Case, name, month, wantMD.Paid, gotMD.Paid, paidTolerance)
|
|
}
|
|
if len(gotMD.Transactions) != len(wantMD.Transactions) {
|
|
t.Errorf("case %q: %q/%q tx count: want %d got %d", doc.Case, name, month, len(wantMD.Transactions), len(gotMD.Transactions))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Compare unmatched count
|
|
if len(got.Unmatched) != len(doc.Output.Unmatched) {
|
|
t.Errorf("case %q: unmatched count: want %d got %d", doc.Case, len(doc.Output.Unmatched), len(got.Unmatched))
|
|
}
|
|
|
|
// Compare credits
|
|
for name, wantCredit := range doc.Output.Credits {
|
|
if gotCredit, ok := got.Credits[name]; !ok {
|
|
t.Errorf("case %q: credits missing for %q", doc.Case, name)
|
|
} else if gotCredit != wantCredit {
|
|
t.Errorf("case %q: credits[%q]: want %d got %d", doc.Case, name, wantCredit, gotCredit)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|