package contract import ( "encoding/json" "os" "path/filepath" "reflect" "testing" ) // fixtureDir holds JSON copies of the spec-derived YAML fixtures, written by // conformance/validate_schemas.py. const fixtureDir = "../../conformance/fixtures/json" // roundTrip decodes a fixture into T, re-encodes it, and reports any field the // generated types dropped on the way through. // // This is the drift check that matters: a schema change that schemagen did not // pick up shows here as a field that vanishes. func roundTrip[T any](t *testing.T, name string) { t.Helper() raw, err := os.ReadFile(filepath.Join(fixtureDir, name+".json")) if err != nil { t.Fatalf("read fixture: %v (run `make validate` first)", err) } var typed T dec := json.NewDecoder(bytesReader(raw)) dec.DisallowUnknownFields() if err := dec.Decode(&typed); err != nil { t.Fatalf("decode into %T: %v", typed, err) } encoded, err := json.Marshal(typed) if err != nil { t.Fatalf("re-encode: %v", err) } var before, after map[string]any if err := json.Unmarshal(raw, &before); err != nil { t.Fatalf("unmarshal fixture: %v", err) } if err := json.Unmarshal(encoded, &after); err != nil { t.Fatalf("unmarshal re-encoded: %v", err) } if missing := missingKeys(before, after, ""); len(missing) > 0 { t.Errorf("round trip dropped fields: %v", missing) } } // missingKeys walks want and reports paths absent from got. // // Explicit nulls and empty collections are skipped. The schemas treat // "end: null" and an absent "end" as the same statement (the window is still // open), so omitempty dropping them on re-encode is correct rather than drift. // Genuine drift -- a fixture field the generated types have no home for -- is // caught by DisallowUnknownFields on the way in. func missingKeys(want, got map[string]any, prefix string) []string { var missing []string for k, wv := range want { path := k if prefix != "" { path = prefix + "." + k } if isEmptyValue(wv) { continue } gv, ok := got[k] if !ok { missing = append(missing, path) continue } wm, wok := wv.(map[string]any) gm, gok := gv.(map[string]any) if wok && gok { missing = append(missing, missingKeys(wm, gm, path)...) } } return missing } // isEmptyValue reports values the schema treats as equivalent to absent. func isEmptyValue(v any) bool { switch t := v.(type) { case nil: return true case []any: return len(t) == 0 case map[string]any: return len(t) == 0 } return false } func TestFixturesRoundTrip(t *testing.T) { t.Run("pressure", func(t *testing.T) { roundTrip[PressureDocument](t, "pressure") }) t.Run("hypothesis", func(t *testing.T) { roundTrip[HypothesisDocument](t, "hypothesis") }) t.Run("revision", func(t *testing.T) { roundTrip[RevisionDocument](t, "revision") }) t.Run("experiment", func(t *testing.T) { roundTrip[ExperimentDocument](t, "experiment") }) t.Run("event", func(t *testing.T) { roundTrip[EventDocument](t, "event") }) t.Run("feedback", func(t *testing.T) { roundTrip[FeedbackDocument](t, "feedback") }) t.Run("backend-requirement", func(t *testing.T) { roundTrip[BackendRequirementDocument](t, "backend-requirement") }) t.Run("revision-descriptor", func(t *testing.T) { roundTrip[RevisionDescriptorDocument](t, "revision-descriptor") }) t.Run("routing-policy", func(t *testing.T) { roundTrip[RoutingPolicyDocument](t, "routing-policy") }) t.Run("telemetry-envelope", func(t *testing.T) { roundTrip[TelemetryEnvelopeDocument](t, "telemetry-envelope") }) } func TestKindOf(t *testing.T) { cases := map[string]EntityKind{ "H-000184": KindHypothesis, "R-000221": KindRevision, "E-000093": KindExperiment, "P-1831": KindPressure, "BR-0041": KindBackendRequirement, "D-2811": KindDecision, "EV-990281": KindEvent, "F-9821": KindFeedback, "C-17": KindCohort, } for id, want := range cases { got, ok := KindOf(id) if !ok || got != want { t.Errorf("KindOf(%q) = %q, %v; want %q", id, got, ok, want) } } // BR- and EV- must not be swallowed by the single-letter prefixes. if k, _ := KindOf("BR-1"); k != KindBackendRequirement { t.Errorf("BR- prefix mis-classified as %q", k) } if k, _ := KindOf("EV-1"); k != KindEvent { t.Errorf("EV- prefix mis-classified as %q", k) } if _, ok := KindOf("something"); ok { t.Error("unprefixed identifier should not classify") } } func TestRequireKind(t *testing.T) { if err := RequireKind("R-1", KindRevision); err != nil { t.Errorf("valid revision id rejected: %v", err) } err := RequireKind("H-1", KindRevision) if err == nil { t.Fatal("hypothesis id accepted where a revision id was required") } var wrong *ErrWrongKind if !errorsAs(err, &wrong) { t.Fatalf("expected *ErrWrongKind, got %T", err) } if wrong.Got != KindHypothesis || wrong.Want != KindRevision { t.Errorf("unexpected error detail: %+v", wrong) } } // Small helpers keep the test file free of imports the generated code does not // already require. func bytesReader(b []byte) *jsonReader { return &jsonReader{b: b} } type jsonReader struct { b []byte i int } func (r *jsonReader) Read(p []byte) (int, error) { if r.i >= len(r.b) { return 0, errEOF } n := copy(p, r.b[r.i:]) r.i += n return n, nil } var errEOF = errorString("EOF") type errorString string func (e errorString) Error() string { return string(e) } func errorsAs(err error, target any) bool { tv := reflect.ValueOf(target).Elem() for err != nil { if reflect.TypeOf(err).AssignableTo(tv.Type()) { tv.Set(reflect.ValueOf(err)) return true } u, ok := err.(interface{ Unwrap() error }) if !ok { return false } err = u.Unwrap() } return false }