Files
sow-tools/internal/project/project_test.go
T
archvillainette 5559479755
build-binaries / build-binaries (pull_request) Successful in 2m18s
test-image / build-image (pull_request) Successful in 51s
test / test (pull_request) Successful in 1m30s
paths.build fix
2026-06-21 20:28:44 +02:00

1929 lines
58 KiB
Go

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 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
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 == "<set>" {
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 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 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 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)
}
}