package invariants import ( "context" "encoding/json" "reflect" "strings" "testing" "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" npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry" ) func TestValidatorApprovesCanonicalNormalizedInteractions(t *testing.T) { references := registryReferences(t, "Aria", "Borin") result, err := newValidator(t, references).Validate(context.Background(), request(references, normalizedList())) if err != nil || !result.Approved { t.Fatalf("Validate() = %#v, %v", result, err) } } func TestValidatorRejectsOwnedCanonicalNameReferenceOrderListOrderAndDuplicates(t *testing.T) { references := registryReferences(t, "Aria", "Borin") for _, test := range []struct { name string mutate func(*dnd.NPCInteractionList) want string }{ {"canonical name", func(value *dnd.NPCInteractionList) { value.Interactions[0].Name = " aria " }, "canonical NPC display name"}, {"reference order", func(value *dnd.NPCInteractionList) { value.Interactions[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}} }, "not in canonical order"}, {"duplicate reference", func(value *dnd.NPCInteractionList) { value.Interactions[0].SourceRefs = append(value.Interactions[0].SourceRefs, value.Interactions[0].SourceRefs[0]) }, "duplicates the previous reference"}, {"list order", func(value *dnd.NPCInteractionList) { value.Interactions[0], value.Interactions[1] = value.Interactions[1], value.Interactions[0] }, "interactions are not in canonical order"}, {"name tie breaker", func(value *dnd.NPCInteractionList) { value.Interactions[0] = dnd.NPCInteraction{Name: "Borin", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}} value.Interactions[1] = dnd.NPCInteraction{Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}} }, "interactions are not in canonical order"}, {"duplicate record", func(value *dnd.NPCInteractionList) { value.Interactions = append(value.Interactions, value.Interactions[0]) }, "duplicates interaction"}, } { t.Run(test.name, func(t *testing.T) { value := normalizedList() test.mutate(&value) result, err := newValidator(t, references).Validate(context.Background(), request(references, value)) if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, test.want) { t.Fatalf("Validate() = %#v, %v; want %q", result, err, test.want) } }) } } func TestValidatorAcceptsValidEvidenceBeforeInvalidEvidence(t *testing.T) { references := registryReferences(t, "Aria", "Borin") value := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{ {Name: "Borin", Kind: dnd.NPCInteractionKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}, {Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}, }} result, err := newValidator(t, references).Validate(context.Background(), request(references, value)) if err != nil || !result.Approved { t.Fatalf("Validate() = %#v, %v", result, err) } } func TestValidatorDefersShapeAndSourceReferenceFailuresAndRequiresRegistry(t *testing.T) { references := registryReferences(t, "Aria", "Borin") for _, value := range []dnd.NPCInteractionList{ {Interactions: []dnd.NPCInteraction{{Name: "Aria"}}}, {Interactions: []dnd.NPCInteraction{{Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}}, } { result, err := newValidator(t, references).Validate(context.Background(), request(references, value)) if err != nil || !result.Approved { t.Fatalf("deferral = %#v, %v", result, err) } } result, err := newValidator(t).Validate(context.Background(), request(contracts.ReferenceSet{}, normalizedList())) if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "required") { t.Fatalf("unbound registry = %#v, %v", result, err) } } func TestValidatorResolvesGeneratedRegistryAndKeepsMetadataAndInputsImmutable(t *testing.T) { references := registryReferences(t, "Aria", "Borin") validator := newValidator(t) value := normalizedList() before := cloneList(value) result, err := validator.Validate(context.Background(), request(references, value)) if err != nil || !result.Approved || !reflect.DeepEqual(value, before) { t.Fatalf("generated validation = %#v, %v; value=%#v", result, err, value) } metadata, err := json.Marshal(newValidator(t, references).ManifestMetadata()) if err != nil { t.Fatal(err) } if strings.Contains(string(metadata), "Aria") || strings.Contains(string(metadata), "other-session") { t.Fatalf("metadata leaked registry content: %s", metadata) } if got := newValidator(t, references).CheckpointFingerprints(); len(got) != 2 || got[0].Value != policy || !strings.HasPrefix(got[1].Value, "sha256:") { t.Fatalf("CheckpointFingerprints() = %#v", got) } registry := pipeline.NewValidatorRegistry() if err := Register(registry); err != nil { t.Fatal(err) } if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil { t.Fatal("DecodeOptions() accepted unknown option") } } func newValidator(t *testing.T, references ...contracts.ReferenceSet) *Validator { t.Helper() validator, err := New(Options{}, references...) if err != nil { t.Fatal(err) } return validator } func request(references contracts.ReferenceSet, value dnd.NPCInteractionList) contracts.TypedValidationRequest[dnd.NPCInteractionList] { return contracts.TypedValidationRequest[dnd.NPCInteractionList]{Source: document(), References: references, Value: value} } func document() *source.SourceDocument { return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}}} } func normalizedList() dnd.NPCInteractionList { return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{ {Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}}, {Name: "Borin", Kind: dnd.NPCInteractionKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}, }} } func cloneList(value dnd.NPCInteractionList) dnd.NPCInteractionList { copyValue := dnd.NPCInteractionList{Interactions: make([]dnd.NPCInteraction, len(value.Interactions))} for index, interaction := range value.Interactions { copyValue.Interactions[index] = interaction copyValue.Interactions[index].SourceRefs = append([]source.SourceRef(nil), interaction.SourceRefs...) } return copyValue } func registryReferences(t *testing.T, names ...string) contracts.ReferenceSet { t.Helper() npcs := make([]dnd.NPC, len(names)) for index, name := range names { npcs[index] = dnd.NPC{ID: identity.DeriveID(name), Name: name, SourceRefs: []source.SourceRef{{SourceID: "other-session", StartUnitID: index + 1, EndUnitID: index + 1}}} } content, err := npccodec.New().Encode(dnd.NPCRegistry{NPCs: npcs}) if err != nil { t.Fatal(err) } return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ npcregistry.ReferenceSlot: { Slot: contracts.ReferenceSlot{Name: npcregistry.ReferenceSlot}, Items: []contracts.ReferenceItem{{SlotName: npcregistry.ReferenceSlot, MediaType: npccodec.MediaType, Content: content, Origin: contracts.ReferenceOrigin{Type: "generated"}}}, }, }} }