package npcs import ( "reflect" "strings" "testing" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" ) func TestNewRequiresLLMClientAndRejectsAmbiguousReferences(t *testing.T) { if _, err := New(nil, Options{}); err == nil || !strings.Contains(err.Error(), "LLM client") { t.Fatalf("New(nil) error = %v, want dependency error", err) } if _, err := New(&fakeNPCsLLMClient{}, Options{}, contracts.ReferenceSet{}, contracts.ReferenceSet{}); err == nil || !strings.Contains(err.Error(), "at most one reference set") { t.Fatalf("New() error = %v, want reference-set error", err) } if got := newExtractor(t, &fakeNPCsLLMClient{}).Key(); got != Key { t.Fatalf("Key() = %q, want %q", got, Key) } } func TestModuleSpecAndReferenceSlots(t *testing.T) { got := ModuleSpec() want := pipeline.ModuleSpec{ Key: Key, Stage: pipeline.StageExtract, ExecutionClass: contracts.ExecutionClassLLMBacked, Requires: []string{"chunks", "source.transcript"}, Provides: []string{"dnd.npcs"}, ArtifactKind: dnd.NPCListKind, ReferenceSlots: []contracts.ReferenceSlot{ {Name: "glossary", Description: "Optional campaign glossary reference material used only for NPC disambiguation.", AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}}, {Name: "party", Description: "Optional party roster reference material used only for NPC disambiguation.", AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}}, {Name: "players", Description: "Optional player list reference material used only for NPC disambiguation.", AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}}, {Name: "roster", Description: "Deprecated alias for party roster reference material used only for NPC disambiguation.", AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}}, }, } if !reflect.DeepEqual(got, want) { t.Fatalf("ModuleSpec() = %#v, want %#v", got, want) } got.Requires[0] = "changed" got.Provides[0] = "changed" got.ReferenceSlots[0].AcceptedMediaTypes[0] = "changed" if !reflect.DeepEqual(ModuleSpec(), want) { t.Fatal("ModuleSpec() returned mutable shared slices") } extractor := newExtractor(t, &fakeNPCsLLMClient{}) if !reflect.DeepEqual(extractor.ReferenceSlots(), want.ReferenceSlots) { t.Fatalf("ReferenceSlots() = %#v, want %#v", extractor.ReferenceSlots(), want.ReferenceSlots) } } func TestRegisterStoresTypedModuleSpec(t *testing.T) { registry := pipeline.NewExtractorRegistry() if err := Register(registry); err != nil { t.Fatalf("Register() error = %v, want nil", err) } got, ok := registry.Spec(Key) if !ok || !reflect.DeepEqual(got, ModuleSpec()) { t.Fatalf("registry.Spec(%q) = %#v, present = %t", Key, got, ok) } if err := Register(nil); err == nil || !strings.Contains(err.Error(), "extractor registry") { t.Fatalf("Register(nil) error = %v, want registry error", err) } if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") { t.Fatalf("DecodeOptions() error = %v, want strict options error", err) } } func TestExtractorMetadataAndCheckpointIdentity(t *testing.T) { extractor := newExtractor(t, &fakeNPCsLLMClient{}) metadata := extractor.ManifestMetadata() for key, want := range map[string]string{ "prompt_id": PromptID, "prompt_version": SchemaVersion, "response_schema_key": string(ResponseSchemaKey), "response_schema_id": ResponseSchemaID, "response_schema_name": ResponseSchemaName, "response_schema_version": SchemaVersion, "identity_policy": "dnd.npcs.identity.v1", "mapping_policy": mappingPolicy, } { if metadata[key] != want { t.Fatalf("metadata[%q] = %#v, want %q", key, metadata[key], want) } } for _, key := range []string{"prompt_sha256", "response_schema_sha256"} { if value, ok := metadata[key].(string); !ok || !strings.HasPrefix(value, "sha256:") { t.Fatalf("metadata[%q] = %#v, want hash", key, metadata[key]) } } fingerprints := extractor.CheckpointFingerprints() want := map[string]string{"prompt": metadata["prompt_sha256"].(string), "response_schema": metadata["response_schema_sha256"].(string), "identity_policy": "dnd.npcs.identity.v1", "mapping_policy": mappingPolicy} if len(fingerprints) != len(want) { t.Fatalf("CheckpointFingerprints() = %#v, want %d entries", fingerprints, len(want)) } for _, fingerprint := range fingerprints { if fingerprint.Value != want[fingerprint.Name] { t.Fatalf("fingerprint %q = %q, want %#v", fingerprint.Name, fingerprint.Value, want) } } }