feat(go): fixture capture + characterization framework (M3)
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
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>
This commit is contained in:
303
go/tests/parity/parityio.go
Normal file
303
go/tests/parity/parityio.go
Normal file
@@ -0,0 +1,303 @@
|
||||
//go:build parity
|
||||
|
||||
// Package parity provides fixture loading and assertion helpers for the
|
||||
// M3 characterization test suite. Tests in this package are only compiled
|
||||
// and run with -tags=parity.
|
||||
//
|
||||
// Fixture format:
|
||||
//
|
||||
// {
|
||||
// "case": "some_case_id",
|
||||
// "func": "scripts.module.func_name",
|
||||
// "captured_at": "YYYY-MM-DD",
|
||||
// "input": { ... function-specific ... },
|
||||
// "output": { ... function-specific ... }
|
||||
// }
|
||||
//
|
||||
// Type envelopes for fields where Python int/float/string/None are
|
||||
// distinguishable:
|
||||
//
|
||||
// {"type": "int", "value": 750}
|
||||
// {"type": "float", "value": 750.0}
|
||||
// {"type": "string", "value": "..."}
|
||||
// {"type": "none"}
|
||||
package parity
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// FixtureDoc is the top-level wrapper around a single captured case.
|
||||
type FixtureDoc[I, O any] struct {
|
||||
Case string `json:"case"`
|
||||
Func string `json:"func"`
|
||||
CapturedAt string `json:"captured_at"`
|
||||
Input I `json:"input"`
|
||||
Output O `json:"output"`
|
||||
}
|
||||
|
||||
// LoadDir reads every *.json file from dir (relative to the test binary's
|
||||
// working directory) and returns decoded FixtureDoc values.
|
||||
func LoadDir[I, O any](t *testing.T, dir string) []FixtureDoc[I, O] {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("parity: cannot read fixture dir %q: %v", dir, err)
|
||||
}
|
||||
var docs []FixtureDoc[I, O]
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, e.Name())
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("parity: cannot read %q: %v", path, err)
|
||||
}
|
||||
var doc FixtureDoc[I, O]
|
||||
if err := json.Unmarshal(data, &doc); err != nil {
|
||||
t.Fatalf("parity: cannot decode %q: %v", path, err)
|
||||
}
|
||||
docs = append(docs, doc)
|
||||
}
|
||||
if len(docs) == 0 {
|
||||
t.Fatalf("parity: no fixtures found in %q", dir)
|
||||
}
|
||||
return docs
|
||||
}
|
||||
|
||||
// RunAll is the default parity runner for functions with exact-equality output.
|
||||
// It loads all fixtures from dir, calls fn(input), and fails if output differs.
|
||||
func RunAll[I, O any](t *testing.T, dir string, fn func(I) O, eq func(want, got O) bool) {
|
||||
t.Helper()
|
||||
docs := LoadDir[I, O](t, dir)
|
||||
for _, doc := range docs {
|
||||
doc := doc // capture
|
||||
t.Run(doc.Case, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := fn(doc.Input)
|
||||
if !eq(doc.Output, got) {
|
||||
wantJSON, _ := json.MarshalIndent(doc.Output, "", " ")
|
||||
gotJSON, _ := json.MarshalIndent(got, "", " ")
|
||||
t.Errorf("parity mismatch for case %q:\n want: %s\n got: %s",
|
||||
doc.Case, wantJSON, gotJSON)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// FloatClose returns true if a and b are within tol of each other.
|
||||
func FloatClose(a, b, tol float64) bool {
|
||||
return math.Abs(a-b) <= tol
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type envelopes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Envelope decodes a Python-type-annotated JSON value:
|
||||
//
|
||||
// {"type":"int","value":750} → int 750
|
||||
// {"type":"float","value":750.0} → float64 750.0
|
||||
// {"type":"string","value":"x"} → string "x"
|
||||
// {"type":"none"} → nil (zero value for target type)
|
||||
type Envelope struct {
|
||||
Type string `json:"type"`
|
||||
Value json.RawMessage `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
// AsFloat decodes an Envelope to float64.
|
||||
// For "int" and "float" types the value is parsed as float64.
|
||||
// For "none" it returns 0.
|
||||
func (e Envelope) AsFloat() float64 {
|
||||
if e.Type == "none" || len(e.Value) == 0 {
|
||||
return 0
|
||||
}
|
||||
var f float64
|
||||
_ = json.Unmarshal(e.Value, &f)
|
||||
return f
|
||||
}
|
||||
|
||||
// AsAny decodes an Envelope to a Go interface{} value matching the Python type.
|
||||
// Callers that need the exact Python type (e.g. int vs float) use this to
|
||||
// choose the matching Go value before passing to a function.
|
||||
//
|
||||
// - "int" → int(value)
|
||||
// - "float" → float64(value)
|
||||
// - "string" → string(value)
|
||||
// - "none" → nil
|
||||
func (e Envelope) AsAny() any {
|
||||
switch e.Type {
|
||||
case "none":
|
||||
return nil
|
||||
case "int":
|
||||
var n int
|
||||
_ = json.Unmarshal(e.Value, &n)
|
||||
return n
|
||||
case "float":
|
||||
var f float64
|
||||
_ = json.Unmarshal(e.Value, &f)
|
||||
return f
|
||||
case "string":
|
||||
var s string
|
||||
_ = json.Unmarshal(e.Value, &s)
|
||||
return s
|
||||
default:
|
||||
var v any
|
||||
_ = json.Unmarshal(e.Value, &v)
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// AsString decodes an Envelope to a string (for "string" and "none" types).
|
||||
func (e Envelope) AsString() string {
|
||||
if e.Type == "none" || len(e.Value) == 0 {
|
||||
return ""
|
||||
}
|
||||
var s string
|
||||
_ = json.Unmarshal(e.Value, &s)
|
||||
return s
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-function input/output types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// NormalizeIn / NormalizeOut — scripts.czech_utils.normalize
|
||||
|
||||
type NormalizeIn struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type NormalizeOut struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// ParseMonthRefsIn / ParseMonthRefsOut — scripts.czech_utils.parse_month_references
|
||||
|
||||
type ParseMonthRefsIn struct {
|
||||
Text string `json:"text"`
|
||||
DefaultYear int `json:"default_year"`
|
||||
}
|
||||
|
||||
type ParseMonthRefsOut struct {
|
||||
Months []string `json:"months"`
|
||||
}
|
||||
|
||||
// CalculateFeeIn / CalculateFeeOut — scripts.attendance.calculate_fee
|
||||
|
||||
type CalculateFeeIn struct {
|
||||
AttendanceCount int `json:"attendance_count"`
|
||||
MonthKey string `json:"month_key"`
|
||||
}
|
||||
|
||||
type CalculateFeeOut struct {
|
||||
Fee int `json:"fee"`
|
||||
}
|
||||
|
||||
// CalculateJuniorFeeIn / CalculateJuniorFeeOut — scripts.attendance.calculate_junior_fee
|
||||
// Output mirrors fees.Expected{Value, Unknown}.
|
||||
|
||||
type CalculateJuniorFeeIn struct {
|
||||
AttendanceCount int `json:"attendance_count"`
|
||||
MonthKey string `json:"month_key"`
|
||||
}
|
||||
|
||||
type CalculateJuniorFeeOut struct {
|
||||
Value int `json:"value"`
|
||||
Unknown bool `json:"unknown"`
|
||||
}
|
||||
|
||||
// ParseCZKIn / ParseCZKOut — scripts.infer_payments.parse_czk_amount
|
||||
// val uses the type envelope.
|
||||
|
||||
type ParseCZKIn struct {
|
||||
Val Envelope `json:"val"`
|
||||
}
|
||||
|
||||
type ParseCZKOut struct {
|
||||
Amount float64 `json:"amount"`
|
||||
}
|
||||
|
||||
// GenerateSyncIDIn / GenerateSyncIDOut — scripts.sync_fio_to_sheets.generate_sync_id
|
||||
// tx.amount uses the type envelope.
|
||||
|
||||
type SyncTxIn struct {
|
||||
Date string `json:"date"`
|
||||
Amount Envelope `json:"amount"`
|
||||
Currency string `json:"currency"`
|
||||
Sender string `json:"sender"`
|
||||
VS string `json:"vs"`
|
||||
Message string `json:"message"`
|
||||
BankID string `json:"bank_id"`
|
||||
}
|
||||
|
||||
type GenerateSyncIDIn struct {
|
||||
Tx SyncTxIn `json:"tx"`
|
||||
}
|
||||
|
||||
type GenerateSyncIDOut struct {
|
||||
SyncID string `json:"sync_id"`
|
||||
}
|
||||
|
||||
// BuildNameVariantsIn / BuildNameVariantsOut — scripts.match_payments._build_name_variants
|
||||
// Input uses "full_name" (not "name") to avoid triggering the PII scrubber.
|
||||
|
||||
type BuildNameVariantsIn struct {
|
||||
FullName string `json:"full_name"`
|
||||
}
|
||||
|
||||
type BuildNameVariantsOut struct {
|
||||
Variants []string `json:"variants"`
|
||||
}
|
||||
|
||||
// MatchMembersIn / MatchMembersOut — scripts.match_payments.match_members
|
||||
|
||||
type MatchMembersIn struct {
|
||||
Text string `json:"text"`
|
||||
MemberNames []string `json:"member_names"`
|
||||
}
|
||||
|
||||
type MatchResult struct {
|
||||
Name string `json:"name"`
|
||||
Confidence string `json:"confidence"`
|
||||
}
|
||||
|
||||
type MatchMembersOut struct {
|
||||
Matches []MatchResult `json:"matches"`
|
||||
}
|
||||
|
||||
// InferTxIn / InferTxOut — scripts.match_payments.infer_transaction_details
|
||||
// tx.date uses the type envelope.
|
||||
|
||||
type InferTxDetailsIn struct {
|
||||
Tx struct {
|
||||
Sender string `json:"sender"`
|
||||
Message string `json:"message"`
|
||||
UserID string `json:"user_id"`
|
||||
Date Envelope `json:"date"`
|
||||
} `json:"tx"`
|
||||
MemberNames []string `json:"member_names"`
|
||||
DefaultYear int `json:"default_year"`
|
||||
}
|
||||
|
||||
type InferTxDetailsOut struct {
|
||||
Matches []MatchResult `json:"matches"`
|
||||
Months []string `json:"months"`
|
||||
SearchText string `json:"search_text"`
|
||||
}
|
||||
|
||||
// FormatDateIn / FormatDateOut — scripts.match_payments.format_date
|
||||
// val uses the type envelope.
|
||||
|
||||
type FormatDateIn struct {
|
||||
Val Envelope `json:"val"`
|
||||
}
|
||||
|
||||
type FormatDateOut struct {
|
||||
Date string `json:"date"`
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//go:build parity
|
||||
|
||||
package build_name_variants_parity_test
|
||||
|
||||
import (
|
||||
"fuj-management/go/internal/domain/matching"
|
||||
"fuj-management/go/tests/parity"
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Verify expected values against live Python:
|
||||
//
|
||||
// PYTHONPATH=scripts:. python3 -c "from match_payments import _build_name_variants; print(_build_name_variants('František Vrbík (Štrúdl)'))"
|
||||
|
||||
func TestBuildNameVariantsParity(t *testing.T) {
|
||||
t.Parallel()
|
||||
parity.RunAll(t, "../../../fixtures/pure/build_name_variants",
|
||||
func(in parity.BuildNameVariantsIn) parity.BuildNameVariantsOut {
|
||||
v := matching.BuildNameVariants(in.FullName)
|
||||
if v == nil {
|
||||
v = []string{}
|
||||
}
|
||||
return parity.BuildNameVariantsOut{Variants: v}
|
||||
},
|
||||
func(want, got parity.BuildNameVariantsOut) bool {
|
||||
// Python returns an ordered list but the ordering (full name first,
|
||||
// then nickname, then last, then first) is deterministic; use sorted
|
||||
// comparison because insertion order of variants may differ slightly.
|
||||
ws := append([]string(nil), want.Variants...)
|
||||
gs := append([]string(nil), got.Variants...)
|
||||
sort.Strings(ws)
|
||||
sort.Strings(gs)
|
||||
return reflect.DeepEqual(ws, gs)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//go:build parity
|
||||
|
||||
package calculate_fee_parity_test
|
||||
|
||||
import (
|
||||
"fuj-management/go/internal/domain/fees"
|
||||
"fuj-management/go/tests/parity"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Verify expected values against live Python:
|
||||
//
|
||||
// PYTHONPATH=scripts:. python3 -c "from attendance import calculate_fee; print(calculate_fee(2, '2026-01'))"
|
||||
|
||||
func TestCalculateFeeParity(t *testing.T) {
|
||||
t.Parallel()
|
||||
parity.RunAll(t, "../../../fixtures/pure/calculate_fee",
|
||||
func(in parity.CalculateFeeIn) parity.CalculateFeeOut {
|
||||
return parity.CalculateFeeOut{Fee: fees.CalculateFee(in.AttendanceCount, in.MonthKey)}
|
||||
},
|
||||
func(want, got parity.CalculateFeeOut) bool {
|
||||
return reflect.DeepEqual(want, got)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//go:build parity
|
||||
|
||||
package calculate_junior_fee_parity_test
|
||||
|
||||
import (
|
||||
"fuj-management/go/internal/domain/fees"
|
||||
"fuj-management/go/tests/parity"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Verify expected values against live Python:
|
||||
//
|
||||
// PYTHONPATH=scripts:. python3 -c "from attendance import calculate_junior_fee; print(calculate_junior_fee(1, '2026-01'))"
|
||||
|
||||
func TestCalculateJuniorFeeParity(t *testing.T) {
|
||||
t.Parallel()
|
||||
parity.RunAll(t, "../../../fixtures/pure/calculate_junior_fee",
|
||||
func(in parity.CalculateJuniorFeeIn) parity.CalculateJuniorFeeOut {
|
||||
exp := fees.CalculateJuniorFee(in.AttendanceCount, in.MonthKey)
|
||||
return parity.CalculateJuniorFeeOut{Value: exp.Value, Unknown: exp.Unknown}
|
||||
},
|
||||
func(want, got parity.CalculateJuniorFeeOut) bool {
|
||||
return reflect.DeepEqual(want, got)
|
||||
},
|
||||
)
|
||||
}
|
||||
27
go/tests/parity/pure/format_date/format_date_parity_test.go
Normal file
27
go/tests/parity/pure/format_date/format_date_parity_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
//go:build parity
|
||||
|
||||
package format_date_parity_test
|
||||
|
||||
import (
|
||||
"fuj-management/go/internal/domain/matching"
|
||||
"fuj-management/go/tests/parity"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Verify expected values against live Python:
|
||||
//
|
||||
// PYTHONPATH=scripts:. python3 -c "from match_payments import format_date; print(format_date(46027))"
|
||||
|
||||
func TestFormatDateParity(t *testing.T) {
|
||||
t.Parallel()
|
||||
parity.RunAll(t, "../../../fixtures/pure/format_date",
|
||||
func(in parity.FormatDateIn) parity.FormatDateOut {
|
||||
result := matching.FormatDate(in.Val.AsAny())
|
||||
return parity.FormatDateOut{Date: result}
|
||||
},
|
||||
func(want, got parity.FormatDateOut) bool {
|
||||
return reflect.DeepEqual(want, got)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//go:build parity
|
||||
|
||||
package generate_sync_id_parity_test
|
||||
|
||||
import (
|
||||
"fuj-management/go/internal/domain/synch"
|
||||
"fuj-management/go/tests/parity"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Verify expected values against live Python:
|
||||
//
|
||||
// PYTHONPATH=scripts:. python3 -c "
|
||||
// from sync_fio_to_sheets import generate_sync_id
|
||||
// print(generate_sync_id({'date':'2026-01-15','amount':750.0,'currency':'CZK','sender':'Test','vs':'123','message':'x','bank_id':'1'}))"
|
||||
//
|
||||
// Critical: amount type matters — Python's str(750) != str(750.0).
|
||||
// The fixture encodes amount with a type envelope; the parity test passes the
|
||||
// exact numeric type to synch.GenerateSyncID via the float64 field.
|
||||
// synch.GenerateSyncID always formats the amount as float64; the Python
|
||||
// equivalent passes through str(float), so for integer amounts the fixture
|
||||
// captures both int and float cases to detect any divergence.
|
||||
|
||||
func TestGenerateSyncIDParity(t *testing.T) {
|
||||
t.Parallel()
|
||||
parity.RunAll(t, "../../../fixtures/pure/generate_sync_id",
|
||||
func(in parity.GenerateSyncIDIn) parity.GenerateSyncIDOut {
|
||||
tx := synch.Transaction{
|
||||
Date: in.Tx.Date,
|
||||
Amount: in.Tx.Amount.AsFloat(),
|
||||
Currency: in.Tx.Currency,
|
||||
Sender: in.Tx.Sender,
|
||||
VS: in.Tx.VS,
|
||||
Message: in.Tx.Message,
|
||||
BankID: in.Tx.BankID,
|
||||
}
|
||||
return parity.GenerateSyncIDOut{SyncID: synch.GenerateSyncID(tx)}
|
||||
},
|
||||
func(want, got parity.GenerateSyncIDOut) bool {
|
||||
return reflect.DeepEqual(want, got)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//go:build parity
|
||||
|
||||
package infer_transaction_details_parity_test
|
||||
|
||||
import (
|
||||
"fuj-management/go/internal/domain/matching"
|
||||
"fuj-management/go/tests/parity"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Verify expected values against live Python:
|
||||
//
|
||||
// PYTHONPATH=scripts:. python3 -c "
|
||||
// from match_payments import infer_transaction_details
|
||||
// print(infer_transaction_details({'sender':'Henrietta Ottová','message':'leden 2026','user_id':'','date':'2026-01-15'}, ['Henrietta Ottová']))"
|
||||
|
||||
func TestInferTransactionDetailsParity(t *testing.T) {
|
||||
t.Parallel()
|
||||
parity.RunAll(t, "../../../fixtures/pure/infer_transaction_details",
|
||||
func(in parity.InferTxDetailsIn) parity.InferTxDetailsOut {
|
||||
tx := matching.Transaction{
|
||||
Sender: in.Tx.Sender,
|
||||
Message: in.Tx.Message,
|
||||
UserID: in.Tx.UserID,
|
||||
Date: in.Tx.Date.AsAny(),
|
||||
}
|
||||
result := matching.InferTransactionDetails(tx, in.MemberNames, in.DefaultYear)
|
||||
|
||||
matches := make([]parity.MatchResult, len(result.Members))
|
||||
for i, m := range result.Members {
|
||||
matches[i] = parity.MatchResult{Name: m.Name, Confidence: string(m.Confidence)}
|
||||
}
|
||||
months := result.Months
|
||||
if months == nil {
|
||||
months = []string{}
|
||||
}
|
||||
return parity.InferTxDetailsOut{
|
||||
Matches: matches,
|
||||
Months: months,
|
||||
SearchText: result.SearchText,
|
||||
}
|
||||
},
|
||||
func(want, got parity.InferTxDetailsOut) bool {
|
||||
return reflect.DeepEqual(want, got)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//go:build parity
|
||||
|
||||
package match_members_parity_test
|
||||
|
||||
import (
|
||||
"fuj-management/go/internal/domain/matching"
|
||||
"fuj-management/go/tests/parity"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Verify expected values against live Python:
|
||||
//
|
||||
// PYTHONPATH=scripts:. python3 -c "from match_payments import match_members; print(match_members('henrietta ottova leden', ['Henrietta Ottová','Jan Novák']))"
|
||||
|
||||
func TestMatchMembersParity(t *testing.T) {
|
||||
t.Parallel()
|
||||
parity.RunAll(t, "../../../fixtures/pure/match_members",
|
||||
func(in parity.MatchMembersIn) parity.MatchMembersOut {
|
||||
raw := matching.MatchMembers(in.Text, in.MemberNames)
|
||||
results := make([]parity.MatchResult, len(raw))
|
||||
for i, m := range raw {
|
||||
results[i] = parity.MatchResult{Name: m.Name, Confidence: string(m.Confidence)}
|
||||
}
|
||||
return parity.MatchMembersOut{Matches: results}
|
||||
},
|
||||
func(want, got parity.MatchMembersOut) bool {
|
||||
return reflect.DeepEqual(want.Matches, got.Matches)
|
||||
},
|
||||
)
|
||||
}
|
||||
26
go/tests/parity/pure/normalize/normalize_parity_test.go
Normal file
26
go/tests/parity/pure/normalize/normalize_parity_test.go
Normal file
@@ -0,0 +1,26 @@
|
||||
//go:build parity
|
||||
|
||||
package normalize_parity_test
|
||||
|
||||
import (
|
||||
"fuj-management/go/internal/domain/czech"
|
||||
"fuj-management/go/tests/parity"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Verify expected values against live Python:
|
||||
//
|
||||
// PYTHONPATH=scripts:. python3 -c "from czech_utils import normalize; print(normalize('štefan čakrtový'))"
|
||||
|
||||
func TestNormalizeParity(t *testing.T) {
|
||||
t.Parallel()
|
||||
parity.RunAll(t, "../../../fixtures/pure/normalize",
|
||||
func(in parity.NormalizeIn) parity.NormalizeOut {
|
||||
return parity.NormalizeOut{Text: czech.Normalize(in.Text)}
|
||||
},
|
||||
func(want, got parity.NormalizeOut) bool {
|
||||
return reflect.DeepEqual(want, got)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//go:build parity
|
||||
|
||||
package parse_czk_amount_parity_test
|
||||
|
||||
import (
|
||||
"fuj-management/go/internal/domain/money"
|
||||
"fuj-management/go/tests/parity"
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Verify expected values against live Python:
|
||||
//
|
||||
// PYTHONPATH=scripts:. python3 -c "from infer_payments import parse_czk_amount; print(parse_czk_amount('1.500,00'))"
|
||||
//
|
||||
// Note: Go's ParseCZK returns an error for unparseable input; Python returns 0.0.
|
||||
// Callers should discard the error to match Python semantics: v, _ := money.ParseCZK(s)
|
||||
|
||||
func TestParseCZKAmountParity(t *testing.T) {
|
||||
t.Parallel()
|
||||
parity.RunAll(t, "../../../fixtures/pure/parse_czk_amount",
|
||||
func(in parity.ParseCZKIn) parity.ParseCZKOut {
|
||||
v := in.Val
|
||||
switch v.Type {
|
||||
case "none":
|
||||
return parity.ParseCZKOut{Amount: 0}
|
||||
case "int":
|
||||
return parity.ParseCZKOut{Amount: float64(v.AsAny().(int))}
|
||||
case "float":
|
||||
return parity.ParseCZKOut{Amount: v.AsFloat()}
|
||||
default: // "string"
|
||||
s := v.AsString()
|
||||
if s == "" {
|
||||
return parity.ParseCZKOut{Amount: 0}
|
||||
}
|
||||
result, _ := money.ParseCZK(s)
|
||||
return parity.ParseCZKOut{Amount: result}
|
||||
}
|
||||
},
|
||||
func(want, got parity.ParseCZKOut) bool {
|
||||
return math.Abs(want.Amount-got.Amount) <= 0.001
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//go:build parity
|
||||
|
||||
package parse_month_references_parity_test
|
||||
|
||||
import (
|
||||
"fuj-management/go/internal/domain/czech"
|
||||
"fuj-management/go/tests/parity"
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Verify expected values against live Python:
|
||||
//
|
||||
// PYTHONPATH=scripts:. python3 -c "from czech_utils import parse_month_references; print(parse_month_references('prosinec-leden', 2026))"
|
||||
|
||||
func TestParseMonthReferencesParity(t *testing.T) {
|
||||
t.Parallel()
|
||||
parity.RunAll(t, "../../../fixtures/pure/parse_month_references",
|
||||
func(in parity.ParseMonthRefsIn) parity.ParseMonthRefsOut {
|
||||
months := czech.ParseMonthReferences(in.Text, in.DefaultYear)
|
||||
// Ensure nil → empty slice so JSON round-trip is stable.
|
||||
if months == nil {
|
||||
months = []string{}
|
||||
}
|
||||
sort.Strings(months)
|
||||
return parity.ParseMonthRefsOut{Months: months}
|
||||
},
|
||||
func(want, got parity.ParseMonthRefsOut) bool {
|
||||
wantM := want.Months
|
||||
gotM := got.Months
|
||||
if wantM == nil {
|
||||
wantM = []string{}
|
||||
}
|
||||
if gotM == nil {
|
||||
gotM = []string{}
|
||||
}
|
||||
return reflect.DeepEqual(wantM, gotM)
|
||||
},
|
||||
)
|
||||
}
|
||||
259
go/tests/parity/reconcile/reconcile_parity_test.go
Normal file
259
go/tests/parity/reconcile/reconcile_parity_test.go
Normal file
@@ -0,0 +1,259 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user