package itemevents import ( "context" "errors" "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/modules/dnd" ) func TestExtractMapsAllEventKindsAndCanonicalizesEvidence(t *testing.T) { quantity := 20 client := &fakeItemEventsLLMClient{response: extractionResponse{Events: []itemEventResponse{ {Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(5, 5)}, {Name: "Moonblade", Kind: "transferred", From: "Aria", To: "Borin", SourceRefs: responseRefs(3, 3)}, {Name: "Hidden Cache", Kind: "discovered", SourceRefs: []itemEventSourceRefResponse{{StartSegment: 1, EndSegment: 1}, {StartSegment: 1, EndSegment: 1}}}, {Name: "Gold Pieces", Kind: "acquired", Quantity: &quantity, To: "party", SourceRefs: responseRefs(2, 2)}, {Name: "Healing Potion", Kind: "consumed", From: "party", SourceRefs: responseRefs(4, 4)}, }}} result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest()) if err != nil { t.Fatalf("Extract() error = %v", err) } if got := []dnd.ItemEventKind{result.Value.Events[0].Kind, result.Value.Events[1].Kind, result.Value.Events[2].Kind, result.Value.Events[3].Kind, result.Value.Events[4].Kind}; !reflect.DeepEqual(got, []dnd.ItemEventKind{ dnd.ItemEventKindDiscovered, dnd.ItemEventKindAcquired, dnd.ItemEventKindTransferred, dnd.ItemEventKindConsumed, dnd.ItemEventKindLost, }) { t.Fatalf("event kinds = %#v, want source order", got) } acquired := result.Value.Events[1] if acquired.Name != "Gold Pieces" || acquired.To != "party" || acquired.Quantity == nil || *acquired.Quantity != 20 { t.Fatalf("acquired event = %#v", acquired) } if result.Value.Events[0].Quantity != nil || result.Value.Events[0].From != "" || result.Value.Events[0].To != "" { t.Fatalf("discovered event = %#v, want omitted optional fields", result.Value.Events[0]) } if refs := result.Value.Events[0].SourceRefs; len(refs) != 1 || refs[0] != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}) { t.Fatalf("source refs = %#v, want source ID attachment and deduplication", refs) } if acquired.Quantity == client.response.Events[3].Quantity { t.Fatal("mapped quantity aliases private response") } if len(client.requests) != 1 { t.Fatalf("LLM calls = %d, want 1", len(client.requests)) } request := client.requests[0] if request.StageName != Key || request.PromptID != PromptID || request.PromptVersion != SchemaVersion || request.ProfileID != "profile-item-events" || request.SessionID != "session-123" { t.Fatalf("LLM request identity = %#v", request) } if transcript := request.Inputs["transcript"]; string(transcript.Content) != string(extractionRequest().Chunk.Content) || transcript.Name != "transcript" { t.Fatalf("transcript input = %#v", transcript) } for _, name := range []string{"players", "party", "glossary"} { if input := request.Inputs[name]; string(input.Content) != " " { t.Fatalf("absent %s input = %#v, want retained empty prompt material", name, input) } } } func TestExtractPreservesInvalidCandidatesAndEmptyResults(t *testing.T) { client := &fakeItemEventsLLMClient{content: []byte(`{"events":[{"name":"","kind":"transferred","quantity":0,"from":"party","to":"Party","source_refs":[{"start_segment":99,"end_segment":-1}]}]}`)} result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest()) if err != nil { t.Fatalf("Extract() error = %v", err) } event := result.Value.Events[0] if event.Name != "" || event.Kind != "transferred" || event.Quantity == nil || *event.Quantity != 0 || event.From != "party" || event.To != "Party" { t.Fatalf("invalid candidate = %#v, want values preserved", event) } if refs := event.SourceRefs; len(refs) != 1 || refs[0] != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 99, EndUnitID: -1}) { t.Fatalf("source refs = %#v, want invalid range preserved", refs) } empty, err := newExtractor(t, &fakeItemEventsLLMClient{response: extractionResponse{Events: []itemEventResponse{}}}).Extract(context.Background(), extractionRequest()) if err != nil || empty.Value.Events == nil || len(empty.Value.Events) != 0 { t.Fatalf("empty provider result = %#v, %v; want valid empty list", empty.Value, err) } } func TestExtractMapsNullableResponseFieldsToAbsentArtifactFields(t *testing.T) { client := &fakeItemEventsLLMClient{content: []byte(`{"events":[{"name":"Hidden Cache","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_segment":1,"end_segment":1}]}]}`)} result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest()) if err != nil { t.Fatal(err) } if len(result.Value.Events) != 1 { t.Fatalf("events = %#v, want one", result.Value.Events) } event := result.Value.Events[0] if event.Quantity != nil || event.From != "" || event.To != "" { t.Fatalf("nullable response fields mapped to artifact values: %#v", event) } } func TestExtractUsesSourceDocumentOrderForCandidates(t *testing.T) { client := &fakeItemEventsLLMClient{response: extractionResponse{Events: []itemEventResponse{ {Name: "Later", Kind: "discovered", SourceRefs: responseRefs(10, 10)}, {Name: "Earlier", Kind: "discovered", SourceRefs: responseRefs(100, 100)}, }}} req := extractionRequest() req.Source.Units = []source.SourceUnit{{ID: 100}, {ID: 10}} req.Chunk.Units = append([]source.SourceUnit(nil), req.Source.Units...) req.Chunk.Ref = source.SourceRef{SourceID: req.Source.ID, StartUnitID: 100, EndUnitID: 10} result, err := newExtractor(t, client).Extract(context.Background(), req) if err != nil { t.Fatal(err) } if got := []string{result.Value.Events[0].Name, result.Value.Events[1].Name}; !reflect.DeepEqual(got, []string{"Earlier", "Later"}) { t.Fatalf("event order = %#v, want source document order", got) } } func TestExtractUsesOnlySupportedPromptInputs(t *testing.T) { client := &fakeItemEventsLLMClient{response: extractionResponse{Events: []itemEventResponse{}}} req := extractionRequest() req.References = contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ "players": {Slot: contracts.ReferenceSlot{Name: "players"}, Items: []contracts.ReferenceItem{{SlotName: "players", Content: []byte("Dana: Aria")}}}, "party": {Slot: contracts.ReferenceSlot{Name: "party"}, Items: []contracts.ReferenceItem{{SlotName: "party", Content: []byte("Aria: ranger")}}}, "glossary": {Slot: contracts.ReferenceSlot{Name: "glossary"}, Items: []contracts.ReferenceItem{{SlotName: "glossary", Content: []byte("Moonblade: heirloom")}}}, "npcs": {Slot: contracts.ReferenceSlot{Name: "npcs"}, Items: []contracts.ReferenceItem{{SlotName: "npcs", Content: []byte("must not be used")}}}, }} if _, err := newExtractor(t, client).Extract(context.Background(), req); err != nil { t.Fatal(err) } inputs := client.requests[0].Inputs if string(inputs["players"].Content) != "Dana: Aria" || string(inputs["party"].Content) != "Aria: ranger" || string(inputs["glossary"].Content) != "Moonblade: heirloom" { t.Fatalf("reference inputs = %#v", inputs) } if _, ok := inputs["npcs"]; ok { t.Fatalf("unexpected generated-lane input: %#v", inputs) } } func TestExtractRetainsLocalErrorContext(t *testing.T) { request := extractionRequest() extractor := newExtractor(t, &fakeItemEventsLLMClient{}) var nilExtractor *Extractor request.SourceInput = contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"different":true}`), "sha256:other", "file:///other.json") for _, test := range []struct { name string extractor *Extractor req contracts.TypedExtractionRequest want string }{ {"nil extractor", nilExtractor, extractionRequest(), "extractor"}, {"nil client", &Extractor{}, extractionRequest(), "LLM client"}, {"preflight", extractor, request, "must match chunk"}, } { t.Run(test.name, func(t *testing.T) { if _, err := test.extractor.Extract(context.Background(), test.req); err == nil || !strings.Contains(err.Error(), "dnd item events") || !strings.Contains(err.Error(), test.want) { t.Fatalf("Extract() error = %v", err) } }) } if _, err := newExtractor(t, &fakeItemEventsLLMClient{err: errors.New("provider unavailable")}).Extract(context.Background(), extractionRequest()); err == nil || !strings.Contains(err.Error(), "provider unavailable") { t.Fatalf("provider error = %v", err) } }