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/npcregistry" "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.NPCOccurrenceList) want string }{ {"mismatched name", func(value *dnd.NPCOccurrenceList) { value.Occurrences[0].Name = " aria " }, "does not match npc_id"}, {"reference order", func(value *dnd.NPCOccurrenceList) { value.Occurrences[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.NPCOccurrenceList) { value.Occurrences[0].SourceRefs = append(value.Occurrences[0].SourceRefs, value.Occurrences[0].SourceRefs[0]) }, "duplicates the previous reference"}, {"list order", func(value *dnd.NPCOccurrenceList) { value.Occurrences[0], value.Occurrences[1] = value.Occurrences[1], value.Occurrences[0] }, "occurrences are not in canonical order"}, {"NPC ID tie breaker", func(value *dnd.NPCOccurrenceList) { value.Occurrences[0] = dnd.NPCOccurrence{NPCID: identity.DeriveID("Aria"), Name: "Aria", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}} value.Occurrences[1] = dnd.NPCOccurrence{NPCID: identity.DeriveID("Borin"), Name: "Borin", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}} }, "occurrences are not in canonical order"}, {"duplicate record", func(value *dnd.NPCOccurrenceList) { value.Occurrences = append(value.Occurrences, value.Occurrences[0]) }, "duplicates occurrence"}, } { 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.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{ {NPCID: "npc:test", Name: "Borin", Kind: dnd.NPCOccurrenceKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}, {NPCID: "npc:test", Name: "Aria", Kind: dnd.NPCOccurrenceKindDialogue, 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.NPCOccurrenceList{ {Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Aria"}}}, {Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Aria", Kind: dnd.NPCOccurrenceKindDialogue, 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.NPCOccurrenceList) contracts.TypedValidationRequest[dnd.NPCOccurrenceList] { return contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{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.NPCOccurrenceList { return dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{ {NPCID: identity.DeriveID("Aria"), Name: "Aria", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}}, {NPCID: identity.DeriveID("Borin"), Name: "Borin", Kind: dnd.NPCOccurrenceKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}, }} } func cloneList(value dnd.NPCOccurrenceList) dnd.NPCOccurrenceList { copyValue := dnd.NPCOccurrenceList{Occurrences: make([]dnd.NPCOccurrence, len(value.Occurrences))} for index, interaction := range value.Occurrences { copyValue.Occurrences[index] = interaction copyValue.Occurrences[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"}}}, }, }} }