package invariants import ( "context" "reflect" "strings" "testing" "unicode/utf8" "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" ) func TestValidatorApprovesNormalizedCombatTurns(t *testing.T) { result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: invariantDocument(), Value: normalizedList()}) if err != nil || !result.Approved || len(result.Warnings) != 0 { t.Fatalf("Validate() = %#v, %v; want approval without warnings", result, err) } } func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) { tests := []struct { name string mutate func(*dnd.CombatTurnList, *source.SourceDocument) want string }{ {name: "actor display whitespace", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) { value.CombatTurns[0].Actor = " Aria " }, want: "actor is not display-normalized"}, {name: "summary display whitespace", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) { value.CombatTurns[0].Summary = "Aria attacks" }, want: "summary is not display-normalized"}, {name: "declaration display whitespace", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) { value.CombatTurns[0].Actions[0].Declaration = "Aria attacks" }, want: "declaration is not display-normalized"}, {name: "target display whitespace", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) { value.CombatTurns[0].Actions[0].Targets = []string{" Goblin"} }, want: "targets[0] is not display-normalized"}, {name: "duplicate target identity", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) { value.CombatTurns[0].Actions[0].Targets = []string{"Goblin", " goblin"} }, want: "duplicates target"}, {name: "resolution display whitespace", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) { resolution := " hit " value.CombatTurns[0].Actions[0].Resolution = &resolution }, want: "resolution is not display-normalized"}, {name: "reference order", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) { value.CombatTurns[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}} }, want: "not in canonical order"}, {name: "duplicate reference", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) { value.CombatTurns[0].SourceRefs = append(value.CombatTurns[0].SourceRefs, value.CombatTurns[0].SourceRefs[0]) }, want: "duplicates the previous reference"}, {name: "chronology", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) { value.CombatTurns = []dnd.CombatTurn{withRef(value.CombatTurns[0], source.SourceRef{SourceID: "session", StartUnitID: 20, EndUnitID: 20}), value.CombatTurns[0]} }, want: "out of chronological order"}, {name: "duplicate identity", mutate: func(value *dnd.CombatTurnList, _ *source.SourceDocument) { duplicate := value.CombatTurns[0] duplicate.Summary = "different prose" value.CombatTurns = append(value.CombatTurns, duplicate) }, want: "duplicates combat turn"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { value := normalizedList() doc := invariantDocument() test.mutate(&value, doc) err := Validate(doc, value) if err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("Validate() error = %v, want %q", err, test.want) } result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: doc, Value: value}) if err != nil || result.Approved || result.ReasonCode != ReasonCode { t.Fatalf("Validator result = %#v, %v; want normalized-invariant rejection", result, err) } }) } } func TestValidatorDefersShapeAndSourceReferenceFailures(t *testing.T) { doc := invariantDocument() shapeInvalid := normalizedList() shapeInvalid.CombatTurns[0].Actor = " " result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: doc, Value: shapeInvalid}) if err != nil || !result.Approved { t.Fatalf("shape-invalid result = %#v, %v; want deferral", result, err) } sourceInvalid := normalizedList() sourceInvalid.CombatTurns[0].SourceRefs[0].StartUnitID = 999 result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: doc, Value: sourceInvalid}) if err != nil || !result.Approved { t.Fatalf("source-invalid result = %#v, %v; want deferral", result, err) } } func TestValidatorBoundsDiagnosticsAndRegistration(t *testing.T) { value := normalizedList() value.CombatTurns = make([]dnd.CombatTurn, 30) for index := range value.CombatTurns { value.CombatTurns[index] = normalizedList().CombatTurns[0] value.CombatTurns[index].Actor = strings.Repeat("火", 300) } result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: invariantDocument(), Value: value}) if err != nil || result.Approved || !utf8.ValidString(result.Message) || len([]byte(result.Message)) > 4096 || !strings.Contains(result.Message, "additional issue(s) omitted") { t.Fatalf("bounded result = %#v, %v; want bounded rejection", result, err) } if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy { t.Fatalf("CheckpointFingerprints() = %#v, want policy fingerprint", got) } if spec := Spec(); spec.Key != Key || spec.ExecutionClass != contracts.ExecutionClassDeterministic { t.Fatalf("Spec() = %#v", spec) } registry := pipeline.NewValidatorRegistry() if err := Register(registry); err != nil { t.Fatalf("Register() error = %v", err) } if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil { t.Fatal("DecodeOptions() accepted unknown option") } if !reflect.DeepEqual(New(Options{}).CheckpointFingerprints(), []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}) { t.Fatal("policy fingerprint changed unexpectedly") } } func normalizedList() dnd.CombatTurnList { resolution := "hit" return dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{ Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn, Round: intPointer(1), Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "Aria attacks", Targets: []string{"Goblin"}, Resolution: &resolution}}, Summary: "Aria attacks", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}, }}} } func invariantDocument() *source.SourceDocument { return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}}} } func intPointer(value int) *int { return &value } func withRef(turn dnd.CombatTurn, ref source.SourceRef) dnd.CombatTurn { turn.SourceRefs = []source.SourceRef{ref} return turn }