Files
fuj-management/go/tests/parity/reconcile/reconcile_parity_test.go
Jan Novak 6465e2a221
All checks were successful
Deploy to K8s / deploy (push) Successful in 11s
feat(go): IO layer behind interfaces (M4)
- io/attendance: CSV-over-public-URL client + Fake for adult/junior tabs
- io/drive: Drive v3 modifiedTime client + Fake
- io/sheets: Sheets v4 client (GetValues/AppendValues/BatchUpdateValues/
  WriteHeader/SortByDateColumn) + Fake with call-capture
- io/cache: Drive-modifiedTime-gated FileCache; two TTL knobs; atomic
  writes; generic Get[T]; Python-compatible JSON format; Flush()
- io/fio: Client interface backed by Fio REST API (apiClient) and HTML
  scraper (transparentClient); Fake; testdata fixtures
- membership/sources: NewSources wires attendance CSV + Sheets + cache
  into LoadAdults/LoadJuniors/LoadTransactions/LoadExceptions; Czech
  month parsing + merged-month maps
- banksync: SyncToSheets (SHA-256 dedup, optional sort) and
  InferPayments ([?] review prefix, dry-run) — tested with fakes
- cmd/fuj: sync and infer subcommands wired; fees and reconcile use
  real NewSources; go.mod gains google.golang.org/api + x/net
- gofumpt extra-rules applied across all packages; lint clean

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 01:05:59 +02:00

261 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"
"math"
"os"
"path/filepath"
"testing"
"fuj-management/go/internal/domain/czech"
"fuj-management/go/internal/domain/reconcile"
)
// ---------------------------------------------------------------------------
// 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)
}
}
})
}
}