package itemoccurrences import ( "context" "strings" "testing" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" itemidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity" ) func TestExtractGroundsOccurrencesInRequiredRegistry(t *testing.T) { id := itemidentity.DeriveID("Torch") client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{ {Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)}, }}} req := extractionRequest() req.References = itemRegistryReferences(t) result, err := newExtractor(t, client, req.References).Extract(context.Background(), req) if err != nil { t.Fatal(err) } if len(result.Value.Occurrences) != 1 || result.Value.Occurrences[0].ItemID != id || result.Value.Occurrences[0].Name != "Torch" { t.Fatalf("occurrences = %#v", result.Value.Occurrences) } if refs := result.Value.Occurrences[0].SourceRefs; len(refs) != 1 || refs[0].SourceID != req.Source.ID || refs[0].StartUnitID != 1 || refs[0].EndUnitID != 1 { t.Fatalf("occurrence evidence = %#v, want current-source unit range", refs) } input := client.requests[0].Inputs[ItemRegistryReferenceSlot] if input.Name != ItemRegistryReferenceSlot || string(input.Content) != `{"items":[{"name":"Torch"}]}` || strings.Contains(string(input.Content), "item:sha256:") { t.Fatalf("registry prompt input = %#v, want names-only projection", input) } } func TestExtractCanonicalizesComparisonEquivalentNames(t *testing.T) { id := itemidentity.DeriveID("Torch") client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{ {Name: " tORCH ", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)}, }}} req := extractionRequest() req.References = itemRegistryReferences(t) result, err := newExtractor(t, client, req.References).Extract(context.Background(), req) if err != nil { t.Fatal(err) } occurrence := result.Value.Occurrences[0] if occurrence.ItemID != id || occurrence.Name != "Torch" { t.Fatalf("canonical occurrence = %#v", occurrence) } } func TestExtractRejectsUnknownNameAfterValidOccurrence(t *testing.T) { client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{ {Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)}, {Name: "Unknown", Kind: "lost", From: "party", SourceRefs: responseRefs(2, 2)}, }}} req := extractionRequest() req.References = itemRegistryReferences(t) result, err := newExtractor(t, client, req.References).Extract(context.Background(), req) if err == nil || !strings.Contains(err.Error(), "occurrences[1].name is not in the item registry") { t.Fatalf("Extract() error = %v, want occurrence index and unknown name", err) } if len(result.Value.Occurrences) != 0 { t.Fatalf("Extract() returned partial occurrences: %#v", result.Value.Occurrences) } } func TestExtractRequiresItemRegistry(t *testing.T) { _, err := newExtractor(t, &fakeItemOccurrencesLLMClient{}).Extract(context.Background(), extractionRequest()) if err == nil { t.Fatal("Extract() error = nil, want required registry error") } } func TestExtractAcceptsOnlyEmptyResponseForEmptyRegistry(t *testing.T) { references := emptyItemRegistryReferences(t) for _, test := range []struct { name string response extractionResponse wantErr bool }{ {name: "empty", response: extractionResponse{Occurrences: []itemOccurrenceResponse{}}}, {name: "selection", response: extractionResponse{Occurrences: []itemOccurrenceResponse{{Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)}}}, wantErr: true}, } { t.Run(test.name, func(t *testing.T) { client := &fakeItemOccurrencesLLMClient{response: test.response} req := extractionRequest() req.References = references result, err := newExtractor(t, client, references).Extract(context.Background(), req) if test.wantErr { if err == nil || !strings.Contains(err.Error(), "name is not in the item registry") || len(result.Value.Occurrences) != 0 { t.Fatalf("Extract() = %#v, %v; want no accepted occurrences", result, err) } return } if err != nil || result.Value.Occurrences == nil || len(result.Value.Occurrences) != 0 { t.Fatalf("Extract() = %#v, %v; want present empty occurrences", result, err) } }) } } func TestExtractPreservesNullableFields(t *testing.T) { client := &fakeItemOccurrencesLLMClient{content: []byte(`{"occurrences":[{"name":"Torch","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`)} req := extractionRequest() req.References = itemRegistryReferences(t) result, err := newExtractor(t, client, req.References).Extract(context.Background(), req) if err != nil || len(result.Value.Occurrences) != 1 || result.Value.Occurrences[0].Quantity != nil || result.Value.Occurrences[0].From != "" || result.Value.Occurrences[0].To != "" { t.Fatalf("result = %#v, %v", result, err) } } func TestExtractUsesOnlySupportedPromptInputs(t *testing.T) { client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{}}} req := extractionRequest() req.References = itemRegistryReferences(t) req.References.Slots["unrelated"] = contracts.ResolvedReferenceSlot{Slot: contracts.ReferenceSlot{Name: "unrelated"}, Items: []contracts.ReferenceItem{{SlotName: "unrelated", Content: []byte("ignored")}}} if _, err := newExtractor(t, client, req.References).Extract(context.Background(), req); err != nil { t.Fatal(err) } if _, ok := client.requests[0].Inputs["unrelated"]; ok { t.Fatalf("unexpected prompt input: %#v", client.requests[0].Inputs) } }