//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) } } }) } }