package project import ( "bytes" "errors" "fmt" "os" "path/filepath" "reflect" "slices" "strings" "testing" ) func TestLoadYAMLConfig(t *testing.T) { root := t.TempDir() writeProjectFile(t, filepath.Join(root, ConfigFile), ` module: name: Test Module resref: testmod paths: source: src assets: assets build: output haks: - name: core priority: 1 max_bytes: 1024 split: false include: - core/** `) proj, err := Load(root) if err != nil { t.Fatalf("Load returned error: %v", err) } if proj.ConfigSource.Name != ConfigFile || proj.ConfigSource.Format != "yaml" || proj.ConfigSource.Legacy { t.Fatalf("unexpected config source: %#v", proj.ConfigSource) } if got, want := proj.Config.Module.Name, "Test Module"; got != want { t.Fatalf("expected module name %q, got %q", want, got) } if got, want := proj.BuildDir(), filepath.Join(root, "output"); got != want { t.Fatalf("expected build dir %s, got %s", want, got) } } func TestEffectiveConfigAppliesVisibleToolkitDefaults(t *testing.T) { root := t.TempDir() writeProjectFile(t, filepath.Join(root, ConfigFile), ` module: name: Test Module resref: testmod paths: source: src `) proj, err := Load(root) if err != nil { t.Fatalf("Load returned error: %v", err) } effective := proj.EffectiveConfig() if got, want := effective.Paths.Build, "build"; got != want { t.Fatalf("expected default build path %q, got %q", want, got) } if got, want := effective.Outputs.HAKManifest, "haks.json"; got != want { t.Fatalf("expected default HAK manifest %q, got %q", want, got) } if got, want := strings.Join(effective.Extract.Archives, ","), "testmod.mod"; got != want { t.Fatalf("expected default extract archives %q, got %q", want, got) } if effective.Extract.ConsumeArchives == nil || *effective.Extract.ConsumeArchives { t.Fatalf("expected default extract consume_archives false, got %#v", effective.Extract.ConsumeArchives) } if got, want := effective.TopData.PackageHAK, "sow_top.hak"; got != want { t.Fatalf("expected default topdata HAK %q, got %q", want, got) } if got, want := strings.Join(effective.Validation.BuiltinScriptPrefixes, ","), "ga_,gc_,gen_,gui_,nw_,nwg_,ta_,x0_,x1_,x2_,x3_"; got != want { t.Fatalf("expected default built-in script prefixes %q, got %q", want, got) } if got := strings.Join(effective.Validation.RequiredFields["ifo"], ","); got != "Mod_Name" { t.Fatalf("expected default IFO required fields, got %#v", effective.Validation.RequiredFields) } if got, want := strings.Join(effective.Music.ConvertExtensions, ","), ".flac,.m4a,.mp3,.ogg,.wav"; got != want { t.Fatalf("expected default music convert extensions %q, got %q", want, got) } if prov := effective.Provenance["paths.build"]; prov.Source != "toolkit default" { t.Fatalf("expected paths.build toolkit default provenance, got %#v", prov) } if prov := effective.Provenance["module.name"]; prov.Source != "yaml" { t.Fatalf("expected module.name YAML provenance, got %#v", prov) } } func TestEffectiveConfigHonorsValidationConfig(t *testing.T) { root := t.TempDir() writeProjectFile(t, filepath.Join(root, ConfigFile), ` module: name: Test Module resref: testmod paths: source: src validation: builtin_script_prefixes: - custom_ - NW_ required_fields: ifo: - Mod_Name - Mod_Hak `) proj, err := Load(root) if err != nil { t.Fatalf("Load returned error: %v", err) } got := strings.Join(proj.EffectiveConfig().Validation.BuiltinScriptPrefixes, ",") if want := "custom_,nw_"; got != want { t.Fatalf("expected configured validation prefixes %q, got %q", want, got) } if got := strings.Join(proj.EffectiveConfig().Validation.RequiredFields["ifo"], ","); got != "Mod_Hak,Mod_Name" { t.Fatalf("expected configured required fields, got %#v", proj.EffectiveConfig().Validation.RequiredFields) } } func TestScanUsesYAMLMusicConvertExtensionsWithinConfiguredDatasetRoots(t *testing.T) { root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, "assets", "audio", "westgate"), 0o755); err != nil { t.Fatalf("mkdir westgate audio: %v", err) } if err := os.MkdirAll(filepath.Join(root, "assets", "audio", "other"), 0o755); err != nil { t.Fatalf("mkdir other audio: %v", err) } writeProjectFile(t, filepath.Join(root, ConfigFile), ` module: name: Test Module resref: testmod paths: source: src assets: assets music: defaults: convert_extensions: - .flac datasets: westgate_audio: source: audio/westgate convert_extensions: - .aac `) writeProjectFile(t, filepath.Join(root, "assets", "audio", "westgate", "theme.aac"), "aac") writeProjectFile(t, filepath.Join(root, "assets", "audio", "westgate", "theme.flac"), "flac") writeProjectFile(t, filepath.Join(root, "assets", "audio", "other", "ignored.aac"), "aac") proj, err := Load(root) if err != nil { t.Fatalf("Load returned error: %v", err) } if err := proj.Scan(); err != nil { t.Fatalf("Scan returned error: %v", err) } if !slices.Contains(proj.Inventory.AssetFiles, "audio/westgate/theme.aac") { t.Fatalf("expected configured dataset AAC file to be discovered, got %#v", proj.Inventory.AssetFiles) } if !slices.Contains(proj.Inventory.AssetFiles, "audio/westgate/theme.flac") { t.Fatalf("expected configured dataset FLAC file to be discovered, got %#v", proj.Inventory.AssetFiles) } if slices.Contains(proj.Inventory.AssetFiles, "audio/other/ignored.aac") { t.Fatalf("did not expect non-dataset AAC file to be discovered, got %#v", proj.Inventory.AssetFiles) } } func TestEffectiveConfigHonorsConfiguredOutputAndCacheFields(t *testing.T) { root := t.TempDir() writeProjectFile(t, filepath.Join(root, ConfigFile), ` module: name: Test Module resref: testmod paths: build: output cache: cache outputs: module_archive: modules/{module.resref}.mod hak_manifest: manifests/haks.json hak_archive: haks/{hak.name}.hak scripts: cache: "{paths.cache}/compiled" topdata: source: topdata build: "{paths.cache}" compiled_2da_dir: tables compiled_tlk: dialog.tlk package_hak: custom_top.hak package_tlk: custom_dialog.tlk wiki: output_root: docs pages_dir: pages state_file: wiki-state.json autogen: cache: root: "{paths.cache}/autogen" `) proj, err := Load(root) if err != nil { t.Fatalf("Load returned error: %v", err) } if got, want := proj.ModuleArchivePath(), filepath.Join(root, "output", "modules", "testmod.mod"); got != want { t.Fatalf("expected module archive path %s, got %s", want, got) } if got, want := proj.HAKManifestPath(), filepath.Join(root, "output", "manifests", "haks.json"); got != want { t.Fatalf("expected HAK manifest path %s, got %s", want, got) } if got, want := proj.HAKArchivePath("core_01"), filepath.Join(root, "output", "haks", "core_01.hak"); got != want { t.Fatalf("expected HAK archive path %s, got %s", want, got) } if got, want := proj.ScriptCacheDir(), filepath.Join(root, "cache", "compiled"); got != want { t.Fatalf("expected script cache path %s, got %s", want, got) } if got, want := proj.TopDataCompiled2DADir(), filepath.Join(root, "cache", "tables"); got != want { t.Fatalf("expected topdata 2da path %s, got %s", want, got) } if got, want := proj.TopDataWikiStatePath(), filepath.Join(root, "cache", "docs", "wiki-state.json"); got != want { t.Fatalf("expected wiki state path %s, got %s", want, got) } if got, want := proj.AutogenCachePath("manifest.json"), filepath.Join(root, "cache", "autogen", "manifest.json"); got != want { t.Fatalf("expected autogen cache path %s, got %s", want, got) } } func TestEffectiveConfigHonorsConfiguredCompilerExtractWikiAndAutogenPolicy(t *testing.T) { root := t.TempDir() writeProjectFile(t, filepath.Join(root, ConfigFile), ` module: name: Test Module resref: testmod paths: source: src tools: custom-tools scripts: source_dir: module-scripts compiler: path: bin/compiler search: - "{paths.tools}/compiler" env: path: TEST_COMPILER nwn_root: - TEST_NWN_ROOT nwn_user_directory: - TEST_NWN_USER extract: layout: nwn_canonical_json hak_discovery: configured_haks cleanup_stale: false merge: gff_json: - target: module/module.ifo.json preserve_fields: - Mod_Entry_Area merge_lists: - field: Mod_Area_list key_field: Area_Name strategy: preserve_existing_order_append_new topdata: wiki: source: topdata/wiki managed_namespaces: - skills renderer: nodebb_tiptap_html link_strategy: preserve_westgate_wiki_links namespaces_file: namespaces.yaml tables_file: custom-tables.yaml visibility_file: visibility.yaml templates_dir: templates manual_sections_dir: manual-sections deploy_manifest: wiki-manifest.json deploy_edit_summary: Custom summary title_prefix_min_length: 4 status_listing_scope: namespace stale_pages: default: report live_cleanup: archive autogen: cache: root: "{paths.cache}/released" max_age: 30m refresh_env: TEST_AUTOGEN_REFRESH release_source: provider: gitea server_url_env: TEST_ASSETS_SERVER repo_env: TEST_ASSETS_REPO `) proj, err := Load(root) if err != nil { t.Fatalf("Load returned error: %v", err) } effective := proj.EffectiveConfig() if got, want := proj.ScriptSourceDir(), filepath.Join(root, "src", "module-scripts"); got != want { t.Fatalf("expected script source dir %s, got %s", want, got) } if got, want := proj.ScriptCompilerPath(), filepath.Join(root, "bin", "compiler"); got != want { t.Fatalf("expected compiler path %s, got %s", want, got) } if got, want := effective.Scripts.Compiler.Search[0], "custom-tools/compiler"; got != want { t.Fatalf("expected compiler search %q, got %q", want, got) } if got, want := effective.Scripts.Compiler.Env.Path, "TEST_COMPILER"; got != want { t.Fatalf("expected compiler env %q, got %q", want, got) } if got, want := effective.Extract.HAKDiscovery, "configured_haks"; got != want { t.Fatalf("expected extract hak discovery %q, got %q", want, got) } if effective.Extract.CleanupStale == nil || *effective.Extract.CleanupStale { t.Fatalf("expected cleanup_stale false, got %#v", effective.Extract.CleanupStale) } if got, want := len(effective.Extract.Merge.GFFJSON), 1; got != want { t.Fatalf("expected %d gff json merge rule, got %d", want, got) } rule := effective.Extract.Merge.GFFJSON[0] if got, want := rule.Target, "module/module.ifo.json"; got != want { t.Fatalf("expected merge target %q, got %q", want, got) } if got, want := rule.PreserveFields, []string{"Mod_Entry_Area"}; !reflect.DeepEqual(got, want) { t.Fatalf("expected preserve fields %#v, got %#v", want, got) } if got, want := rule.MergeLists[0].Strategy, "preserve_existing_order_append_new"; got != want { t.Fatalf("expected merge list strategy %q, got %q", want, got) } if got, want := effective.TopData.Wiki.DeployManifest, "wiki-manifest.json"; got != want { t.Fatalf("expected wiki deploy manifest %q, got %q", want, got) } if got, want := effective.TopData.Wiki.Source, "topdata/wiki"; got != want { t.Fatalf("expected wiki source %q, got %q", want, got) } if got, want := effective.TopData.Wiki.Renderer, "nodebb_tiptap_html"; got != want { t.Fatalf("expected wiki renderer %q, got %q", want, got) } if got, want := effective.TopData.Wiki.LinkStrategy, "preserve_westgate_wiki_links"; got != want { t.Fatalf("expected wiki link strategy %q, got %q", want, got) } if got, want := effective.TopData.Wiki.NamespacesFile, "namespaces.yaml"; got != want { t.Fatalf("expected wiki namespaces file %q, got %q", want, got) } if got, want := effective.TopData.Wiki.TablesFile, "custom-tables.yaml"; got != want { t.Fatalf("expected wiki tables file %q, got %q", want, got) } if got, want := effective.TopData.Wiki.VisibilityFile, "visibility.yaml"; got != want { t.Fatalf("expected wiki visibility file %q, got %q", want, got) } if got, want := effective.TopData.Wiki.TemplatesDir, "templates"; got != want { t.Fatalf("expected wiki templates dir %q, got %q", want, got) } if got, want := effective.TopData.Wiki.ManualSectionsDir, "manual-sections"; got != want { t.Fatalf("expected wiki manual sections dir %q, got %q", want, got) } if got, want := effective.TopData.Wiki.StalePages.Default, "report"; got != want { t.Fatalf("expected wiki stale default %q, got %q", want, got) } if got, want := effective.TopData.Wiki.StalePages.LiveCleanup, "archive"; got != want { t.Fatalf("expected wiki stale live cleanup %q, got %q", want, got) } if got, want := effective.TopData.Wiki.TitlePrefixMinLength, 4; got != want { t.Fatalf("expected wiki title prefix minimum length %d, got %d", want, got) } if got, want := effective.TopData.Wiki.StatusListingScope, "namespace"; got != want { t.Fatalf("expected wiki status listing scope %q, got %q", want, got) } if got, want := effective.Autogen.Cache.MaxAge, "30m"; got != want { t.Fatalf("expected autogen cache max age %q, got %q", want, got) } if got, want := proj.AutogenRefreshEnv(), "TEST_AUTOGEN_REFRESH"; got != want { t.Fatalf("expected autogen refresh env %q, got %q", want, got) } } func TestProjectValidationAcceptsWikiStalePurgePolicy(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "src")) mkdirAll(t, filepath.Join(root, "build")) writeProjectFile(t, filepath.Join(root, ConfigFile), ` module: name: Test Module resref: testmod paths: source: src build: build topdata: wiki: stale_pages: default: report live_cleanup: purge `) proj, err := Load(root) if err != nil { t.Fatalf("load project: %v", err) } if err := proj.ValidateLayout(); err != nil { t.Fatalf("validate purge stale policy: %v", err) } } func TestProjectValidationRejectsUnsupportedWikiStalePolicies(t *testing.T) { for _, policy := range []string{"unpublish", "remove-everything"} { t.Run(policy, func(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "src")) mkdirAll(t, filepath.Join(root, "build")) writeProjectFile(t, filepath.Join(root, ConfigFile), fmt.Sprintf(` module: name: Test Module resref: testmod paths: source: src build: build topdata: wiki: stale_pages: live_cleanup: %s `, policy)) proj, err := Load(root) if err != nil { t.Fatalf("load project: %v", err) } err = proj.ValidateLayout() if err == nil || !strings.Contains(err.Error(), policy) { t.Fatalf("expected %q stale policy validation failure, got %v", policy, err) } }) } } func TestValidateLayoutRejectsInvalidExtractMergeRules(t *testing.T) { tests := []struct { name string config string wantErr string }{ { name: "empty target", config: ` extract: merge: gff_json: - target: "" preserve_fields: [Mod_Entry_Area] `, wantErr: "extract.merge.gff_json[0].target must not be empty", }, { name: "duplicate target", config: ` extract: merge: gff_json: - target: module/module.ifo.json - target: module/module.ifo.json `, wantErr: "extract.merge.gff_json target \"module/module.ifo.json\" is configured more than once", }, { name: "empty merge list field", config: ` extract: merge: gff_json: - target: module/module.ifo.json merge_lists: - field: "" key_field: Area_Name strategy: preserve_existing_order_append_new `, wantErr: "extract.merge.gff_json[0].merge_lists[0].field must not be empty", }, { name: "unsupported strategy", config: ` extract: merge: gff_json: - target: module/module.ifo.json merge_lists: - field: Mod_Area_list key_field: Area_Name strategy: replace `, wantErr: "extract.merge.gff_json[0].merge_lists[0].strategy \"replace\" is not supported", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { root := t.TempDir() writeProjectFile(t, filepath.Join(root, ConfigFile), ` module: name: Test Module resref: testmod paths: source: src build: build `+tt.config) proj, err := Load(root) if err != nil { t.Fatalf("Load returned error: %v", err) } err = proj.ValidateLayout() if err == nil { t.Fatalf("expected validation error containing %q", tt.wantErr) } if !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("expected validation error containing %q, got %v", tt.wantErr, err) } }) } } func TestActiveOverridesAreVisibleAndSensitiveValuesAreMasked(t *testing.T) { root := t.TempDir() writeProjectFile(t, filepath.Join(root, ConfigFile), ` module: name: Test Module resref: testmod paths: source: src `) t.Setenv("SOW_BUILD_HAKS_KEEP_EXISTING", "1") t.Setenv("NODEBB_API_TOKEN", "secret-token") proj, err := Load(root) if err != nil { t.Fatalf("Load returned error: %v", err) } overrides := proj.ActiveOverrides() var sawKeepExisting, sawMaskedToken bool for _, override := range overrides { if override.Key == "build.keep_existing_haks" && override.Value == "1" { sawKeepExisting = true } if override.Key == "wiki.token" && override.Value == "" { sawMaskedToken = true } } if !sawKeepExisting { t.Fatalf("expected build keep existing override in %#v", overrides) } if !sawMaskedToken { t.Fatalf("expected masked wiki token override in %#v", overrides) } } func TestEffectiveConfigJSONIsDeterministic(t *testing.T) { root := t.TempDir() writeProjectFile(t, filepath.Join(root, ConfigFile), ` module: name: Test Module resref: testmod paths: source: src `) proj, err := Load(root) if err != nil { t.Fatalf("Load returned error: %v", err) } first, err := proj.EffectiveConfigJSON() if err != nil { t.Fatalf("EffectiveConfigJSON returned error: %v", err) } second, err := proj.EffectiveConfigJSON() if err != nil { t.Fatalf("EffectiveConfigJSON returned error: %v", err) } if !bytes.Equal(first, second) { t.Fatalf("effective config JSON is not deterministic") } if !bytes.Contains(first, []byte(`"hak_manifest": "haks.json"`)) { t.Fatalf("effective config JSON missing HAK manifest default: %s", first) } } func TestLoadYMLConfig(t *testing.T) { root := t.TempDir() writeProjectFile(t, filepath.Join(root, ConfigFileYML), ` module: name: Test Module resref: testmod paths: source: src build: build `) proj, err := Load(root) if err != nil { t.Fatalf("Load returned error: %v", err) } if proj.ConfigSource.Name != ConfigFileYML || proj.ConfigSource.Format != "yaml" || proj.ConfigSource.Legacy { t.Fatalf("unexpected config source: %#v", proj.ConfigSource) } } func TestLoadLegacyJSONConfig(t *testing.T) { root := t.TempDir() writeProjectFile(t, filepath.Join(root, LegacyConfigFile), `{ "module": { "name": "Legacy Module", "resref": "legacy" }, "paths": { "source": "src", "build": "build" } }`+"\n") proj, err := Load(root) if err != nil { t.Fatalf("Load returned error: %v", err) } if proj.ConfigSource.Name != LegacyConfigFile || proj.ConfigSource.Format != "json" || !proj.ConfigSource.Legacy { t.Fatalf("unexpected config source: %#v", proj.ConfigSource) } } func TestLoadPrefersYAMLOverLegacyJSON(t *testing.T) { root := t.TempDir() writeProjectFile(t, filepath.Join(root, LegacyConfigFile), `{ "module": { "name": "Legacy Module", "resref": "legacy" } }`+"\n") writeProjectFile(t, filepath.Join(root, ConfigFile), ` module: name: YAML Module resref: yamlmod paths: source: src build: build `) proj, err := Load(root) if err != nil { t.Fatalf("Load returned error: %v", err) } if proj.ConfigSource.Name != ConfigFile { t.Fatalf("expected YAML config to win, got %#v", proj.ConfigSource) } if got, want := proj.Config.Module.ResRef, "yamlmod"; got != want { t.Fatalf("expected resref %q, got %q", want, got) } } func TestLoadRejectsMalformedYAML(t *testing.T) { root := t.TempDir() writeProjectFile(t, filepath.Join(root, ConfigFile), "module:\n name: Test\n resref: bad\n") if _, err := Load(root); err == nil { t.Fatal("expected malformed YAML error") } } func TestLoadRejectsUnknownYAMLFields(t *testing.T) { root := t.TempDir() writeProjectFile(t, filepath.Join(root, ConfigFile), ` module: name: Test Module resref: testmod paths: source: src build: build unexpected: true `) err := loadAndValidate(root) if err == nil { t.Fatal("expected unknown field error") } if !strings.Contains(err.Error(), "field unexpected not found") { t.Fatalf("expected unknown field error, got %v", err) } } func TestFindRootUsesYAMLConfig(t *testing.T) { root := t.TempDir() child := filepath.Join(root, "nested", "dir") mkdirAll(t, child) writeProjectFile(t, filepath.Join(root, ConfigFile), "module:\n name: Test\n resref: test\n") got, err := FindRoot(child) if err != nil { t.Fatalf("FindRoot returned error: %v", err) } if got != root { t.Fatalf("expected root %s, got %s", root, got) } } func TestTopDataAssetsDirPrefersExplicitConfig(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "src")) mkdirAll(t, filepath.Join(root, "assets")) mkdirAll(t, filepath.Join(root, "build")) mkdirAll(t, filepath.Join(root, "custom-assets")) proj := &Project{ Root: root, Config: Config{ Module: ModuleConfig{Name: "Test", ResRef: "test"}, Paths: PathConfig{Source: "src", Assets: "assets", Build: "build"}, TopData: TopDataConfig{ Assets: "custom-assets", }, }, } result := proj.TopDataAssetsDir() expected := filepath.Join(root, "custom-assets") if result != expected { t.Errorf("expected %s, got %s", expected, result) } } func TestTopDataAssetsDirDoesNotAssumeSiblingRepo(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "src")) mkdirAll(t, filepath.Join(root, "assets")) mkdirAll(t, filepath.Join(root, "build")) parentDir := filepath.Dir(root) siblingPath := filepath.Join(parentDir, "sow-assets") mkdirAll(t, siblingPath) mkdirAll(t, filepath.Join(siblingPath, "assets", "part", "belt")) proj := &Project{ Root: root, Config: Config{ Module: ModuleConfig{Name: "Test", ResRef: "test"}, Paths: PathConfig{Source: "src", Assets: "assets", Build: "build"}, TopData: TopDataConfig{ Assets: "sow-assets", }, }, } result := proj.TopDataAssetsDir() expected := filepath.Join(root, "sow-assets") if result != expected { t.Errorf("expected unresolved local path %s, got %s", expected, result) } } func TestTopDataAssetsDirReturnsEmptyForNoConfig(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "src")) mkdirAll(t, filepath.Join(root, "assets")) mkdirAll(t, filepath.Join(root, "build")) proj := &Project{ Root: root, Config: Config{ Module: ModuleConfig{Name: "Test", ResRef: "test"}, Paths: PathConfig{Source: "src", Assets: "assets", Build: "build"}, TopData: TopDataConfig{}, }, } result := proj.TopDataAssetsDir() if result != "" { t.Errorf("expected empty string, got %s", result) } } func TestModuleArchivePathUsesConfiguredBuildDirAndResRef(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "output")) proj := &Project{ Root: root, Config: Config{ Module: ModuleConfig{Name: "Test", ResRef: "testmod"}, Paths: PathConfig{Build: "output"}, }, } got := proj.ModuleArchivePath() want := filepath.Join(root, "output", "testmod.mod") if got != want { t.Fatalf("expected %s, got %s", want, got) } } func TestHAKPathsUseConfiguredBuildDir(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "output")) proj := &Project{ Root: root, Config: Config{ Paths: PathConfig{Build: "output"}, }, } if got, want := proj.HAKManifestPath(), filepath.Join(root, "output", "haks.json"); got != want { t.Fatalf("expected manifest path %s, got %s", want, got) } if got, want := proj.HAKArchivePath("core_01"), filepath.Join(root, "output", "core_01.hak"); got != want { t.Fatalf("expected hak path %s, got %s", want, got) } } func TestTopDataPackagePathsUseConfiguredNamesAndBuildDir(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "output")) proj := &Project{ Root: root, Config: Config{ Paths: PathConfig{Build: "output"}, TopData: TopDataConfig{ PackageHAK: "custom_top.hak", PackageTLK: "custom_dialog.tlk", }, }, } if got, want := proj.TopDataPackageHAKPath(), filepath.Join(root, "output", "custom_top.hak"); got != want { t.Fatalf("expected top hak path %s, got %s", want, got) } if got, want := proj.TopDataPackageTLKPath(), filepath.Join(root, "output", "custom_dialog.tlk"); got != want { t.Fatalf("expected top tlk path %s, got %s", want, got) } } func TestValidateLayoutAllowsMissingAssetsDir(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "src")) mkdirAll(t, filepath.Join(root, "build")) proj := &Project{ Root: root, Config: Config{ Module: ModuleConfig{Name: "Test", ResRef: "test"}, Paths: PathConfig{Source: "src", Assets: "assets", Build: "build"}, }, } if err := proj.ValidateLayout(); err != nil { t.Fatalf("ValidateLayout returned error: %v", err) } if _, err := os.Stat(filepath.Join(root, "assets")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("expected missing assets dir to remain absent, got err=%v", err) } } func TestValidateLayoutRejectsInvalidTopDataPackageOutputNames(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "src")) mkdirAll(t, filepath.Join(root, "build")) proj := &Project{ Root: root, Config: Config{ Module: ModuleConfig{Name: "Test", ResRef: "test"}, Paths: PathConfig{Source: "src", Build: "build"}, TopData: TopDataConfig{ Source: "topdata", PackageHAK: "nested/custom_top.hak", PackageTLK: "custom_dialog.txt", }, }, } err := proj.ValidateLayout() if err == nil { t.Fatal("expected topdata output validation error") } if !strings.Contains(err.Error(), "topdata.package_hak") { t.Fatalf("expected package_hak validation error, got %v", err) } if !strings.Contains(err.Error(), "topdata.package_tlk") { t.Fatalf("expected package_tlk validation error, got %v", err) } } func TestValidateLayoutRejectsUnsafeGeneratedOutputPaths(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "src")) mkdirAll(t, filepath.Join(root, "build")) proj := &Project{ Root: root, Config: Config{ Module: ModuleConfig{Name: "Test", ResRef: "test"}, Paths: PathConfig{Source: "src", Build: "build"}, Outputs: OutputConfig{ HAKManifest: "../haks.json", }, TopData: TopDataConfig{ Source: "topdata", Compiled2DADir: "../2da", }, }, } err := proj.ValidateLayout() if err == nil { t.Fatal("expected unsafe output path validation error") } if !strings.Contains(err.Error(), "outputs.hak_manifest") { t.Fatalf("expected HAK manifest path validation error, got %v", err) } if !strings.Contains(err.Error(), "topdata.compiled_2da_dir") { t.Fatalf("expected topdata path validation error, got %v", err) } } func TestValidateLayoutRejectsInvalidTopDataWikiConfig(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "src")) mkdirAll(t, filepath.Join(root, "build")) proj := &Project{ Root: root, Config: Config{ Module: ModuleConfig{Name: "Test", ResRef: "test"}, Paths: PathConfig{Source: "src", Build: "build"}, TopData: TopDataConfig{ Source: "topdata", Wiki: TopDataWikiConfig{ Source: "../wiki", Renderer: "dokuwiki", LinkStrategy: "rewrite_links", StatusListingScope: "global", NamespacesFile: "../namespaces.yaml", TablesFile: "../tables.yaml", VisibilityFile: "../visibility.yaml", TemplatesDir: ".", ManualSectionsDir: "../manual", StalePages: TopDataWikiStalePagesConfig{ Default: "delete", LiveCleanup: "delete", }, }, }, }, } err := proj.ValidateLayout() if err == nil { t.Fatal("expected invalid wiki config validation error") } for _, needle := range []string{ "topdata.wiki.source", "topdata.wiki.renderer", "topdata.wiki.link_strategy", "topdata.wiki.status_listing_scope", "topdata.wiki.namespaces_file", "topdata.wiki.tables_file", "topdata.wiki.visibility_file", "topdata.wiki.templates_dir", "topdata.wiki.manual_sections_dir", "topdata.wiki.stale_pages.default", "topdata.wiki.stale_pages.live_cleanup", } { if !strings.Contains(err.Error(), needle) { t.Fatalf("expected validation error to mention %s, got %v", needle, err) } } } func TestValidateLayoutRejectsRootSourceAndAssetPaths(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "build")) proj := &Project{ Root: root, Config: Config{ Module: ModuleConfig{Name: "Test", ResRef: "test"}, Paths: PathConfig{Source: ".", Assets: ".", Build: "build"}, }, } err := proj.ValidateLayout() if err == nil { t.Fatal("expected root source and asset path validation error") } if !strings.Contains(err.Error(), "paths.source") { t.Fatalf("expected source path validation error, got %v", err) } if !strings.Contains(err.Error(), "paths.assets") { t.Fatalf("expected assets path validation error, got %v", err) } } func TestValidateLayoutRejectsInvalidAutogenConsumerConfig(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "src")) mkdirAll(t, filepath.Join(root, "build")) proj := &Project{ Root: root, Config: Config{ Module: ModuleConfig{Name: "Test", ResRef: "test"}, Paths: PathConfig{Source: "src", Build: "build"}, Autogen: AutogenConfig{ Consumers: []AutogenConsumerConfig{ { ID: "head_visualeffects", Producer: "head_visualeffects", Dataset: "visualeffects", Mode: "unsupported", Root: "vfxs", Include: []string{"head_accessories/**/*.mdl"}, Derive: AutogenDeriveConfig{Kind: "model_stem", GroupFrom: "first_path_segment"}, Manifest: AutogenManifestConfig{ ReleaseTag: "head-vfx-manifest-current", AssetName: "nested/sow-head-vfx-manifest.json", CacheName: "sow-head-vfx-manifest.txt", }, }, }, }, }, } err := proj.ValidateLayout() if err == nil { t.Fatal("expected autogen validation error") } if !strings.Contains(err.Error(), "autogen.consumers[0].mode") { t.Fatalf("expected autogen mode validation error, got %v", err) } if !strings.Contains(err.Error(), "autogen.consumers[0].manifest.asset_name") { t.Fatalf("expected autogen asset_name validation error, got %v", err) } if !strings.Contains(err.Error(), "autogen.consumers[0].manifest.cache_name") { t.Fatalf("expected autogen cache_name validation error, got %v", err) } } func TestValidateLayoutAcceptsGeneratedTopData2DAConfig(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "assets")) mkdirAll(t, filepath.Join(root, "build")) proj := Project{ Root: root, Config: Config{ Module: ModuleConfig{Name: "Test", ResRef: "testmod"}, Paths: PathConfig{Assets: "assets"}, Generated: GeneratedConfig{ TopData2DA: []GeneratedTopData2DAConfig{ { ID: "parts", Source: "topdata", Output: "{paths.cache}/generated-assets/parts-2da", IncludeDatasets: []string{"parts/**"}, PackageRoot: "part", Autogen: AutogenConsumerConfig{ ID: "parts", Mode: "parts_rows", Root: "part", Include: []string{"**/*.mdl"}, Derive: AutogenDeriveConfig{Kind: "trailing_numeric_suffix", GroupFrom: "first_path_segment"}, PartsRows: PartsRowsConfig{ RowDefaults: map[string]string{"COSTMODIFIER": "0"}, ACBonus: PartsRowsACBonusConfig{ Default: PartsRowsACBonusPolicy{Strategy: "ascending_row_id_sort_key", Divisor: 100, Format: "%.2f"}, Datasets: map[string]PartsRowsACBonusPolicy{ "chest": {Strategy: "fixed", Value: "0.00"}, }, }, }, }, }, { ID: "cachedmodels", Source: "topdata", Output: "{paths.cache}/generated-assets/cachedmodels-2da", IncludeDatasets: []string{"cachedmodels"}, PackageRoot: "vfxs", Autogen: AutogenConsumerConfig{ ID: "cachedmodels", Mode: "cachedmodels_rows", Root: "vfxs", Include: []string{"head_accessories/**/*.mdl", "head_features/**/*.mdl"}, Derive: AutogenDeriveConfig{Kind: "model_stem", GroupFrom: "first_path_segment"}, }, }, }, }, }, } if err := proj.ValidateLayout(); err != nil { t.Fatalf("ValidateLayout returned error: %v", err) } effective := proj.EffectiveConfig() if got, want := effective.Generated.TopData2DA[0].Output, ".cache/generated-assets/parts-2da"; got != want { t.Fatalf("expected expanded output %q, got %q", want, got) } } func TestValidateLayoutRejectsEscapingGeneratedTopData2DAConfig(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "assets")) mkdirAll(t, filepath.Join(root, "build")) proj := Project{ Root: root, Config: Config{ Module: ModuleConfig{Name: "Test", ResRef: "testmod"}, Paths: PathConfig{Assets: "assets"}, Generated: GeneratedConfig{ TopData2DA: []GeneratedTopData2DAConfig{ { ID: "parts", Source: "../topdata", Output: "{paths.cache}/generated-assets/parts-2da", IncludeDatasets: []string{"parts/**"}, PackageRoot: "part", Autogen: AutogenConsumerConfig{ ID: "parts", Mode: "parts_rows", Root: "part", Include: []string{"**/*.mdl"}, Derive: AutogenDeriveConfig{Kind: "trailing_numeric_suffix", GroupFrom: "first_path_segment"}, }, }, }, }, }, } err := proj.ValidateLayout() if err == nil || !strings.Contains(err.Error(), "generated_assets.topdata_2da[0].source") { t.Fatalf("expected generated topdata source validation error, got %v", err) } } func TestValidateLayoutRejectsInvalidGeneratedPartsRowsACBonusConfig(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "assets")) mkdirAll(t, filepath.Join(root, "build")) proj := Project{ Root: root, Config: Config{ Module: ModuleConfig{Name: "Test", ResRef: "testmod"}, Paths: PathConfig{Assets: "assets"}, Generated: GeneratedConfig{ TopData2DA: []GeneratedTopData2DAConfig{ { ID: "parts", Source: "topdata", Output: "{paths.cache}/generated-assets/parts-2da", IncludeDatasets: []string{"parts/**"}, PackageRoot: "part", Autogen: AutogenConsumerConfig{ ID: "parts", Mode: "parts_rows", Root: "part", Include: []string{"**/*.mdl"}, Derive: AutogenDeriveConfig{Kind: "trailing_numeric_suffix", GroupFrom: "first_path_segment"}, PartsRows: PartsRowsConfig{ ACBonus: PartsRowsACBonusConfig{ Default: PartsRowsACBonusPolicy{Strategy: "descending_row_id_sort_key", MaxRowID: 0, Divisor: -1, Format: "%d"}, Datasets: map[string]PartsRowsACBonusPolicy{ "chest": {Strategy: "fixed"}, "robe": {Strategy: "unknown"}, }, }, }, }, }, }, }, }, } err := proj.ValidateLayout() if err == nil { t.Fatal("expected invalid parts_rows acbonus config to fail") } text := err.Error() for _, want := range []string{ "generated_assets.topdata_2da[0].autogen.parts_rows.acbonus.default.max_row_id", "generated_assets.topdata_2da[0].autogen.parts_rows.acbonus.default.divisor", "generated_assets.topdata_2da[0].autogen.parts_rows.acbonus.default.format", "generated_assets.topdata_2da[0].autogen.parts_rows.acbonus.datasets.chest.value", "generated_assets.topdata_2da[0].autogen.parts_rows.acbonus.datasets.robe.strategy", } { if !strings.Contains(text, want) { t.Fatalf("expected validation error containing %q, got %v", want, err) } } } func TestValidateLayoutRejectsDuplicateHAKNames(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "src")) mkdirAll(t, filepath.Join(root, "build")) proj := &Project{ Root: root, Config: Config{ Module: ModuleConfig{Name: "Test", ResRef: "test"}, Paths: PathConfig{Source: "src", Build: "build"}, HAKs: []HAKConfig{ {Name: "core", Priority: 1, Include: []string{"core/**"}}, {Name: "CORE", Priority: 2, Include: []string{"other/**"}}, }, }, } err := proj.ValidateLayout() if err == nil { t.Fatal("expected duplicate hak validation error") } if !strings.Contains(err.Error(), "duplicated") { t.Fatalf("expected duplicate validation error, got %v", err) } } func TestValidateLayoutRejectsAmbiguousMusicPrefixes(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "assets")) mkdirAll(t, filepath.Join(root, "build")) proj := &Project{ Root: root, Config: Config{ Module: ModuleConfig{Name: "Test", ResRef: "test"}, Paths: PathConfig{Assets: "assets", Build: "build"}, Music: MusicConfig{ Prefixes: map[string]string{ "envi/music": "mus_", "/envi/music": "mus2_", }, }, }, } err := proj.ValidateLayout() if err == nil { t.Fatal("expected music prefix validation error") } if !strings.Contains(err.Error(), "normalize to the same path") { t.Fatalf("expected ambiguous prefix validation error, got %v", err) } } func TestScanAllowsMissingAssetsDir(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "src")) mkdirAll(t, filepath.Join(root, "build")) proj := &Project{ Root: root, Config: Config{ Module: ModuleConfig{Name: "Test", ResRef: "test"}, Paths: PathConfig{Source: "src", Assets: "assets", Build: "build"}, }, } if err := proj.Scan(); err != nil { t.Fatalf("Scan returned error: %v", err) } if len(proj.Inventory.AssetFiles) != 0 { t.Fatalf("expected no asset files, got %#v", proj.Inventory.AssetFiles) } } func mkdirAll(t *testing.T, path string) { t.Helper() if err := os.MkdirAll(path, 0o755); err != nil { t.Fatalf("mkdir %s: %v", path, err) } } func TestCloneWithHAKNamesFiltersConfiguredHAKs(t *testing.T) { proj := &Project{ Root: "/tmp/test", Config: Config{ Module: ModuleConfig{Name: "Test", ResRef: "test"}, Paths: PathConfig{Source: "src", Assets: "assets", Build: "build"}, HAKs: []HAKConfig{ {Name: "sow_over", Priority: 1, Include: []string{"over/**"}}, {Name: "sow_core", Priority: 2, Include: []string{"core/**"}}, {Name: "sow_item", Priority: 3, Include: []string{"item/**"}}, }, }, } filtered, err := proj.CloneWithHAKNames([]string{"sow_core", "sow_item"}) if err != nil { t.Fatalf("CloneWithHAKNames returned error: %v", err) } if filtered == proj { t.Fatalf("expected filtered clone, got original pointer") } got := make([]string, 0, len(filtered.Config.HAKs)) for _, hak := range filtered.Config.HAKs { got = append(got, hak.Name) } want := []string{"sow_core", "sow_item"} if !slices.Equal(got, want) { t.Fatalf("unexpected filtered haks: got %v want %v", got, want) } if len(proj.Config.HAKs) != 3 { t.Fatalf("original project config should remain unchanged, got %d haks", len(proj.Config.HAKs)) } } func TestCloneWithHAKNamesRejectsUnknownHAKs(t *testing.T) { proj := &Project{ Config: Config{ HAKs: []HAKConfig{ {Name: "sow_core", Priority: 1, Include: []string{"core/**"}}, }, }, } if _, err := proj.CloneWithHAKNames([]string{"missing_hak"}); err == nil { t.Fatal("expected error for unknown hak name") } } func loadAndValidate(root string) error { proj, err := Load(root) if err != nil { return err } return proj.ValidateLayout() } func writeProjectFile(t *testing.T, path, content string) { t.Helper() if err := os.WriteFile(path, []byte(strings.TrimPrefix(content, "\n")), 0o644); err != nil { t.Fatalf("write %s: %v", path, err) } }