feat(go): fixture capture + characterization framework (M3)
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:
2026-05-06 23:26:24 +02:00
parent 28f0e468f7
commit 67d2f11d7c
119 changed files with 4931 additions and 10 deletions

View File

@@ -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)
},
)
}

View File

@@ -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)
},
)
}

View File

@@ -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)
},
)
}

View 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)
},
)
}

View File

@@ -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)
},
)
}

View File

@@ -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)
},
)
}

View File

@@ -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)
},
)
}

View 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)
},
)
}

View File

@@ -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
},
)
}

View File

@@ -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)
},
)
}