package topdata import ( "encoding/json" "os" "path/filepath" "strings" "testing" "time" ) func TestBuildNativeGeneratesAndSkipsWikiPages(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "skills")) writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{ "output": "skills.2da", "columns": ["Label", "Name", "Description", "HideFromLevelUp", "Untrained", "KeyAbility", "ArmorCheckPenalty", "Constant"], "rows": [ { "id": 0, "key": "skills:athletics", "Label": "Athletics", "Name": {"tlk": {"key": "skills:athletics.name", "text": "Athletics"}}, "Description": {"tlk": {"key": "skills:athletics.description", "text": "Ability: Strength.\n\nUses:\n- Climb\n- Swim"}}, "HideFromLevelUp": "0", "Untrained": "1", "KeyAbility": "STR", "ArmorCheckPenalty": "1", "Constant": "SKILL_ATHLETICS" } ] }`+"\n") writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:athletics":0}`+"\n") proj := testProject(root) proj.Config.TopData.ReferenceBuilder = "" result, err := BuildNative(proj, nil) if err != nil { t.Fatalf("BuildNative failed: %v", err) } generatedHTMLCount := countGeneratedWikiHTMLFiles(t, filepath.Join(root, ".cache", "wiki", "pages")) if result.WikiPages != generatedHTMLCount { t.Fatalf("expected wiki page count to match generated HTML files, got result=%d files=%d", result.WikiPages, generatedHTMLCount) } pagePath := filepath.Join(root, ".cache", "wiki", "pages", "skills", "athletics.html") got, err := os.ReadFile(pagePath) if err != nil { t.Fatalf("read generated page: %v", err) } text := string(got) if !strings.Contains(text, "

Athletics

") || !strings.Contains(text, `class="wiki-callout wiki-callout--status"`) || !strings.Contains(text, "This skill is unchanged from vanilla.") { t.Fatalf("unexpected generated page:\n%s", text) } if !strings.Contains(text, ``) || !strings.Contains(text, ``) || !strings.Contains(text, "

See Also

") { t.Fatalf("expected generated page to include manual section placeholders:\n%s", text) } statusPath := filepath.Join(root, ".cache", "wiki", "pages", "meta", "wikistatus.html") statusRaw, err := os.ReadFile(statusPath) if err != nil { t.Fatalf("read status page: %v", err) } if !strings.Contains(string(statusRaw), "Vanilla1") { t.Fatalf("expected wiki status summary to count vanilla page:\n%s", string(statusRaw)) } indexRaw, err := os.ReadFile(filepath.Join(root, ".cache", "wiki", "page-index.json")) if err != nil { t.Fatalf("read page index: %v", err) } var pageIndex wikiPageIndex if err := json.Unmarshal(indexRaw, &pageIndex); err != nil { t.Fatalf("parse page index: %v", err) } if len(pageIndex.Pages) != generatedHTMLCount { t.Fatalf("expected page-index entries to match generated HTML files, got index=%d files=%d", len(pageIndex.Pages), generatedHTMLCount) } indexText := string(indexRaw) if !strings.Contains(indexText, `"page_id": "skills:athletics"`) || !strings.Contains(indexText, `"output_path": "pages/skills/athletics.html"`) || !strings.Contains(indexText, `"edit_policy": "preserve_manual_sections"`) { t.Fatalf("expected deterministic page-index metadata, got:\n%s", indexText) } if !strings.Contains(indexText, `"page_id": "meta:wikistatus"`) || !strings.Contains(indexText, `"output_path": "pages/meta/wikistatus.html"`) || !strings.Contains(indexText, `"edit_policy": "generated_only"`) { t.Fatalf("expected status pages in page-index metadata, got:\n%s", indexText) } if _, err := os.Stat(filepath.Join(root, ".cache", "wiki", "state.json")); err != nil { t.Fatalf("expected state file after first build: %v", err) } stateBefore, err := loadWikiState(filepath.Join(root, ".cache", "wiki", "state.json")) if err != nil { t.Fatalf("read state before second build: %v", err) } digestBefore, err := computeWikiSourceDigest(filepath.Join(root, "topdata")) if err != nil { t.Fatalf("compute digest before second build: %v", err) } if stateBefore.Digest != digestBefore { t.Fatalf("expected state digest to match current digest before second build, got state=%s current=%s", stateBefore.Digest, digestBefore) } second, err := BuildNative(proj, nil) if err != nil { t.Fatalf("second BuildNative failed: %v", err) } if second.WikiPages != generatedHTMLCount { t.Fatalf("expected generated wiki page count after rebuild to remain stable, got %d want %d", second.WikiPages, generatedHTMLCount) } } func countGeneratedWikiHTMLFiles(t *testing.T, root string) int { t.Helper() count := 0 err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { if err != nil { return err } if !d.IsDir() && strings.EqualFold(filepath.Ext(path), ".html") { count++ } return nil }) if err != nil { t.Fatalf("count generated wiki HTML files: %v", err) } return count } func TestWikiClassNamePrefersFullNameInsteadOfShortCode(t *testing.T) { ctx := &wikiContext{} got := ctx.resolveRowName("classes", map[string]any{ "Name": map[string]any{"tlk": map[string]any{"key": "classes:wizard.name", "text": "Wizard"}}, "Short": map[string]any{"tlk": map[string]any{"key": "classes:wizard.short", "text": "Wiz"}}, }) if got != "Wizard" { t.Fatalf("expected full class name, got %q", got) } } func TestWikiTemplateRendersFieldsFormattersAndPreservedTopSection(t *testing.T) { ctx := &wikiContext{} page := wikiTemplatePage{ PageID: "classes:acolyte", Category: "classes", Key: "classes:acolyte", Title: "Acolyte", Status: wikiStatusNew, Row: map[string]any{ "HitDie": 6, "SkillPointBase": 4, "Description": map[string]any{"tlk": map[string]any{ "text": "Acolyte description.", }}, }, ManualSections: []wikiManualSection{ {ID: "user_top", Alias: "UserTopSection", InitialHTML: "

"}, {ID: "notes", Alias: "UserBottomSection", InitialHTML: "

Notes

"}, }, } got, err := ctx.renderWikiTemplateString("classes.html", `

{{title}}

{{UserTopSection}}

Hit Die: {{HitDie}}

Skill Points: {{SkillPointBase}}

{{format:StatusCallout}} {{format:Description}} {{UserBottomSection}}`, page) if err != nil { t.Fatalf("render template: %v", err) } if !strings.Contains(got, "

Acolyte

\n") { t.Fatalf("expected preserved top section directly after title, got:\n%s", got) } for _, expected := range []string{ "

Hit Die: 6

", "

Skill Points: 4

", "This is a new class!", "

Acolyte description.

", ``, } { if !strings.Contains(got, expected) { t.Fatalf("expected %q in rendered template:\n%s", expected, got) } } } func TestWikiTemplateMissingFieldFailsUnlessDefaultProvided(t *testing.T) { ctx := &wikiContext{} page := wikiTemplatePage{PageID: "classes:acolyte", Category: "classes", Title: "Acolyte", Row: map[string]any{}} if _, err := ctx.renderWikiTemplateString("classes.html", `

{{HitDie}}

`, page); err == nil || !strings.Contains(err.Error(), `missing field "HitDie"`) { t.Fatalf("expected missing field error, got %v", err) } got, err := ctx.renderWikiTemplateString("classes.html", `

{{field:HitDie|default=unknown}}

`, page) if err != nil { t.Fatalf("expected defaulted field to render, got %v", err) } if got != "

unknown

" { t.Fatalf("expected default value, got %q", got) } } func TestWikiTemplateRendersConfiguredClassProgressionTable(t *testing.T) { ctx := testWikiTableContext(t, ` tables: ClassProgression: source: class_progression class: "wiki-table wiki-class-progression" rows: levels: 1-4 when: "{{Level >= 1 && Level <= 4}}" columns: - key: level label: Lvl value: "{{ordinal(Level)}}" - key: bab label: BAB value: "{{BAB}}" - key: saves label: Saves children: - key: fort label: Fort value: "{{FortSave}}" - key: reflex label: Ref value: "{{RefSave}}" - key: will label: Will value: "{{WillSave}}" - key: feats label: Feats value: "{{join(GrantedFeats, \", \")}}" empty: "" allow_wiki_markup: true - key: hp_range label: HP range value: "{{range(HPMin, HPMax)}}" - key: notes label: Notes value: "{{if(Level == 4 && BonusFeat != \"0\", link(\"feat:weapon_specialization\", \"Weapon Specialization\") + \" first available.\", Notes)}}" empty: "" allow_wiki_markup: true `) page := wikiTemplatePage{ PageID: "classes:fighter", Category: "classes", Key: "classes:fighter", Title: "Fighter", Row: map[string]any{ "AttackBonusTable": "cls_atk_1", "BonusFeatsTable": map[string]any{"table": "classes/bonusfeats:fighter"}, "FeatsTable": map[string]any{"table": "classes/feats:fighter"}, "HitDie": 10, "SavingThrowTable": map[string]any{"table": "classes/saves:fortitude"}, }, } got, err := ctx.renderWikiTemplateString("classes.html", `

{{title}}

{{table:ClassProgression}}`, page) if err != nil { t.Fatalf("render table template: %v", err) } for _, expected := range []string{ ``, ``, ``, ``, ``, ``, ``, ``, ``, } { if !strings.Contains(got, expected) { t.Fatalf("expected %q in rendered table:\n%s", expected, got) } } } func TestWikiTableMissingFieldReportsTableAndColumn(t *testing.T) { ctx := testWikiTableContext(t, ` tables: Broken: source: class_progression rows: levels: 1 columns: - key: missing label: Missing value: "{{NoSuchField}}" `) page := wikiTemplatePage{ PageID: "classes:fighter", Category: "classes", Key: "classes:fighter", Row: map[string]any{ "AttackBonusTable": "cls_atk_1", "HitDie": 10, "SavingThrowTable": map[string]any{"table": "classes/saves:fortitude"}, }, } _, err := ctx.renderWikiTemplateString("classes.html", `{{table:Broken}}`, page) if err == nil { t.Fatal("expected missing field error") } for _, expected := range []string{`classes:fighter`, `table Broken`, `column missing`, `NoSuchField`} { if !strings.Contains(err.Error(), expected) { t.Fatalf("expected error to contain %q, got %v", expected, err) } } } func TestWikiTableRejectsImpossibleNestedHeader(t *testing.T) { _, err := parseWikiTableDefinitions([]byte(` tables: Broken: source: class_progression rows: levels: 1 columns: - key: bad label: Bad value: "{{Level}}" children: - key: child label: Child value: "{{Level}}" `), "test-tables.yaml") if err == nil || !strings.Contains(err.Error(), "cannot have both value and children") { t.Fatalf("expected impossible nested header error, got %v", err) } } func testWikiTableContext(t *testing.T, yamlSource string) *wikiContext { t.Helper() ctx := &wikiContext{ featRows: map[string]map[string]any{}, featIDToKey: map[int]string{}, classFeatTables: map[string]wikiTable{}, classBonusTables: map[string]wikiTable{}, classSaveTables: map[string]wikiTable{}, wikiTablesPath: "test-tables.yaml", } ctx.featRows["feat:literate"] = map[string]any{"FEAT": map[string]any{"tlk": map[string]any{"text": "Literate"}}} ctx.featRows["feat:weapon_proficiency_simple"] = map[string]any{"FEAT": map[string]any{"tlk": map[string]any{"text": "Simple Weapon Proficiency"}}} ctx.featRows["feat:weapon_specialization"] = map[string]any{"FEAT": map[string]any{"tlk": map[string]any{"text": "Weapon Specialization"}}} ctx.classFeatTables["cls_feat_fight"] = wikiTable{OutputStem: "cls_feat_fight", Rows: []map[string]any{ {"FeatIndex": map[string]any{"id": "feat:literate"}, "GrantedOnLevel": "1", "List": "3"}, {"FeatIndex": map[string]any{"id": "feat:weapon_proficiency_simple"}, "GrantedOnLevel": "1", "List": "3"}, }} ctx.classFeatTables["classes/feats:fighter"] = ctx.classFeatTables["cls_feat_fight"] ctx.classBonusTables["cls_bfeat_fight"] = wikiTable{OutputStem: "cls_bfeat_fight", Rows: []map[string]any{ {"id": 1, "Bonus": "1"}, {"id": 2, "Bonus": "1"}, {"id": 3, "Bonus": "0"}, {"id": 4, "Bonus": "1"}, }} ctx.classBonusTables["classes/bonusfeats:fighter"] = ctx.classBonusTables["cls_bfeat_fight"] ctx.classSaveTables["cls_savthr_fort"] = wikiTable{OutputStem: "cls_savthr_fort", Rows: []map[string]any{ {"Level": "1", "FortSave": "2", "RefSave": "0", "WillSave": "0"}, {"Level": "2", "FortSave": "3", "RefSave": "0", "WillSave": "0"}, {"Level": "3", "FortSave": "3", "RefSave": "1", "WillSave": "1"}, {"Level": "4", "FortSave": "4", "RefSave": "1", "WillSave": "1"}, }} ctx.classSaveTables["classes/saves:fortitude"] = ctx.classSaveTables["cls_savthr_fort"] tables, err := parseWikiTableDefinitions([]byte(yamlSource), ctx.wikiTablesPath) if err != nil { t.Fatalf("parse table definitions: %v", err) } ctx.wikiTableDefinitions = tables return ctx } func TestWikiManagedHashIgnoresPreservedSectionBody(t *testing.T) { first := strings.Join([]string{ ``, ``, `

Acolyte

`, ``, `

Original user edit.

`, ``, `

Generated.

`, ``, }, "\n") second := strings.Replace(first, "

Original user edit.

", "

Changed by a user.

", 1) if computeManagedHash(first) != computeManagedHash(second) { t.Fatalf("expected preserved section body not to affect managed hash") } } func TestWikiSourceDigestIncludesTemplates(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "data", "skills")) mkdirAll(t, filepath.Join(root, "wiki", "templates", "pages")) writeFile(t, filepath.Join(root, "data", "skills", "base.json"), `{"rows":[]}`+"\n") templatePath := filepath.Join(root, "wiki", "templates", "pages", "skills.html") writeFile(t, templatePath, `

{{title}}

`+"\n") before, err := computeWikiSourceDigest(root) if err != nil { t.Fatalf("compute initial digest: %v", err) } writeFile(t, templatePath, `

{{title}}

Changed

`+"\n") after, err := computeWikiSourceDigest(root) if err != nil { t.Fatalf("compute changed digest: %v", err) } if before == after { t.Fatalf("expected template edit to change wiki source digest") } } func TestWikiPageIDForKeyNormalizesSlashAndUnderscoreSeparators(t *testing.T) { got := wikiPageIDForKey("feat:special/attacks_bull_rush") if want := "feat:special:attacks:bull:rush"; got != want { t.Fatalf("expected normalized page ID %q, got %q", want, got) } } func TestSaveWikiPageIndexRejectsDuplicateOutputPaths(t *testing.T) { err := saveWikiPageIndex(filepath.Join(t.TempDir(), "page-index.json"), wikiPageIndex{ Version: wikiGeneratorVersion, Pages: []wikiPageIndexEntry{ {PageID: "feat:special:attacks:bull:rush", OutputPath: "pages/feat/special/attacks/bull/rush.html"}, {PageID: "feat:special/attacks:bull:rush", OutputPath: "pages/feat/special/attacks/bull/rush.html"}, }, }) if err == nil || !strings.Contains(err.Error(), "duplicate wiki page-index output_path") { t.Fatalf("expected duplicate output_path validation error, got %v", err) } } func TestBuildAndPackageBuildsWikiOnlyWhenRequested(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "skills")) writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{ "output": "skills.2da", "columns": ["Label", "Name", "Description", "HideFromLevelUp", "Untrained", "KeyAbility", "ArmorCheckPenalty", "Constant"], "rows": [ { "id": 0, "key": "skills:athletics", "Label": "Athletics", "Name": {"tlk": {"key": "skills:athletics.name", "text": "Athletics"}}, "Description": {"tlk": {"key": "skills:athletics.description", "text": "Skill text."}}, "HideFromLevelUp": "0", "Untrained": "1", "KeyAbility": "STR", "ArmorCheckPenalty": "1", "Constant": "SKILL_ATHLETICS" } ] }`+"\n") writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:athletics":0}`+"\n") proj := testProject(root) proj.Config.TopData.ReferenceBuilder = "" result, err := BuildAndPackageWithOptions(proj, BuildAndPackageOptions{}, nil) if err != nil { t.Fatalf("BuildAndPackageWithOptions failed: %v", err) } if result.WikiOutputDir != "" || result.WikiPages != 0 { t.Fatalf("expected wiki generation to be skipped by default, got dir=%q pages=%d", result.WikiOutputDir, result.WikiPages) } if _, err := os.Stat(filepath.Join(root, ".cache", "wiki", "state.json")); !os.IsNotExist(err) { t.Fatalf("expected no wiki state without explicit wiki build, got %v", err) } result, err = BuildAndPackageWithOptions(proj, BuildAndPackageOptions{Force: true, BuildWiki: true}, nil) if err != nil { t.Fatalf("BuildAndPackageWithOptions with wiki failed: %v", err) } generatedHTMLCount := countGeneratedWikiHTMLFiles(t, filepath.Join(root, ".cache", "wiki", "pages")) if result.WikiPages != generatedHTMLCount { t.Fatalf("expected wiki page count to match generated HTML files when requested, got result=%d files=%d", result.WikiPages, generatedHTMLCount) } if _, err := os.Stat(filepath.Join(root, ".cache", "wiki", "state.json")); err != nil { t.Fatalf("expected wiki state after explicit wiki build: %v", err) } } func TestBuildNativeRegeneratesWikiWhenTLKTextChanges(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "skills")) writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") basePath := filepath.Join(root, "topdata", "data", "skills", "base.json") writeFile(t, basePath, `{ "output": "skills.2da", "columns": ["Label", "Name", "Description", "HideFromLevelUp", "Untrained", "KeyAbility", "ArmorCheckPenalty", "Constant"], "rows": [ { "id": 0, "key": "skills:athletics", "Label": "Athletics", "Name": {"tlk": {"key": "skills:athletics.name", "text": "Athletics"}}, "Description": {"tlk": {"key": "skills:athletics.description", "text": "Original text."}}, "HideFromLevelUp": "0", "Untrained": "1", "KeyAbility": "STR", "ArmorCheckPenalty": "1", "Constant": "SKILL_ATHLETICS" } ] }`+"\n") writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:athletics":0}`+"\n") proj := testProject(root) proj.Config.TopData.ReferenceBuilder = "" proj.Config.Autogen.Consumers = nil if _, err := BuildNative(proj, nil); err != nil { t.Fatalf("initial BuildNative failed: %v", err) } writeFile(t, basePath, `{ "output": "skills.2da", "columns": ["Label", "Name", "Description", "HideFromLevelUp", "Untrained", "KeyAbility", "ArmorCheckPenalty", "Constant"], "rows": [ { "id": 0, "key": "skills:athletics", "Label": "Athletics", "Name": {"tlk": {"key": "skills:athletics.name", "text": "Athletics"}}, "Description": {"tlk": {"key": "skills:athletics.description", "text": "Updated text."}}, "HideFromLevelUp": "0", "Untrained": "1", "KeyAbility": "STR", "ArmorCheckPenalty": "1", "Constant": "SKILL_ATHLETICS" } ] }`+"\n") if _, err := BuildNative(proj, nil); err != nil { t.Fatalf("BuildNative after text change failed: %v", err) } pagePath := filepath.Join(root, ".cache", "wiki", "pages", "skills", "athletics.html") got, err := os.ReadFile(pagePath) if err != nil { t.Fatalf("read regenerated page: %v", err) } if !strings.Contains(string(got), "Updated text.") { t.Fatalf("expected regenerated page to contain updated description:\n%s", string(got)) } } func TestBuildPackageIgnoresWikiSourcesForFreshness(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "skills")) writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{ "output": "skills.2da", "columns": ["Label", "Name", "Description", "HideFromLevelUp", "Untrained", "KeyAbility", "ArmorCheckPenalty", "Constant"], "rows": [ { "id": 0, "key": "skills:athletics", "Label": "Athletics", "Name": {"tlk": {"key": "skills:athletics.name", "text": "Athletics"}}, "Description": {"tlk": {"key": "skills:athletics.description", "text": "Skill text."}}, "HideFromLevelUp": "0", "Untrained": "1", "KeyAbility": "STR", "ArmorCheckPenalty": "1", "Constant": "SKILL_ATHLETICS" } ] }`+"\n") writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:athletics":0}`+"\n") proj := testProject(root) proj.Config.TopData.ReferenceBuilder = "" proj.Config.Autogen.Consumers = nil if _, err := BuildNative(proj, nil); err != nil { t.Fatalf("BuildNative failed: %v", err) } pagePath := filepath.Join(root, ".cache", "wiki", "pages", "skills", "athletics.html") newTime := time.Now().Add(2 * time.Second) if err := os.Chtimes(pagePath, newTime, newTime); err != nil { t.Fatalf("touch wiki page: %v", err) } if _, err := BuildPackage(proj, nil); err != nil { t.Fatalf("expected wiki output to be ignored for freshness, got %v", err) } }
LvlSaves1st+12[[feat:literate|Literate]], [[feat:weapon:proficiency:simple|Simple Weapon Proficiency]]1-10[[feat:weapon:specialization|Weapon Specialization]] first available.