Files
fuj-management/go/tests/parity/parityio.go
Jan Novak 67d2f11d7c
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
feat(go): fixture capture + characterization framework (M3)
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>
2026-05-06 23:26:24 +02:00

304 lines
7.8 KiB
Go

//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"`
}