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