Prepare optional spell catalog inputs

This commit is contained in:
2026-08-29 15:03:44 +00:00
parent 5831c0c9e6
commit b3363f87d6
7 changed files with 369 additions and 8 deletions

View File

@@ -12,6 +12,7 @@ import (
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/audio"
"gitea.maximumdirect.net/eric/narratio/internal/config"
@@ -64,6 +65,7 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
glossaryInput := stableInputSource(env.Config.StableInputs.GlossaryFile, env.Config.Session.Inputs.GlossaryFile, sessionSrc)
playersInput := stableInputSource(env.Config.StableInputs.PlayersFile, env.Config.Session.Inputs.PlayersFile, sessionSrc)
partyInput := stableInputSource(env.Config.StableInputs.PartyFile, env.Config.Session.Inputs.PartyFile, sessionSrc)
spellCatalogInput := stableInputSource(env.Config.StableInputs.SpellCatalogFile, env.Config.Session.Inputs.SpellCatalogFile, sessionSrc)
speakersSrc, err := resolveConfigRelativePath(speakersInput)
if err != nil {
@@ -85,6 +87,17 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
if err != nil {
return nil, fmt.Errorf("prepare: party path: %w", err)
}
spellCatalogConfigured := strings.TrimSpace(spellCatalogInput.Path) != ""
spellCatalogSrc := ""
if spellCatalogConfigured {
spellCatalogSrc, err = resolveConfigRelativePath(spellCatalogInput)
if err != nil {
return nil, fmt.Errorf("prepare: spell catalog path: %w", err)
}
if err := requireRegularReadableFile(spellCatalogSrc, "spell catalog"); err != nil {
return nil, fmt.Errorf("prepare: %w", err)
}
}
for _, required := range []struct {
path string
@@ -106,7 +119,7 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
return nil, fmt.Errorf("prepare: resolve audio inputs: %w", err)
}
inputs := make([]manifest.InputRecord, 0, 8+len(resolvedLocalAudio))
inputs := make([]manifest.InputRecord, 0, 9+len(resolvedLocalAudio))
registerInput := func(kind, path, checksum string) {
inputs = append(inputs, manifest.InputRecord{Kind: kind, Path: path, Checksum: checksum})
}
@@ -155,18 +168,36 @@ func (prepareStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
}
registerInput("pipeline_resolved", pipelineDst, pipelineChecksum)
for _, cfgFile := range []struct {
type preparedConfigFile struct {
kind string
src string
dst string
source string
}{
}
preparedConfigFiles := []preparedConfigFile{
{kind: "speakers", src: speakersSrc, dst: filepath.Join(paths.InputsDir, "speakers.yml"), source: speakersInput.Source},
{kind: "autocorrect", src: autocorrectSrc, dst: filepath.Join(paths.InputsDir, "autocorrect.yml"), source: autocorrectInput.Source},
{kind: "glossary", src: glossarySrc, dst: filepath.Join(paths.InputsDir, "glossary.yml"), source: glossaryInput.Source},
{kind: "players", src: playersSrc, dst: filepath.Join(paths.InputsDir, "players.yml"), source: playersInput.Source},
{kind: "party", src: partySrc, dst: filepath.Join(paths.InputsDir, "party.yml"), source: partyInput.Source},
} {
}
spellDescriptor, ok := artifactpolicy.DescribePreparedInputSource(artifactpolicy.SourceInputSpellCatalog)
if !ok {
return nil, fmt.Errorf("prepare: spell catalog prepared-input descriptor is unavailable")
}
spellCatalogDst := filepath.Join(paths.InputsDir, spellDescriptor.Filename)
if spellCatalogConfigured {
preparedConfigFiles = append(preparedConfigFiles, preparedConfigFile{
kind: spellDescriptor.ManifestKind,
src: spellCatalogSrc,
dst: spellCatalogDst,
source: spellCatalogInput.Source,
})
} else if err := fileops.RemoveFileUnderRoot(paths.Root, spellCatalogDst); err != nil {
return nil, fmt.Errorf("prepare: remove obsolete spell catalog: %w", err)
}
for _, cfgFile := range preparedConfigFiles {
checksum, err := copyFileIfChanged(env.ArtifactStore, cfgFile.src, cfgFile.dst)
if err != nil {
return nil, fmt.Errorf("prepare: materialize %s: %w", cfgFile.kind, err)
@@ -585,6 +616,28 @@ func requireFile(path string, label string) error {
return nil
}
func requireRegularReadableFile(path string, label string) error {
if strings.TrimSpace(path) == "" {
return fmt.Errorf("%s path is required", label)
}
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("open %s %q: %w", label, path, err)
}
info, statErr := file.Stat()
closeErr := file.Close()
if statErr != nil {
return fmt.Errorf("stat %s %q: %w", label, path, statErr)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("%s %q is not a regular file", label, path)
}
if closeErr != nil {
return fmt.Errorf("close %s %q: %w", label, path, closeErr)
}
return nil
}
func isFlac(path string) bool {
return strings.EqualFold(filepath.Ext(path), ".flac")
}

View File

@@ -2,6 +2,8 @@ package stage
import (
"context"
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
@@ -154,6 +156,167 @@ func TestPrepareStageIdempotent(t *testing.T) {
}
}
func TestPrepareStageMaterializesSpellCatalogWithProvenance(t *testing.T) {
tests := []struct {
name string
configPath func(*config.Config) string
source string
}{
{name: "campaign", configPath: func(cfg *config.Config) string { return cfg.CampaignPath }, source: "campaign_config"},
{name: "session", configPath: func(cfg *config.Config) string { return cfg.SessionPath }, source: "session_config"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env, m := setupPrepareEnv(t)
root := filepath.Dir(env.Config.SessionPath)
writeFile(t, filepath.Join(root, "audio", "a.flac"), "audio")
env.Config.Session.Inputs.AudioFiles = []string{"./audio/a.flac"}
payload := []byte(`{"spells":[{"name":"Fireball"}]}` + "\n")
sourcePath := filepath.Join(filepath.Dir(tt.configPath(env.Config)), tt.name+"-spells.json")
writeFile(t, sourcePath, string(payload))
env.Config.StableInputs.SpellCatalogFile = config.ResolvedInputFile{
Path: filepath.Base(sourcePath),
ConfigPath: tt.configPath(env.Config),
Source: tt.source,
}
if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil {
t.Fatalf("prepare.Run() error = %v", err)
}
destination := filepath.Join(sessionPathsForEnv(env, m.SessionID).InputsDir, "spell_catalog.json")
got, err := os.ReadFile(destination)
if err != nil {
t.Fatalf("ReadFile(destination) error = %v", err)
}
if string(got) != string(payload) {
t.Fatalf("destination bytes = %q, want %q", got, payload)
}
record := findManifestInput(t, m.Inputs, "spell_catalog")
digest := sha256.Sum256(payload)
wantChecksum := hex.EncodeToString(digest[:])
if record.Path != destination || record.Source != tt.source || record.Checksum != wantChecksum {
t.Fatalf("spell catalog record = %#v, want path=%q source=%q checksum=%q", record, destination, tt.source, wantChecksum)
}
assertManifestInputsSorted(t, m.Inputs)
})
}
}
func TestPrepareStageSpellCatalogOptionalOmission(t *testing.T) {
env, m := setupPrepareEnv(t)
root := filepath.Dir(env.Config.SessionPath)
writeFile(t, filepath.Join(root, "audio", "a.flac"), "audio")
env.Config.Session.Inputs.AudioFiles = []string{"./audio/a.flac"}
if _, err := (prepareStage{}).Run(context.Background(), env, m); err != nil {
t.Fatalf("prepare.Run() error = %v", err)
}
for _, input := range m.Inputs {
if input.Kind == "spell_catalog" {
t.Fatalf("unexpected spell catalog record: %#v", input)
}
}
destination := filepath.Join(sessionPathsForEnv(env, m.SessionID).InputsDir, "spell_catalog.json")
if _, err := os.Lstat(destination); !os.IsNotExist(err) {
t.Fatalf("Lstat(destination) error = %v, want not exist", err)
}
}
func TestPrepareStageSpellCatalogMissingOrNonRegularSource(t *testing.T) {
tests := []struct {
name string
setup func(string) error
}{
{name: "missing", setup: func(string) error { return nil }},
{name: "directory", setup: func(path string) error { return os.Mkdir(path, 0o755) }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env, m := setupPrepareEnv(t)
root := filepath.Dir(env.Config.SessionPath)
writeFile(t, filepath.Join(root, "audio", "a.flac"), "audio")
env.Config.Session.Inputs.AudioFiles = []string{"./audio/a.flac"}
sourcePath := filepath.Join(root, "spells.json")
if err := tt.setup(sourcePath); err != nil {
t.Fatalf("setup source: %v", err)
}
env.Config.StableInputs.SpellCatalogFile = config.ResolvedInputFile{
Path: "./spells.json",
ConfigPath: env.Config.CampaignPath,
Source: "campaign_config",
}
_, err := (prepareStage{}).Run(context.Background(), env, m)
if err == nil || !strings.Contains(err.Error(), "spell catalog") {
t.Fatalf("prepare.Run() error = %v, want spell catalog source error", err)
}
})
}
}
func TestPrepareStageReplacesAndRemovesSpellCatalog(t *testing.T) {
env, m := setupPrepareEnv(t)
root := filepath.Dir(env.Config.SessionPath)
writeFile(t, filepath.Join(root, "audio", "a.flac"), "audio")
env.Config.Session.Inputs.AudioFiles = []string{"./audio/a.flac"}
sourcePath := filepath.Join(root, "spells.json")
writeFile(t, sourcePath, "first\n")
env.Config.StableInputs.SpellCatalogFile = config.ResolvedInputFile{
Path: "./spells.json",
ConfigPath: env.Config.CampaignPath,
Source: "campaign_config",
}
stage := prepareStage{}
if _, err := stage.Run(context.Background(), env, m); err != nil {
t.Fatalf("first prepare.Run() error = %v", err)
}
firstChecksum := findManifestInput(t, m.Inputs, "spell_catalog").Checksum
destination := filepath.Join(sessionPathsForEnv(env, m.SessionID).InputsDir, "spell_catalog.json")
writeFile(t, sourcePath, "second\n")
if _, err := stage.Run(context.Background(), env, m); err != nil {
t.Fatalf("replacement prepare.Run() error = %v", err)
}
secondChecksum := findManifestInput(t, m.Inputs, "spell_catalog").Checksum
if secondChecksum == firstChecksum {
t.Fatalf("replacement checksum = %q, want different from %q", secondChecksum, firstChecksum)
}
mustReadFileEquals(t, destination, "second\n")
env.Config.StableInputs.SpellCatalogFile = config.ResolvedInputFile{}
env.Config.Session.Inputs.SpellCatalogFile = ""
if _, err := stage.Run(context.Background(), env, m); err != nil {
t.Fatalf("removal prepare.Run() error = %v", err)
}
if _, err := os.Lstat(destination); !os.IsNotExist(err) {
t.Fatalf("Lstat(destination) error = %v, want removed", err)
}
for _, input := range m.Inputs {
if input.Kind == "spell_catalog" {
t.Fatalf("stale spell catalog record remains: %#v", input)
}
}
}
func TestPrepareStageRefusesAmbiguousObsoleteSpellCatalog(t *testing.T) {
env, m := setupPrepareEnv(t)
root := filepath.Dir(env.Config.SessionPath)
writeFile(t, filepath.Join(root, "audio", "a.flac"), "audio")
env.Config.Session.Inputs.AudioFiles = []string{"./audio/a.flac"}
destination := filepath.Join(sessionPathsForEnv(env, m.SessionID).InputsDir, "spell_catalog.json")
writeFile(t, filepath.Join(destination, "keep.txt"), "keep")
_, err := (prepareStage{}).Run(context.Background(), env, m)
if err == nil || !strings.Contains(err.Error(), "remove obsolete spell catalog") || !strings.Contains(err.Error(), "non-regular") {
t.Fatalf("prepare.Run() error = %v, want ambiguous destination rejection", err)
}
mustReadFileEquals(t, filepath.Join(destination, "keep.txt"), "keep")
}
func TestPrepareStageRecordsLocalSessionProvenance(t *testing.T) {
env, m := setupPrepareEnv(t)
root := filepath.Dir(env.Config.SessionPath)
@@ -719,6 +882,17 @@ func findManifestInput(t *testing.T, inputs []manifest.InputRecord, kind string)
return manifest.InputRecord{}
}
func assertManifestInputsSorted(t *testing.T, inputs []manifest.InputRecord) {
t.Helper()
for index := 1; index < len(inputs); index++ {
previous := inputs[index-1]
current := inputs[index]
if previous.Kind > current.Kind || (previous.Kind == current.Kind && previous.Path > current.Path) {
t.Fatalf("manifest inputs are not sorted at %d: %#v before %#v", index, previous, current)
}
}
}
func mustReadFileEquals(t *testing.T, path, want string) {
t.Helper()
data, err := os.ReadFile(path)