package npcs 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 TestExtractReturnsCanonicalNPCListFromPrivateResponse(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.NPCList{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 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 TestExtractHandlesCancellationAndProviderErrors(t *testing.T) { request := extractionRequest() extractor := newExtractor(t, &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{}}}) canceled, cancel := context.WithCancel(context.Background()) cancel() if _, err := extractor.Extract(canceled, request); err == nil || !strings.Contains(err.Error(), "context") { t.Fatalf("canceled Extract() error = %v, want context error", err) } _, err := extractor.Extract(context.Background(), mismatchedSourceInputRequest(request)) if err == nil || !strings.Contains(err.Error(), "dnd npcs") || !strings.Contains(err.Error(), "must match chunk") { t.Fatalf("source input error = %v, want contextual source input error", err) } _, err = newExtractor(t, &fakeNPCsLLMClient{err: errors.New("provider unavailable")}).Extract(context.Background(), request) if err == nil || !strings.Contains(err.Error(), "dnd npcs") || !strings.Contains(err.Error(), "provider unavailable") { t.Fatalf("provider Extract() error = %v, want contextual provider error", err) } }