package shared import ( "io/fs" "reflect" "strings" "testing" "testing/fstest" "gitea.maximumdirect.net/eric/notarius/internal/framework/llm" "gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs" ) func TestPromptAssetManifestPromptFS(t *testing.T) { manifest := PromptAssetManifest{ ModuleDir: "dnd.test", ModuleFiles: []promptfs.ModulePromptFile{ {Name: "prompt.yaml", Path: "assets/prompts/prompt.yaml"}, {Name: "instructions.md", Path: "assets/prompts/instructions.md"}, }, SharedFiles: []string{ "common-dnd-system.md", "common-dnd-transcript-full.md", "common-dnd-transcript-chunk.md", }, } fsys, err := manifest.PromptFS(fstest.MapFS{ "assets/prompts/prompt.yaml": {Data: []byte("id: dnd.test")}, "assets/prompts/instructions.md": {Data: []byte("instructions")}, }) if err != nil { t.Fatalf("PromptFS() error = %v, want nil", err) } wantModule := map[string]string{ "assets/prompts/dnd.test/prompt.yaml": "id: dnd.test", "assets/prompts/dnd.test/instructions.md": "instructions", } for path, wantContent := range wantModule { content, err := fs.ReadFile(fsys, path) if err != nil { t.Fatalf("ReadFile(%q) error = %v, want nil", path, err) } if string(content) != wantContent { t.Fatalf("ReadFile(%q) = %q, want %q", path, content, wantContent) } } for _, path := range []string{ "assets/prompts/dnd.test/sharedassets/common-dnd-system.md", "assets/prompts/dnd.test/sharedassets/common-dnd-transcript-full.md", "assets/prompts/dnd.test/sharedassets/common-dnd-transcript-chunk.md", } { content, err := fs.ReadFile(fsys, path) if err != nil { t.Fatalf("ReadFile(%q) error = %v, want nil", path, err) } if len(content) == 0 { t.Fatalf("ReadFile(%q) returned empty content, want mounted asset", path) } } for _, path := range []string{ "assets/prompts/dnd.test/sharedassets/common-dnd-references.md", "assets/prompts/dnd.test/sharedassets/common-dnd-npc-registry.md", } { if _, err := fs.ReadFile(fsys, path); err == nil { t.Fatalf("ReadFile(%q) succeeded, want unlisted shared asset to be absent", path) } } } func TestPromptAssetManifestRejectsInvalidSharedNames(t *testing.T) { tests := []struct { name string sharedFiles []string wantError string }{ {name: "unknown", sharedFiles: []string{"missing.md"}, wantError: "unknown shared prompt asset name"}, {name: "duplicate", sharedFiles: []string{"common-dnd-system.md", "common-dnd-system.md"}, wantError: "duplicate shared prompt asset name"}, {name: "empty", sharedFiles: []string{""}, wantError: "must not be empty"}, {name: "slash path", sharedFiles: []string{"nested/common-dnd-system.md"}, wantError: "must not contain path separators"}, {name: "backslash path", sharedFiles: []string{`nested\common-dnd-system.md`}, wantError: "must not contain path separators"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { _, err := (PromptAssetManifest{ ModuleDir: "dnd.test", ModuleFiles: []promptfs.ModulePromptFile{ {Name: "prompt.yaml", Path: "assets/prompts/prompt.yaml"}, }, SharedFiles: test.sharedFiles, }).PromptFS(fstest.MapFS{ "assets/prompts/prompt.yaml": {Data: []byte("id: dnd.test")}, }) if err == nil || !strings.Contains(err.Error(), test.wantError) { t.Fatalf("PromptFS() error = %v, want %q", err, test.wantError) } }) } } func TestPromptAssetManifestRejectsMissingModuleFile(t *testing.T) { _, err := (PromptAssetManifest{ ModuleDir: "dnd.test", ModuleFiles: []promptfs.ModulePromptFile{ {Name: "instructions.md", Path: "assets/prompts/instructions.md"}, }, }).PromptFS(fstest.MapFS{}) if err == nil || !strings.Contains(err.Error(), "read module prompt asset assets/prompts/instructions.md") { t.Fatalf("PromptFS() error = %v, want missing module asset context", err) } } func TestPromptAssetManifestRejectsMissingSharedFile(t *testing.T) { const name = "missing-for-test.md" const path = "prompts/missing-for-test.md" previous, existed := sharedPromptPaths[name] sharedPromptPaths[name] = path t.Cleanup(func() { if existed { sharedPromptPaths[name] = previous } else { delete(sharedPromptPaths, name) } }) _, err := (PromptAssetManifest{ ModuleDir: "dnd.test", ModuleFiles: []promptfs.ModulePromptFile{ {Name: "prompt.yaml", Path: "assets/prompts/prompt.yaml"}, }, SharedFiles: []string{name}, }).PromptFS(fstest.MapFS{ "assets/prompts/prompt.yaml": {Data: []byte("id: dnd.test")}, }) if err == nil || !strings.Contains(err.Error(), "read shared prompt asset "+path) { t.Fatalf("PromptFS() error = %v, want missing shared asset context", err) } } func TestPromptAssetManifestHashMatchesManifestParts(t *testing.T) { moduleFS := fstest.MapFS{ "assets/prompts/prompt.yaml": {Data: []byte("id: dnd.test")}, "assets/prompts/instructions.md": {Data: []byte("instructions")}, } manifest := PromptAssetManifest{ ModuleDir: "dnd.test", ModuleFiles: []promptfs.ModulePromptFile{ {Name: "prompt.yaml", Path: "assets/prompts/prompt.yaml"}, {Name: "instructions.md", Path: "assets/prompts/instructions.md"}, }, SharedFiles: []string{ "common-dnd-transcript-chunk.md", "common-dnd-system.md", }, } got, err := manifest.Hash(moduleFS) if err != nil { t.Fatalf("Hash() error = %v, want nil", err) } sharedFS, err := sharedAssetFS() if err != nil { t.Fatalf("sharedAssetFS() error = %v, want nil", err) } want, err := llm.HashAssets([]llm.AssetHashPart{ {FS: moduleFS, Path: "assets/prompts/prompt.yaml"}, {FS: moduleFS, Path: "assets/prompts/instructions.md"}, {FS: sharedFS, Path: "prompts/common-dnd-transcript-chunk.md"}, {FS: sharedFS, Path: "prompts/common-dnd-system.md"}, }) if err != nil { t.Fatalf("HashAssets() error = %v, want nil", err) } if got != want { t.Fatalf("Hash() = %q, want independently assembled manifest hash %q", got, want) } withUnused, err := llm.HashAssets([]llm.AssetHashPart{ {FS: moduleFS, Path: "assets/prompts/prompt.yaml"}, {FS: moduleFS, Path: "assets/prompts/instructions.md"}, {FS: sharedFS, Path: "prompts/common-dnd-transcript-chunk.md"}, {FS: sharedFS, Path: "prompts/common-dnd-system.md"}, {FS: sharedFS, Path: "prompts/common-dnd-npc-registry.md"}, }) if err != nil { t.Fatalf("HashAssets() with unused asset error = %v, want nil", err) } if got == withUnused { t.Fatalf("Hash() included an unlisted shared asset") } } func TestSharedPromptDescriptorsReturnFreshCopies(t *testing.T) { first, err := resolveSharedPromptFiles([]string{"common-dnd-system.md"}) if err != nil { t.Fatalf("resolveSharedPromptFiles() error = %v, want nil", err) } second, err := resolveSharedPromptFiles([]string{"common-dnd-system.md"}) if err != nil { t.Fatalf("resolveSharedPromptFiles() second error = %v, want nil", err) } first[0].Name = "changed.md" first[0].Path = "changed.md" if reflect.DeepEqual(first, second) || second[0].Name != "common-dnd-system.md" || second[0].Path != "prompts/common-dnd-system.md" { t.Fatalf("resolveSharedPromptFiles() reused descriptor state: first=%#v second=%#v", first, second) } } func TestPromptAssetManifestMountsExternalSharedFiles(t *testing.T) { external := fstest.MapFS{"core/protocol.md": {Data: []byte("integer protocol")}} manifest := PromptAssetManifest{ ModuleDir: "dnd.test", ModuleFiles: []promptfs.ModulePromptFile{ {Name: "prompt.yaml", Path: "prompts/prompt.yaml"}, }, ExternalSharedFiles: []promptfs.SharedPromptFile{ {Name: "protocol.md", FS: external, Path: "core/protocol.md"}, }, } fys, err := manifest.PromptFS(fstest.MapFS{ "prompts/prompt.yaml": {Data: []byte("id: dnd.test")}, }) if err != nil { t.Fatalf("PromptFS() error = %v, want nil", err) } content, err := fs.ReadFile(fys, "assets/prompts/dnd.test/sharedassets/protocol.md") if err != nil || string(content) != "integer protocol" { t.Fatalf("mounted external protocol = %q, %v", content, err) } } func TestPromptAssetManifestHashIncludesExternalSharedFiles(t *testing.T) { moduleFS := fstest.MapFS{"prompts/prompt.yaml": {Data: []byte("id: dnd.test")}} external := fstest.MapFS{"core/protocol.md": {Data: []byte("integer protocol")}} manifest := PromptAssetManifest{ ModuleDir: "dnd.test", ModuleFiles: []promptfs.ModulePromptFile{ {Name: "prompt.yaml", Path: "prompts/prompt.yaml"}, }, ExternalSharedFiles: []promptfs.SharedPromptFile{ {Name: "protocol.md", FS: external, Path: "core/protocol.md"}, }, } got, err := manifest.Hash(moduleFS) if err != nil { t.Fatalf("Hash() error = %v, want nil", err) } want, err := llm.HashAssets([]llm.AssetHashPart{ {FS: moduleFS, Path: "prompts/prompt.yaml"}, {FS: external, Path: "core/protocol.md"}, }) if err != nil { t.Fatal(err) } if got != want { t.Fatalf("Hash() = %q, want external-aware hash %q", got, want) } } func TestPromptAssetManifestRejectsInvalidExternalSharedFiles(t *testing.T) { moduleFS := fstest.MapFS{"prompts/prompt.yaml": {Data: []byte("id: dnd.test")}} tests := []struct { name string external []promptfs.SharedPromptFile shared []string want string }{ { name: "missing", external: []promptfs.SharedPromptFile{ {Name: "protocol.md", FS: fstest.MapFS{}, Path: "core/protocol.md"}, }, want: "read shared prompt asset core/protocol.md", }, { name: "duplicate external destinations", external: []promptfs.SharedPromptFile{ {Name: "protocol.md", FS: fstest.MapFS{"a.md": {Data: []byte("a")}}, Path: "a.md"}, {Name: " protocol.md ", FS: fstest.MapFS{"b.md": {Data: []byte("b")}}, Path: "b.md"}, }, want: `duplicate sharedassets prompt destination "assets/prompts/dnd.test/sharedassets/protocol.md"`, }, { name: "duplicate named and external destinations", shared: []string{"common-dnd-system.md"}, external: []promptfs.SharedPromptFile{ {Name: "common-dnd-system.md", FS: fstest.MapFS{"system.md": {Data: []byte("external")}}, Path: "system.md"}, }, want: `duplicate sharedassets prompt destination "assets/prompts/dnd.test/sharedassets/common-dnd-system.md"`, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { manifest := PromptAssetManifest{ ModuleDir: "dnd.test", ModuleFiles: []promptfs.ModulePromptFile{ {Name: "prompt.yaml", Path: "prompts/prompt.yaml"}, }, SharedFiles: test.shared, ExternalSharedFiles: test.external, } if _, err := manifest.PromptFS(moduleFS); err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("PromptFS() error = %v, want %q", err, test.want) } if _, err := manifest.Hash(moduleFS); err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("Hash() error = %v, want %q", err, test.want) } }) } }