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, "
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, "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 TestWikiManagedHashIgnoresPreservedSectionBody(t *testing.T) { first := strings.Join([]string{ ``, ``, `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, `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) } }