package locationoccurrences import ( "context" "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" locationcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/locations" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" ) func TestNormalizeCanonicalizesNamesByIDAndClonesInputs(t *testing.T) { doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}} locations := registryLocations("The Tavern", "The Tavern") normalizer := newNormalizer(t, registryReferences(t, locations)) input := dnd.LocationOccurrenceList{Occurrences: []dnd.LocationOccurrence{{ LocationID: locations.Locations[1].ID, Name: " a tavern ", Kind: dnd.LocationOccurrenceKindVisited, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}, }}} before := cloneList(input) result, err := normalizer.Normalize(context.Background(), normalizeRequest(input, doc, contracts.ReferenceSet{})) if err != nil { t.Fatal(err) } occurrence := result.Value.Occurrences[0] if occurrence.Name != locations.Locations[1].Name || !reflect.DeepEqual(occurrence.SourceRefs, []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}) { t.Fatalf("normalized occurrence = %#v", occurrence) } if !hasWarning(result.Warnings, ReasonCodeNameCanonicalized) || !hasWarning(result.Warnings, ReasonCodeSourceRefsNormalized) || !reflect.DeepEqual(input, before) { t.Fatalf("warnings/input = %#v/%#v", result.Warnings, input) } second, err := normalizer.Normalize(context.Background(), normalizeRequest(result.Value, doc, contracts.ReferenceSet{})) if err != nil || !reflect.DeepEqual(second.Value, result.Value) || len(second.Warnings) != 0 { t.Fatalf("second normalization = %#v, %v", second, err) } result.Value.Occurrences[0].SourceRefs[0].StartUnitID = 999 if input.Occurrences[0].SourceRefs[0].StartUnitID == 999 { t.Fatal("normalized source references share input storage") } } func TestNormalizeKeepsSameNamedIDsAndDistinctEvidence(t *testing.T) { doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 50}, {ID: 10}, {ID: 90}}} locations := registryLocations("The Tavern", "The Tavern") first, second := locations.Locations[0], locations.Locations[1] ref := func(unit int) source.SourceRef { return source.SourceRef{SourceID: doc.ID, StartUnitID: unit, EndUnitID: unit} } firstMention := dnd.LocationOccurrence{LocationID: first.ID, Name: first.Name, Kind: dnd.LocationOccurrenceKindMentioned, SourceRefs: []source.SourceRef{ref(50)}} input := dnd.LocationOccurrenceList{Occurrences: []dnd.LocationOccurrence{ {LocationID: second.ID, Name: second.Name, Kind: dnd.LocationOccurrenceKindMentioned, SourceRefs: []source.SourceRef{ref(90)}}, firstMention, firstMention, {LocationID: first.ID, Name: first.Name, Kind: dnd.LocationOccurrenceKindVisited, SourceRefs: []source.SourceRef{ref(50)}}, {LocationID: first.ID, Name: first.Name, Kind: dnd.LocationOccurrenceKindPlanned, SourceRefs: []source.SourceRef{ref(50)}}, {LocationID: first.ID, Name: first.Name, Kind: dnd.LocationOccurrenceKindRecalled, SourceRefs: []source.SourceRef{ref(50)}}, {LocationID: first.ID, Name: first.Name, Kind: dnd.LocationOccurrenceKindMentioned, SourceRefs: []source.SourceRef{ref(10)}}, {LocationID: first.ID, Name: first.Name, Kind: dnd.LocationOccurrenceKindMentioned, SourceRefs: []source.SourceRef{ref(999)}}, }} result, err := newNormalizer(t, registryReferences(t, locations)).Normalize(context.Background(), normalizeRequest(input, doc, contracts.ReferenceSet{})) if err != nil { t.Fatal(err) } got := result.Value.Occurrences if len(got) != 7 || got[0].Kind != dnd.LocationOccurrenceKindVisited || got[1].Kind != dnd.LocationOccurrenceKindPlanned || got[2].Kind != dnd.LocationOccurrenceKindRecalled || got[3].Kind != dnd.LocationOccurrenceKindMentioned || got[3].SourceRefs[0].StartUnitID != 50 || got[4].SourceRefs[0].StartUnitID != 10 || got[5].LocationID != second.ID || got[6].SourceRefs[0].StartUnitID != 999 { t.Fatalf("canonical occurrences = %#v", got) } if !hasWarning(result.Warnings, ReasonCodeDuplicateCollapsed) || !hasWarning(result.Warnings, ReasonCodeOccurrencesReordered) { t.Fatalf("warnings = %#v", result.Warnings) } } func TestNormalizePreservesUnknownIDsAndMalformedOperationRegistry(t *testing.T) { doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}}} locations := registryLocations("The Mill") unknown := dnd.LocationOccurrence{LocationID: "location:sha256:unknown", Name: "The Mill", Kind: "unexpected", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}} result, err := newNormalizer(t, registryReferences(t, locations)).Normalize(context.Background(), normalizeRequest(dnd.LocationOccurrenceList{Occurrences: []dnd.LocationOccurrence{unknown}}, doc, contracts.ReferenceSet{})) if err != nil || !reflect.DeepEqual(result.Value.Occurrences[0], unknown) || !hasWarning(result.Warnings, ReasonCodeUnknownLocationID) { t.Fatalf("unknown normalization = %#v, %v", result, err) } malformed := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{LocationRegistryReferenceSlot: { Items: []contracts.ReferenceItem{{SlotName: LocationRegistryReferenceSlot, MediaType: "application/json", Content: []byte(`{"private":"registry evidence"}`)}}, }}} if _, err := newNormalizer(t).Normalize(context.Background(), normalizeRequest(dnd.LocationOccurrenceList{}, doc, malformed)); err == nil || !strings.Contains(err.Error(), "resolve location registry") || strings.Contains(err.Error(), "registry evidence") { t.Fatalf("operation registry error = %v", err) } } func TestNormalizerContractsRequiredRegistryAndWarningBounds(t *testing.T) { if _, err := New(Options{}, contracts.ReferenceSet{}, contracts.ReferenceSet{}); err == nil || !strings.Contains(err.Error(), "at most one reference set") { t.Fatalf("New() error = %v", err) } normalizer := newNormalizer(t, registryReferences(t, registryLocations("The Mill"))) spec := ModuleSpec() if spec.Key != Key || spec.Stage != pipeline.StageNormalize || spec.ExecutionClass != contracts.ExecutionClassDeterministic || spec.ArtifactKind != dnd.LocationOccurrenceListKind { t.Fatalf("ModuleSpec() = %#v", spec) } wantSlots := []contracts.ReferenceSlot{{ Name: LocationRegistryReferenceSlot, Description: "Required normalized location registry used only for location identity grounding, never as occurrence evidence.", Required: true, AcceptedMediaTypes: []string{"application/json"}, AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.LocationListKind}, MaxBytes: LocationRegistryMaxBytes, }} if !reflect.DeepEqual(spec.ReferenceSlots, wantSlots) { t.Fatalf("ModuleSpec().ReferenceSlots = %#v, want %#v", spec.ReferenceSlots, wantSlots) } if got := normalizer.ReferenceSlots(); !reflect.DeepEqual(got, wantSlots) { t.Fatalf("ReferenceSlots() = %#v, want %#v", got, wantSlots) } registry := pipeline.NewNormalizerRegistry() if err := Register(registry); err != nil { t.Fatal(err) } registeredSpec, ok := registry.Spec(Key) if !ok { t.Fatalf("registry missing %q", Key) } if !reflect.DeepEqual(registeredSpec.ReferenceSlots, wantSlots) { t.Fatalf("registered reference slots = %#v, want %#v", registeredSpec.ReferenceSlots, wantSlots) } if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil { t.Fatal("DecodeOptions() accepted unknown options") } if metadata := normalizer.ManifestMetadata(); metadata["normalization_policy"] != normalizationPolicy || metadata["location_registry_digest"] == "" || metadata["location_count"] != 1 { t.Fatalf("metadata = %#v", metadata) } if fingerprints := normalizer.CheckpointFingerprints(); len(fingerprints) != 2 || fingerprints[1].Name != "location_registry" || fingerprints[1].Value == "" { t.Fatalf("fingerprints = %#v", fingerprints) } if _, err := newNormalizer(t).Normalize(context.Background(), normalizeRequest(dnd.LocationOccurrenceList{}, nil, contracts.ReferenceSet{})); err == nil || !strings.Contains(err.Error(), "required") { t.Fatalf("unbound registry error = %v", err) } count := diagnostics.MaxWarnings + 5 doc := &source.SourceDocument{ID: "session", Units: make([]source.SourceUnit, count)} input := dnd.LocationOccurrenceList{Occurrences: make([]dnd.LocationOccurrence, count)} location := registryLocations("The Mill").Locations[0] for index := range doc.Units { doc.Units[index].ID = index + 1 input.Occurrences[index] = dnd.LocationOccurrence{LocationID: location.ID, Name: "not canonical", Kind: dnd.LocationOccurrenceKindMentioned, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: count - index, EndUnitID: count - index}}} } bounded, err := newNormalizer(t, registryReferences(t, dnd.LocationList{Locations: []dnd.Location{location}})).Normalize(context.Background(), normalizeRequest(input, doc, contracts.ReferenceSet{})) if err != nil || len(bounded.Warnings) != diagnostics.MaxWarnings || bounded.Warnings[len(bounded.Warnings)-1].ReasonCode != ReasonCodeWarningsOmitted { t.Fatalf("bounded warnings = %#v, %v", bounded.Warnings, err) } } func newNormalizer(t *testing.T, references ...contracts.ReferenceSet) *Normalizer { t.Helper() normalizer, err := New(Options{}, references...) if err != nil { t.Fatalf("New() error = %v", err) } return normalizer } func normalizeRequest(value dnd.LocationOccurrenceList, doc *source.SourceDocument, references contracts.ReferenceSet) contracts.TypedNormalizeRequest[dnd.LocationOccurrenceList] { return contracts.TypedNormalizeRequest[dnd.LocationOccurrenceList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.LocationOccurrenceList]{Value: value}, References: references} } func registryLocations(names ...string) dnd.LocationList { locations := make([]dnd.Location, len(names)) for index, name := range names { refs := []source.SourceRef{{SourceID: "registry", StartUnitID: index + 1, EndUnitID: index + 1}} locations[index] = dnd.Location{ID: identity.DeriveID(name, refs), Name: name, SourceRefs: refs} } return dnd.LocationList{Locations: locations} } func registryReferences(t *testing.T, locations dnd.LocationList) contracts.ReferenceSet { t.Helper() content, err := locationcodec.New().Encode(locations) if err != nil { t.Fatal(err) } return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{LocationRegistryReferenceSlot: { Items: []contracts.ReferenceItem{{SlotName: LocationRegistryReferenceSlot, MediaType: locationcodec.MediaType, Content: content}}, }}} } func cloneList(input dnd.LocationOccurrenceList) dnd.LocationOccurrenceList { output := dnd.LocationOccurrenceList{Occurrences: make([]dnd.LocationOccurrence, len(input.Occurrences))} for index, occurrence := range input.Occurrences { output.Occurrences[index] = occurrence output.Occurrences[index].SourceRefs = append([]source.SourceRef(nil), occurrence.SourceRefs...) } if input.Occurrences == nil { output.Occurrences = nil } return output } func hasWarning(warnings []contracts.Warning, code string) bool { for _, warning := range warnings { if warning.ReasonCode == code { return true } } return false }