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"`
|
||||
}
|
||||
Reference in New Issue
Block a user