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 TestEffectiveConfigReportsConfiguredAndDefaultProvenance(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() for _, field := range []string{"module.name", "paths.build"} { if strings.TrimSpace(effective.Provenance[field].Source) == "" { t.Errorf("expected %s to report provenance", field) } } if effective.Module.Name != "Test Module" { t.Fatalf("configured module name was not preserved: %q", effective.Module.Name) } } 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) } effective := proj.EffectiveConfig() for _, prefix := range []string{"custom_", "nw_"} { if !slices.Contains(effective.Validation.BuiltinScriptPrefixes, prefix) { t.Errorf("configured validation prefixes missing %q: %#v", prefix, effective.Validation.BuiltinScriptPrefixes) } } for _, field := range []string{"Mod_Hak", "Mod_Name"} { if !slices.Contains(effective.Validation.RequiredFields["ifo"], field) { t.Errorf("configured required fields missing %q: %#v", field, effective.Validation.RequiredFields) } } } func TestEffectiveConfigIncludesTopDataValueEncodings(t *testing.T) { root := t.TempDir() writeProjectFile(t, filepath.Join(root, ConfigFile), ` module: name: Test Module resref: testmod topdata: source: topdata value_encodings: - dataset: racialtypes/core column: AvailableSkinColors mode: packed_hex_list min: 0 max: 175 hex_width: 2 `) proj, err := Load(root) if err != nil { t.Fatalf("Load returned error: %v", err) } encodings := proj.EffectiveConfig().TopData.ValueEncodings if len(encodings) != 1 { t.Fatalf("expected one topdata value encoding, got %#v", encodings) } got := encodings[0] if got.Dataset != "racialtypes/core" || got.Column != "AvailableSkinColors" || got.Mode != "packed_hex_list" || got.Min != 0 || got.Max != 175 || got.HexWidth != 2 { t.Fatalf("unexpected topdata value encoding: %#v", got) } } func TestEffectiveConfigIncludesTopDataValueDefaults(t *testing.T) { root := t.TempDir() writeProjectFile(t, filepath.Join(root, ConfigFile), ` module: name: Test Module resref: testmod topdata: source: topdata value_defaults: - dataset: racialtypes/core column: ECL value: 0 `) proj, err := Load(root) if err != nil { t.Fatalf("Load returned error: %v", err) } defaults := proj.EffectiveConfig().TopData.ValueDefaults if len(defaults) != 1 { t.Fatalf("expected one topdata value default, got %#v", defaults) } got := defaults[0] if got.Dataset != "racialtypes/core" || got.Column != "ECL" || got.Value != 0 { t.Fatalf("unexpected topdata value default: %#v", got) } } func TestEffectiveConfigIncludesTopDataRowGeneration(t *testing.T) { root := t.TempDir() writeProjectFile(t, filepath.Join(root, ConfigFile), ` module: name: Test Module resref: testmod topdata: source: topdata row_generation: - namespace: portraits mode: first_null_row minimum_row: 15000 `) proj, err := Load(root) if err != nil { t.Fatalf("Load returned error: %v", err) } rules := proj.EffectiveConfig().TopData.RowGeneration if len(rules) != 1 { t.Fatalf("expected one topdata row generation rule, got %#v", rules) } got := rules[0] if got.Dataset != "portraits" || got.Namespace != "portraits" || got.Mode != "first_null_row" || got.MinimumRow != 15000 { t.Fatalf("unexpected topdata row generation rule: %#v", got) } } func TestEffectiveConfigIncludesTopDataRowExtensions(t *testing.T) { root := t.TempDir() writeProjectFile(t, filepath.Join(root, ConfigFile), ` module: name: Test Module resref: testmod topdata: source: topdata row_extensions: - dataset: classes/spellsgained/* mode: repeat_last level_column: Level target_level: 60 `) proj, err := Load(root) if err != nil { t.Fatalf("Load returned error: %v", err) } rules := proj.EffectiveConfig().TopData.RowExtensions if len(rules) != 1 { t.Fatalf("expected one topdata row extension rule, got %#v", rules) } got := rules[0] if got.Dataset != "classes/spellsgained/*" || got.Mode != "repeat_last" || got.LevelColumn != "Level" || got.TargetLevel != 60 { t.Fatalf("unexpected topdata row extension rule: %#v", got) } } func TestEffectiveConfigIncludesTopDataClassFeatInjections(t *testing.T) { root := t.TempDir() writeProjectFile(t, filepath.Join(root, ConfigFile), ` module: name: Test Module resref: testmod topdata: source: topdata class_feat_injections: global_feats: - feat: feat:literate list: 3 granted_on_level: 1 on_menu: 0 unless_present: [feat:illiterate] class_skill_masterfeats: - masterfeat: masterfeats:skillfocus list: 1 granted_on_level: -1 on_menu: 0 `) proj, err := Load(root) if err != nil { t.Fatalf("Load returned error: %v", err) } injections := proj.EffectiveConfig().TopData.ClassFeatInjections if len(injections.GlobalFeats) != 1 || len(injections.ClassSkillMasterfeats) != 1 { t.Fatalf("expected configured class feat injections, got %#v", injections) } if got := injections.GlobalFeats[0]; got.Feat != "feat:literate" || got.List != "3" || got.GrantedOnLevel != "1" || got.OnMenu != "0" || len(got.UnlessPresent) != 1 || got.UnlessPresent[0] != "feat:illiterate" { t.Fatalf("unexpected global class feat injection: %#v", got) } if got := injections.ClassSkillMasterfeats[0]; got.Masterfeat != "masterfeats:skillfocus" || got.List != "1" || got.GrantedOnLevel != "-1" || got.OnMenu != "0" { t.Fatalf("unexpected class skill masterfeat injection: %#v", got) } } func TestValidateLayoutRejectsInvalidTopDataValueEncodings(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "topdata")) mkdirAll(t, filepath.Join(root, "build")) proj := &Project{ Root: root, Config: Config{ Module: ModuleConfig{Name: "Test", ResRef: "test"}, Paths: PathConfig{Build: "build"}, TopData: TopDataConfig{ Source: "topdata", ValueEncodings: []TopDataValueEncodingConfig{ {Dataset: "racialtypes/core", Column: "AvailableSkinColors", Mode: "packed_hex_list", Min: 0, Max: 256, HexWidth: 2}, {Dataset: "racialtypes/core", Column: "AvailableSkinColors", Mode: "packed_hex_list", Min: 0, Max: 175, HexWidth: 2}, {Dataset: "", Column: "AvailableHeadsMale", Mode: "unknown", Min: -1, Max: 999, HexWidth: 0}, }, ValueDefaults: []TopDataValueDefaultConfig{ {Dataset: "racialtypes/core", Column: "ECL", Value: 0}, {Dataset: "racialtypes/core", Column: "ECL", Value: 1}, {Dataset: "", Column: "", Value: 0}, }, RowGeneration: []TopDataRowGenerationConfig{ {Dataset: "portraits", Mode: "first_null_row"}, {Dataset: "portraits", Mode: "after_base"}, {Namespace: "placeabletypes", Mode: "unsupported"}, {Mode: "first_null_row"}, {Dataset: "classes", Mode: "first_null_row", MinimumRow: -1}, }, RowExtensions: []TopDataRowExtensionConfig{ {Dataset: "classes/spellsgained/*", Mode: "repeat_last", LevelColumn: "Level", TargetLevel: 60}, {Dataset: "classes/spellsgained/*", Mode: "repeat_last", LevelColumn: "Level", TargetLevel: 60}, {Dataset: "", Mode: "unsupported", LevelColumn: "", TargetLevel: 0}, }, }, }, } err := proj.ValidateLayout() if err == nil { t.Fatal("expected invalid topdata value encoding validation error") } text := err.Error() for _, want := range []string{ "topdata.value_encodings[0].max does not fit in hex_width 2", "topdata.value_encodings[1] duplicates an earlier dataset/column encoding", "topdata.value_encodings[2].dataset is required", "topdata.value_encodings[2].mode \"unknown\" is not supported", "topdata.value_encodings[2].min must be zero or greater", "topdata.value_encodings[2].hex_width must be greater than zero", "topdata.value_defaults[1] duplicates an earlier dataset/column default", "topdata.value_defaults[2].dataset is required", "topdata.value_defaults[2].column is required", "topdata.row_generation[1] duplicates an earlier dataset row generation rule", "topdata.row_generation[2].mode \"unsupported\" is not supported", "topdata.row_generation[3].dataset is required", "topdata.row_generation[4].minimum_row must be zero or greater", "topdata.row_extensions[1] duplicates an earlier dataset row extension rule", "topdata.row_extensions[2].dataset is required", "topdata.row_extensions[2].mode \"unsupported\" is not supported", "topdata.row_extensions[2].level_column is required", "topdata.row_extensions[2].target_level must be greater than zero", } { if !strings.Contains(text, want) { t.Fatalf("expected validation error %q, got %v", want, err) } } } func TestValidateLayoutRejectsInvalidTopDataClassFeatInjections(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "topdata")) mkdirAll(t, filepath.Join(root, "build")) proj := &Project{ Root: root, Config: Config{ Module: ModuleConfig{Name: "Test", ResRef: "test"}, Paths: PathConfig{Build: "build"}, TopData: TopDataConfig{ Source: "topdata", ClassFeatInjections: TopDataClassFeatInjectionConfig{ GlobalFeats: []TopDataClassFeatGlobalRule{ {Feat: "literate", List: "3", GrantedOnLevel: "1", OnMenu: "0", UnlessPresent: []string{"illiterate"}}, {Feat: "feat:valid", List: "", GrantedOnLevel: "x", OnMenu: "0"}, }, ClassSkillMasterfeats: []TopDataClassFeatMasterfeatRule{ {Masterfeat: "skillfocus", List: "1", GrantedOnLevel: "-1", OnMenu: ""}, }, }, }, }, } err := proj.ValidateLayout() if err == nil { t.Fatal("expected invalid class feat injection validation error") } text := err.Error() for _, want := range []string{ "topdata.class_feat_injections.global_feats[0].feat", "topdata.class_feat_injections.global_feats[0].unless_present[0]", "topdata.class_feat_injections.global_feats[1].list", "topdata.class_feat_injections.global_feats[1].granted_on_level", "topdata.class_feat_injections.class_skill_masterfeats[0].masterfeat", "topdata.class_feat_injections.class_skill_masterfeats[0].on_menu", } { if !strings.Contains(text, want) { t.Fatalf("expected validation error %q, got %v", want, err) } } } func TestDefaultAssetExtensionsKeepAuthoredBMUWithoutConversionSources(t *testing.T) { for _, extension := range []string{".bmu", ".wav"} { if !slices.Contains(AssetExtensions, extension) { t.Errorf("default asset extensions must retain %s", extension) } } for _, extension := range []string{".mp3", ".ogg"} { if slices.Contains(AssetExtensions, extension) { t.Errorf("default asset extensions must not include conversion source %s", extension) } } } func TestLoadRejectsRemovedMusicConfig(t *testing.T) { root := t.TempDir() writeProjectFile(t, filepath.Join(root, ConfigFile), ` module: name: Test Module resref: testmod music: datasets: {} `) if _, err := Load(root); err == nil || !strings.Contains(err.Error(), "music") { t.Fatalf("expected strict YAML decoding to reject removed music config, got %v", err) } } 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 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 page_index_file: manifest/page-index.json source: docs-src/wiki data_file: providers/wiki-data.yaml page_paths_file: paths/wiki-paths.yaml page_templates_dir: page-fragments manual_sections_file: sections/defaults.yaml 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.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.TopDataWikiPageIndexPath(), filepath.Join(root, "cache", "docs", "manifest", "page-index.json"); got != want { t.Fatalf("expected wiki page-index path %s, got %s", want, got) } if got, want := proj.TopDataWikiDataPath(), filepath.Join(root, "docs-src", "wiki", "providers", "wiki-data.yaml"); got != want { t.Fatalf("expected wiki data path %s, got %s", want, got) } if got, want := proj.TopDataWikiPagePathsPath(), filepath.Join(root, "docs-src", "wiki", "paths", "wiki-paths.yaml"); got != want { t.Fatalf("expected wiki page paths path %s, got %s", want, got) } if got, want := proj.TopDataWikiPageTemplatesDir(), filepath.Join(root, "docs-src", "wiki", "page-fragments"); got != want { t.Fatalf("expected wiki page template path %s, got %s", want, got) } if got, want := proj.TopDataWikiManualSectionsPath(), filepath.Join(root, "docs-src", "wiki", "sections", "defaults.yaml"); got != want { t.Fatalf("expected wiki manual sections 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 TestEffectiveConfigHonorsConfiguredExtractWikiAndAutogenPolicy(t *testing.T) { root := t.TempDir() writeProjectFile(t, filepath.Join(root, ConfigFile), ` module: name: Test Module resref: testmod paths: source: src tools: custom-tools 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_pages: false 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 := 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 effective.TopData.Wiki.StatusPages == nil || *effective.TopData.Wiki.StatusPages { t.Fatalf("expected wiki status pages to be disabled, got %#v", effective.TopData.Wiki.StatusPages) } 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(`"name": "Test Module"`)) { t.Fatalf("effective config JSON missing configured module name: %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) } } // paths.build is an OUTPUT dir the builder creates (MkdirAll) before writing, so // a bare clone with no build dir yet must still validate/build with no pre-step // (R2/parity). Only a build path that exists but is not a directory is an error. func TestValidateLayoutAllowsMissingBuildDir(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "src")) // deliberately do NOT create build/ proj := &Project{ Root: root, Config: Config{ Module: ModuleConfig{Name: "Test", ResRef: "test"}, Paths: PathConfig{Source: "src", Build: "build"}, }, } if err := proj.ValidateLayout(); err != nil { t.Fatalf("ValidateLayout returned error for missing build dir: %v", err) } if _, err := os.Stat(filepath.Join(root, "build")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("expected missing build dir to remain absent, got err=%v", err) } } // A build path that exists but is a regular file is still a hard error. func TestValidateLayoutRejectsBuildDirThatIsAFile(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "src")) if err := os.WriteFile(filepath.Join(root, "build"), []byte("i am a file, not a dir"), 0o644); err != nil { t.Fatal(err) } proj := &Project{ Root: root, Config: Config{ Module: ModuleConfig{Name: "Test", ResRef: "test"}, Paths: PathConfig{Source: "src", Build: "build"}, }, } err := proj.ValidateLayout() if err == nil || !strings.Contains(err.Error(), "paths.build must be a directory") { t.Fatalf("expected paths.build-must-be-a-directory error, got %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: ".", DataFile: "../data.yaml", PagePathsFile: "../wiki.yaml", PageTemplatesDir: "../templates/pages", ManualSectionsFile: "../manual/default.yaml", PageIndexFile: "../page-index.json", ManualSectionsDir: "../manual", AlignmentLinks: TopDataWikiAlignmentLinks{ LinkFormat: "raw_url", }, 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.data_file", "topdata.wiki.page_paths_file", "topdata.wiki.page_templates_dir", "topdata.wiki.manual_sections_file", "topdata.wiki.page_index_file", "topdata.wiki.manual_sections_dir", "topdata.wiki.alignment_links.link_format", "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: "accessory_visualeffects", Producer: "accessory_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-accessory-vfx-manifest.json", CacheName: "sow-accessory-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 TestValidateLayoutRejectsInvalidAccessoryVisualeffectsConfig(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: "accessory_visualeffects", Producer: "accessory_visualeffects", Dataset: "visualeffects", Mode: "accessory_visualeffects", Root: "vfxs", Include: []string{"head_jewels/**/*.mdl"}, Derive: AutogenDeriveConfig{Kind: "model_stem", GroupFrom: "first_path_segment"}, Manifest: AutogenManifestConfig{ ReleaseTag: "head-vfx-manifest-current", AssetName: "sow-accessory-vfx-manifest.json", CacheName: "sow-accessory-vfx-manifest.json", }, AccessoryVisualeffects: AccessoryVisualeffectsConfig{ Groups: map[string]AccessoryVisualeffectGroupConfig{ "head_jewels": { ModelColumns: []string{"Imp Root M Node"}, }, }, StripModelPrefixes: []string{""}, ModelColumn: "Imp HeadCon Node", }, }, }, }, }, } err := proj.ValidateLayout() if err == nil { t.Fatal("expected accessory visualeffects validation error") } for _, needle := range []string{ "autogen.consumers[0].accessory_visualeffects.groups[head_jewels].prefix", "autogen.consumers[0].accessory_visualeffects.groups[head_jewels].model_columns[0]", "autogen.consumers[0].accessory_visualeffects.strip_model_prefixes[0]", "autogen.consumers[0].accessory_visualeffects.model_column", } { if !strings.Contains(err.Error(), needle) { t.Fatalf("expected validation error to mention %s, got %v", needle, err) } } } func TestValidateLayoutRejectsInvalidAccessoryVisualeffectsNamingConfig(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: "accessory_visualeffects", Producer: "accessory_visualeffects", Dataset: "visualeffects", Mode: "accessory_visualeffects", Root: "vfxs", Include: []string{"head_features/**/*.mdl"}, Derive: AutogenDeriveConfig{Kind: "model_stem", GroupFrom: "first_path_segment"}, Manifest: AutogenManifestConfig{ ReleaseTag: "head-vfx-manifest-current", AssetName: "sow-accessory-vfx-manifest.json", CacheName: "sow-accessory-vfx-manifest.json", }, AccessoryVisualeffects: AccessoryVisualeffectsConfig{ Groups: map[string]AccessoryVisualeffectGroupConfig{ "head_features": {}, }, GroupTokenSource: "unsupported", CategoryFrom: "unsupported", Delimiter: " ", Case: "unsupported", }, }, }, }, }, } err := proj.ValidateLayout() if err == nil { t.Fatal("expected accessory visualeffects naming validation error") } for _, needle := range []string{ "autogen.consumers[0].accessory_visualeffects.group_token_source", "autogen.consumers[0].accessory_visualeffects.category_from", "autogen.consumers[0].accessory_visualeffects.delimiter", "autogen.consumers[0].accessory_visualeffects.case", } { if !strings.Contains(err.Error(), needle) { t.Fatalf("expected validation error to mention %s, got %v", needle, err) } } } func TestValidateLayoutAcceptsCDNChannelConsumerWithoutReleasedSourceFields(t *testing.T) { // cdn_channel consumers resolve entirely from Source.* at runtime; they must // not be required to supply root/include/derive/manifest fields. 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: "accessory_visualeffects", Producer: "accessory_visualeffects", Dataset: "visualeffects", Mode: "accessory_visualeffects", Optional: true, AccessoryVisualeffects: AccessoryVisualeffectsConfig{ Groups: map[string]AccessoryVisualeffectGroupConfig{ "head_accessories": {Prefix: "head_acc_"}, "chest_accessories": {Prefix: "chest_acc_"}, "head_decorations": {Prefix: "head_dec_"}, "head_features": {Prefix: "head_feat_"}, }, }, Source: AutogenSourceConfig{ Kind: "cdn_channel", ChannelsPath: "releases/haks/channels.json", ManifestPath: "releases/haks/{tag}/vfxs.yml", }, // Intentionally omitted: Root, Include, Derive, Manifest }, }, }, }, } if err := proj.ValidateLayout(); err != nil { t.Fatalf("cdn_channel consumer should pass ValidateLayout without released-source fields, got: %v", err) } } func TestLoadRejectsLegacyAccessoryVisualeffectsNamingFields(t *testing.T) { root := t.TempDir() writeProjectFile(t, filepath.Join(root, ConfigFile), ` module: name: Test Module resref: testmod paths: source: src autogen: consumers: - id: accessory_visualeffects producer: accessory_visualeffects dataset: visualeffects mode: accessory_visualeffects root: vfxs include: - head_features/**/*.mdl derive: kind: model_stem group_from: first_path_segment accessory_visualeffects: groups: head_features: model_columns: - Imp_HeadCon_Node group_token_source: folder_name category_from: immediate_parent legacy_groups: head_features: head_features legacy_key_format: "{dataset}:{group}_{stem}" `) _, err := Load(root) if err == nil { t.Fatal("expected legacy accessory visualeffects naming fields to be rejected") } if !strings.Contains(err.Error(), "legacy_groups") { t.Fatalf("expected legacy_groups unknown field 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"}, }, }, { ID: "static_topdata", Source: "topdata", Output: "{paths.cache}/generated-assets/static-topdata-2da", IncludeDatasets: []string{"tailmodel", "wingmodel"}, PackageRoot: "part", }, }, }, }, } 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 TestValidateLayoutRejectsUnsupportedGeneratedTopDataAutogenMode(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: "bad", Source: "topdata", Output: "{paths.cache}/generated-assets/bad-2da", IncludeDatasets: []string{"tailmodel"}, PackageRoot: "part", Autogen: AutogenConsumerConfig{ Mode: "unknown_rows", Root: "part", Include: []string{"**/*.mdl"}, Derive: AutogenDeriveConfig{Kind: "model_stem", GroupFrom: "first_path_segment"}, }, }, }, }, }, } err := proj.ValidateLayout() if err == nil || !strings.Contains(err.Error(), `generated_assets.topdata_2da[0].autogen.mode "unknown_rows" is not supported`) { t.Fatalf("expected unsupported generated autogen mode validation error, got %v", err) } } 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 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 TestValidateAutogenConsumerSourcesCDNChannel(t *testing.T) { cases := []struct { name string source AutogenSourceConfig wantFail bool }{ {"valid", AutogenSourceConfig{Kind: "cdn_channel", ChannelsPath: "releases/haks/channels.json", ManifestPath: "releases/haks/{tag}/vfxs.yml"}, false}, {"missing channels_path", AutogenSourceConfig{Kind: "cdn_channel", ManifestPath: "releases/haks/{tag}/vfxs.yml"}, true}, {"missing manifest_path", AutogenSourceConfig{Kind: "cdn_channel", ChannelsPath: "releases/haks/channels.json"}, true}, {"manifest_path lacks {tag}", AutogenSourceConfig{Kind: "cdn_channel", ChannelsPath: "releases/haks/channels.json", ManifestPath: "releases/haks/vfxs.yml"}, true}, {"empty kind is ignored", AutogenSourceConfig{}, false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { failures := validateAutogenConsumerSources([]AutogenConsumerConfig{{ID: "x", Source: tc.source}}) if tc.wantFail && len(failures) == 0 { t.Fatalf("expected validation failure, got none") } if !tc.wantFail && len(failures) != 0 { t.Fatalf("expected no failure, got %v", failures) } }) } } func TestValidateRejectsMultiplePartsRowsConsumers(t *testing.T) { consumers := []AutogenConsumerConfig{ {ID: "parts-a", Mode: "parts_rows"}, {ID: "parts-b", Mode: "parts_rows"}, } if failures := validateAutogenConsumerSources(consumers); len(failures) == 0 { t.Fatal("expected error for two parts_rows consumers") } single := []AutogenConsumerConfig{{ID: "parts", Mode: "parts_rows"}} if failures := validateAutogenConsumerSources(single); len(failures) != 0 { t.Fatalf("single parts_rows consumer must validate, got %v", failures) } } 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) } }