package npcregistry 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" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" ) func TestExtractReturnsCanonicalNPCRegistryFromPrivateResponse(t *testing.T) { client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{ { Name: "Captain Vale", SourceRefs: responseSourceRefs(3, 3), }, { Name: "Mira Thorn", SourceRefs: []npcSourceRefResponse{ {StartUnitID: 2, EndUnitID: 2}, {StartUnitID: 1, EndUnitID: 2}, {StartUnitID: 1, EndUnitID: 2}, }, }, }}} result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest()) if err != nil { t.Fatalf("Extract() error = %v, want nil", err) } want := dnd.NPCRegistry{NPCs: []dnd.NPC{ {ID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}, {SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}}, {ID: identity.DeriveID("Captain Vale"), Name: "Captain Vale", SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}}}, }} if !reflect.DeepEqual(result.Value, want) { t.Fatalf("Value = %#v, want %#v", result.Value, want) } 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-npcs" || request.SessionID != "session-123" { t.Fatalf("LLM request identity = %#v", request) } transcript := request.Inputs["transcript"] if transcript.Name != "transcript" || transcript.MediaType != "application/json" || transcript.Digest != "sha256:chunk" || transcript.OriginURI != "file:///session-alpha.json" || string(transcript.Content) != string(extractionRequest().Chunk.Content) { t.Fatalf("transcript input = %#v, want chunk-scoped material", transcript) } } func TestExtractOrdersNPCsBySourcePositionRatherThanUnitID(t *testing.T) { client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{ { Name: "Later NPC", SourceRefs: responseSourceRefs(10, 10), }, { Name: "Earlier NPC", SourceRefs: []npcSourceRefResponse{ {StartUnitID: 50, EndUnitID: 50}, {StartUnitID: 100, EndUnitID: 100}, }, }, }}} req := extractionRequest() req.Source.Units = []source.SourceUnit{ {ID: 100, Kind: "transcript_segment", Text: "Earlier NPC appears."}, {ID: 10, Kind: "transcript_segment", Text: "Later NPC appears."}, {ID: 50, Kind: "transcript_segment", Text: "Earlier NPC appears again."}, } req.Chunk.Units = append([]source.SourceUnit(nil), req.Source.Units...) req.Chunk.Ref = source.SourceRef{SourceID: req.Source.ID, StartUnitID: 100, EndUnitID: 50} result, err := newExtractor(t, client).Extract(context.Background(), req) if err != nil { t.Fatalf("Extract() error = %v, want nil", err) } if len(result.Value.NPCs) != 2 || result.Value.NPCs[0].Name != "Earlier NPC" || result.Value.NPCs[1].Name != "Later NPC" { t.Fatalf("NPC order = %#v, want source-document order", result.Value.NPCs) } } func TestExtractUsesDocumentOrderForNPCReferencesAndStableTies(t *testing.T) { client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{ {Name: "Later", SourceRefs: responseSourceRefs(10, 10)}, {Name: "First", SourceRefs: []npcSourceRefResponse{ {StartUnitID: 10, EndUnitID: 10}, {StartUnitID: 30, EndUnitID: 30}, {StartUnitID: 30, EndUnitID: 30}, {StartUnitID: 999, EndUnitID: 0}, }}, {Name: "Second", SourceRefs: responseSourceRefs(30, 30)}, }}} req := extractionRequest() req.Source.Units = []source.SourceUnit{{ID: 30}, {ID: 10}} req.Chunk.Units = append([]source.SourceUnit(nil), req.Source.Units...) req.Chunk.Ref = source.SourceRef{SourceID: req.Source.ID, StartUnitID: 30, EndUnitID: 10} result, err := newExtractor(t, client).Extract(context.Background(), req) if err != nil { t.Fatalf("Extract() error = %v", err) } if got := []string{result.Value.NPCs[0].Name, result.Value.NPCs[1].Name, result.Value.NPCs[2].Name}; !reflect.DeepEqual(got, []string{"First", "Second", "Later"}) { t.Fatalf("NPC order = %#v, want document chronology with stable equal-evidence ties", got) } refs := result.Value.NPCs[0].SourceRefs if got := []int{refs[0].StartUnitID, refs[1].StartUnitID, refs[2].StartUnitID}; !reflect.DeepEqual(got, []int{30, 10, 999}) { t.Fatalf("source refs = %#v, want document order with exact duplicate removed", refs) } refs[0].StartUnitID = 777 for _, npc := range client.response.NPCs { for _, ref := range npc.SourceRefs { if ref.StartUnitID == 777 { t.Fatal("result source references alias the model response") } } } } func TestExtractPassesCampaignReferencesAsPromptInputs(t *testing.T) { client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{}}} 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: Mira")}}}, "party": {Slot: contracts.ReferenceSlot{Name: "party"}, Items: []contracts.ReferenceItem{{SlotName: "party", Content: []byte("Mira: ranger")}}}, "glossary": {Slot: contracts.ReferenceSlot{Name: "glossary"}, Items: []contracts.ReferenceItem{{SlotName: "glossary", Content: []byte("Greencloak: local title")}}}, }} if _, err := newExtractor(t, client).Extract(context.Background(), req); err != nil { t.Fatalf("Extract() error = %v, want nil", err) } inputs := client.requests[0].Inputs if string(inputs["players"].Content) != "Dana: Mira" || string(inputs["party"].Content) != "Mira: ranger" || string(inputs["glossary"].Content) != "Greencloak: local title" { t.Fatalf("reference inputs = %#v", inputs) } if strings.Contains(string(inputs["transcript"].Content), "local title") { t.Fatal("transcript input contains reference content") } } func TestExtractPreservesMalformedCandidatesForValidators(t *testing.T) { client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{{ Name: "", SourceRefs: []npcSourceRefResponse{{StartUnitID: 99, EndUnitID: 0}}, }}}} result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest()) if err != nil { t.Fatalf("Extract() error = %v, want nil", err) } if len(result.Value.NPCs) != 1 || result.Value.NPCs[0].ID != "" || result.Value.NPCs[0].Name != "" { t.Fatalf("malformed candidate = %#v, want invalid values preserved", result.Value) } if refs := result.Value.NPCs[0].SourceRefs; len(refs) != 1 || refs[0].SourceID != "session-alpha" || refs[0].StartUnitID != 99 || refs[0].EndUnitID != 0 { t.Fatalf("malformed source refs = %#v, want invalid range preserved after exact deduplication", refs) } } func TestExtractMapsRawSemanticCandidatesWithoutRepair(t *testing.T) { client := &fakeNPCsLLMClient{content: []byte(`{"npcs":[{"name":"","source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`)} result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest()) if err != nil { t.Fatalf("Extract() error = %v, want nil", err) } npc := result.Value.NPCs[0] if npc.ID != "" || npc.Name != "" { t.Fatalf("NPC = %#v, want blank semantic values preserved", npc) } if refs := npc.SourceRefs; len(refs) != 1 || refs[0] != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 0, EndUnitID: -1}) { t.Fatalf("source refs = %#v, want raw nonpositive candidates preserved", refs) } } func TestExtractRetainsLocalErrorContextAndProviderFailures(t *testing.T) { request := extractionRequest() extractor := newExtractor(t, &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{}}}) var nilExtractor *Extractor for _, test := range []struct { name string extractor *Extractor req contracts.TypedExtractionRequest want string }{ {name: "nil extractor", extractor: nilExtractor, req: request, want: "extractor"}, {name: "nil LLM client", extractor: &Extractor{}, req: request, want: "LLM client"}, {name: "wrapped preflight failure", extractor: extractor, req: mismatchedSourceInputRequest(request), want: "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 npc registry") || !strings.Contains(err.Error(), test.want) { t.Fatalf("Extract() error = %v, want contextual local error", err) } }) } _, err := newExtractor(t, &fakeNPCsLLMClient{err: errors.New("provider unavailable")}).Extract(context.Background(), request) if err == nil || !strings.Contains(err.Error(), "dnd npc registry") || !strings.Contains(err.Error(), "provider unavailable") { t.Fatalf("provider Extract() error = %v, want contextual provider error", err) } }