package scenedescriptions 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 TestExtractMapsExactlyOneSceneToTheChunk(t *testing.T) { client := &fakeSceneDescriptionsLLMClient{response: extractionResponse{ Kind: dnd.SceneKindCombat, Title: " Fight at the Watchtower ", Summary: " Bandits attack the party. ", }} request := extractionRequest() result, err := newExtractor(t, client).Extract(context.Background(), request) if err != nil { t.Fatalf("Extract() error = %v, want nil", err) } want := dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{{ ID: request.Chunk.ID, SourceRef: request.Chunk.Ref, Kind: dnd.SceneKindCombat, Title: "Fight at the Watchtower", Summary: "Bandits attack the party.", }}} 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)) } completed := client.requests[0] if completed.StageName != Key || completed.PromptID != PromptID || completed.PromptVersion != SchemaVersion || completed.ProfileID != "profile-scene-descriptions" || completed.SessionID != "session-123" { t.Fatalf("LLM request identity = %#v", completed) } transcript := completed.Inputs["transcript"] if transcript.Name != "transcript" || transcript.MediaType != "application/json" || transcript.Digest != "sha256:chunk" || transcript.OriginURI != "file:///session-alpha.json" || string(transcript.Content) != string(request.Chunk.Content) { t.Fatalf("transcript input = %#v, want chunk-scoped material", transcript) } } func TestExtractPassesOptionalReferencesAndUsesEmptyPlaceholders(t *testing.T) { client := &fakeSceneDescriptionsLLMClient{response: extractionResponse{Kind: dnd.SceneKindNarrative, Title: "Arrival", Summary: "The party arrives."}} req := extractionRequest() if _, err := newExtractor(t, client).Extract(context.Background(), req); err != nil { t.Fatalf("Extract() error = %v, want nil", err) } for _, slot := range []string{"players", "party", "glossary"} { if got := string(client.requests[0].Inputs[slot].Content); got != " " { t.Fatalf("empty %s input = %q, want explicit placeholder", slot, got) } } 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[1].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 TestExtractPreservesTheModelKindWithoutRepair(t *testing.T) { client := &fakeSceneDescriptionsLLMClient{response: extractionResponse{ Kind: dnd.SceneKind("unrecognized"), Title: " Untitled scene ", Summary: " Summary ", }} result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest()) if err != nil { t.Fatalf("Extract() error = %v, want nil", err) } scene := result.Value.Scenes[0] if scene.Kind != dnd.SceneKind("unrecognized") || scene.Title != "Untitled scene" || scene.Summary != "Summary" { t.Fatalf("scene = %#v, want model kind preserved and textual fields trimmed", scene) } } func TestExtractValidatesRequestsAndSurfacesProviderFailures(t *testing.T) { request := extractionRequest() extractor := newExtractor(t, &fakeSceneDescriptionsLLMClient{response: extractionResponse{Kind: dnd.SceneKindMeta, Title: "Table talk", Summary: "The group discusses rules."}}) for _, test := range []struct { name string req contracts.TypedExtractionRequest }{ {name: "nil source", req: func() contracts.TypedExtractionRequest { r := request; r.Source = nil; return r }()}, {name: "nil chunk", req: func() contracts.TypedExtractionRequest { r := request; r.Chunk = nil; return r }()}, {name: "empty chunk", req: emptyChunkRequest(request)}, {name: "mismatched source input", req: mismatchedSourceInputRequest(request)}, } { t.Run(test.name, func(t *testing.T) { if _, err := extractor.Extract(context.Background(), test.req); err == nil || !strings.Contains(err.Error(), "dnd scene descriptions") { t.Fatalf("Extract() error = %v, want contextual validation error", err) } }) } 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 := newExtractor(t, &fakeSceneDescriptionsLLMClient{err: errors.New("provider unavailable")}).Extract(context.Background(), request) if err == nil || !strings.Contains(err.Error(), "dnd scene descriptions") || !strings.Contains(err.Error(), "provider unavailable") { t.Fatalf("provider Extract() error = %v, want contextual provider error", err) } } func TestMapResponseUsesOnlyChunkIdentityAndRange(t *testing.T) { chunk := &source.Chunk{ID: "source:chunk:7", Ref: source.SourceRef{SourceID: "source", StartUnitID: 12, EndUnitID: 14}} got := mapResponse(extractionResponse{Kind: dnd.SceneKindRecap, Title: " Recap ", Summary: " Summary "}, chunk) want := dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{{ ID: "source:chunk:7", SourceRef: source.SourceRef{SourceID: "source", StartUnitID: 12, EndUnitID: 14}, Kind: dnd.SceneKindRecap, Title: "Recap", Summary: "Summary", }}} if !reflect.DeepEqual(got, want) { t.Fatalf("mapResponse() = %#v, want %#v", got, want) } }