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, `wiki_slug=`) || !strings.Contains(text, ``) { t.Fatalf("expected generated page to include user_bottom manual section placeholder:\n%s", text) } if strings.Contains(text, `manual:start id="notes"`) || strings.Contains(text, `manual:start id="see_also"`) || strings.Contains(text, "

See Also

") { t.Fatalf("expected generated page to omit legacy split bottom manual sections:\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, `"public_path": "Skills/Athletics"`) || strings.Contains(indexText, `"public_slug"`) || !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 TestBuildWikiRegeneratesMissingCachedPages(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."}}, "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 = "" if _, err := BuildNative(proj, nil); err != nil { t.Fatalf("BuildNative failed: %v", err) } pagesDir := filepath.Join(root, ".cache", "wiki", "pages") if err := os.RemoveAll(pagesDir); err != nil { t.Fatalf("remove generated wiki pages: %v", err) } if err := os.MkdirAll(pagesDir, 0o755); err != nil { t.Fatalf("recreate empty wiki pages dir: %v", err) } result, err := BuildWikiWithOptions(proj, BuildWikiOptions{}, nil) if err != nil { t.Fatalf("BuildWikiWithOptions failed: %v", err) } if result.Status != wikiGeneratedStatus { t.Fatalf("expected missing cached pages to regenerate, got wiki status %q", result.Status) } if _, err := os.Stat(filepath.Join(pagesDir, "skills", "Athletics.html")); err != nil { t.Fatalf("expected regenerated skill page: %v", err) } if err := os.RemoveAll(pagesDir); err != nil { t.Fatalf("remove regenerated wiki pages: %v", err) } if err := os.MkdirAll(pagesDir, 0o755); err != nil { t.Fatalf("recreate empty wiki pages dir before native build: %v", err) } nativeResult, err := BuildNative(proj, nil) if err != nil { t.Fatalf("BuildNative with missing cached pages failed: %v", err) } if nativeResult.WikiStatus != wikiGeneratedStatus { t.Fatalf("expected native build to regenerate missing cached pages, got wiki status %q", nativeResult.WikiStatus) } } func TestBuildWikiRegeneratesOldGeneratorCache(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."}}, "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 = "" if _, err := BuildNative(proj, nil); err != nil { t.Fatalf("BuildNative failed: %v", err) } statePath := filepath.Join(root, ".cache", "wiki", "state.json") state, err := loadWikiState(statePath) if err != nil { t.Fatalf("read state: %v", err) } state.Version = "nodebb-tiptap-html-v2" if err := saveWikiState(statePath, state); err != nil { t.Fatalf("save old-version state: %v", err) } result, err := BuildWikiWithOptions(proj, BuildWikiOptions{}, nil) if err != nil { t.Fatalf("BuildWikiWithOptions failed: %v", err) } if result.Status != wikiGeneratedStatus { t.Fatalf("expected old generator cache to regenerate, got wiki status %q", result.Status) } refreshed, err := loadWikiState(statePath) if err != nil { t.Fatalf("read refreshed state: %v", err) } if refreshed.Version != wikiGeneratorVersion { t.Fatalf("expected refreshed generator version %q, got %q", wikiGeneratorVersion, refreshed.Version) } } func TestRenderNodeBBManagedHTMLPreservesHeadinglessTemplateHTML(t *testing.T) { source := strings.TrimSpace(`

This is generated.

Generated description.

Hit Died6

Class Skills

`) got := renderNodeBBManagedHTML("classes:adept", source, []wikiManualSection{ {ID: "user_top", Alias: "UserTopSection", InitialHTML: "

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

"}, }) for _, want := range []string{ ``, `

Class Skills

`, `
`, `
  • [[Skills/Heal|Heal]]
  • `, } { if !strings.Contains(got, want) { t.Fatalf("expected generated HTML to preserve %q:\n%s", want, got) } } if strings.Contains(got, "<table") || strings.Contains(got, "<div") || strings.Contains(got, "

    <") { t.Fatalf("expected generated HTML not to be escaped into plain text:\n%s", got) } if strings.Count(got, `manual:start id="user_top"`) != 1 { t.Fatalf("expected existing user_top manual section not to be duplicated:\n%s", got) } if strings.Count(got, `manual:start id="user_bottom"`) != 1 { t.Fatalf("expected missing user_bottom manual section to be bootstrapped once:\n%s", got) } } func TestBuildNativeIndexesUnregisteredStatusPagesWithDeclaredNamespacePath(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "skills")) mkdirAll(t, filepath.Join(root, "topdata", "wiki")) writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") writeFile(t, filepath.Join(root, "topdata", "wiki", "namespaces.yaml"), ` namespaces: - id: meta title: Wiki Status canonical_path: Wiki/Status category_env: SOW_WIKI_META_CID edit_policy: generated_only `) 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."}}, "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 = "" if _, err := BuildNative(proj, nil); err != nil { t.Fatalf("BuildNative failed: %v", err) } 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) } paths := map[string]string{} for _, page := range pageIndex.Pages { paths[page.PageID] = page.PublicPath } if got, want := paths["meta:wikistatus"], "Wiki/Status/Wiki_Status"; got != want { t.Fatalf("expected declared canonical path for status page, got %q want %q", got, want) } if got := paths["meta:wikistatus:skills:vanilla"]; !strings.HasPrefix(got, "Wiki/Status/") { t.Fatalf("expected declared canonical path for status listing, got %q", got) } } func TestBuildNativeCanOmitAggregateWikiStatusListings(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."}}, "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.TopData.Wiki.StatusListingScope = "namespace" if _, err := BuildNative(proj, nil); err != nil { t.Fatalf("BuildNative failed: %v", err) } if _, err := os.Stat(filepath.Join(root, ".cache", "wiki", "pages", "meta", "wikistatus", "skills", "vanilla.html")); err != nil { t.Fatalf("expected namespace status listing to be generated: %v", err) } if _, err := os.Stat(filepath.Join(root, ".cache", "wiki", "pages", "meta", "wikistatus", "vanilla.html")); !os.IsNotExist(err) { t.Fatalf("expected aggregate status listing to be omitted, got err=%v", err) } } func TestBuildNativeCanDisableWikiStatusPages(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."}}, "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") disabled := false proj := testProject(root) proj.Config.TopData.ReferenceBuilder = "" proj.Config.TopData.Wiki.StatusPages = &disabled result, err := BuildNative(proj, nil) if err != nil { t.Fatalf("BuildNative failed: %v", err) } if _, err := os.Stat(filepath.Join(root, ".cache", "wiki", "pages", "meta")); !os.IsNotExist(err) { t.Fatalf("expected wiki status pages to be disabled, got err=%v", err) } indexRaw, err := os.ReadFile(filepath.Join(root, ".cache", "wiki", "page-index.json")) if err != nil { t.Fatalf("read page index: %v", err) } if strings.Contains(string(indexRaw), `"namespace": "meta"`) || strings.Contains(string(indexRaw), `"meta:wikistatus`) { t.Fatalf("expected disabled status pages to be omitted from page-index:\n%s", indexRaw) } if result.WikiPages != 1 { t.Fatalf("expected only entity page to be generated, got %d pages", result.WikiPages) } } func TestBuildNativeWikiGeneratesRacePagesFromCoreRacialtypes(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "feat")) mkdirAll(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "modules")) mkdirAll(t, filepath.Join(root, "topdata", "data", "racialtypes", "00_feats")) writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{ "output": "feat.2da", "columns": ["FEAT", "DESCRIPTION"], "rows": [ {"id": 10, "key": "feat:darkvision", "FEAT": "Darkvision", "DESCRIPTION": "See in the dark."} ] }`+"\n") writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{"feat:darkvision":10}`+"\n") writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "base.json"), `{ "output": "racialtypes.2da", "columns": ["Label", "Name", "Description", "Appearance", "PlayerRace", "FeatsTable"], "rows": [ {"id": 7, "key": "racialtypes:beast", "Label": "Beast", "Name": "Beast", "Description": "Creature category.", "Appearance": 174, "PlayerRace": 0, "FeatsTable": "****"} ] }`+"\n") writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "lock.json"), `{"racialtypes:beast":7,"racialtypes:goblin_dynamic":39}`+"\n") writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "core", "modules", "add_goblin.json"), `{ "entries": { "racialtypes:goblin_dynamic": { "Label": "Goblin_Dynamic", "Name": {"tlk": {"text": "Goblin"}}, "Description": {"tlk": {"text": "A playable goblin race."}}, "Appearance": 15381, "PlayerRace": 1, "FeatsTable": {"table": "racialtypes/feats:goblin_dynamic"} } } }`+"\n") writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "00_feats", "goblin.json"), `{ "key": "racialtypes/feats:goblin_dynamic", "output": "race_feat_gobpc.2da", "columns": ["FeatLabel", "FeatIndex"], "rows": [ {"id": 0, "FeatIndex": {"id": "feat:darkvision"}} ] }`+"\n") writeFile(t, filepath.Join(root, "topdata", "data", "racialtypes", "00_feats", "lock.json"), "{}\n") disabled := false proj := testProject(root) proj.Config.TopData.ReferenceBuilder = "" proj.Config.TopData.Wiki.StatusPages = &disabled result, err := BuildNative(proj, nil) if err != nil { t.Fatalf("BuildNative failed: %v", err) } if result.WikiPages != 2 { t.Fatalf("expected one generated race page and its referenced feat page, got %d", result.WikiPages) } pagePath := filepath.Join(root, ".cache", "wiki", "pages", "races", "Goblin.html") got, err := os.ReadFile(pagePath) if err != nil { t.Fatalf("read generated race page: %v", err) } if !strings.Contains(string(got), "A playable goblin race.") || !strings.Contains(string(got), "[[Feats/Darkvision|Darkvision]]") { t.Fatalf("expected generated race page content, got:\n%s", got) } indexRaw, err := os.ReadFile(filepath.Join(root, ".cache", "wiki", "page-index.json")) if err != nil { t.Fatalf("read page index: %v", err) } indexText := string(indexRaw) if !strings.Contains(indexText, `"page_id": "races:goblin_dynamic"`) || !strings.Contains(indexText, `"namespace": "races"`) || !strings.Contains(indexText, `"public_path": "Races/Goblin"`) { t.Fatalf("expected generated race page-index entry, got:\n%s", indexText) } } 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 TestDefaultWikiManualSectionsUseSingleUserBottomBlock(t *testing.T) { sections := defaultWikiManualSections() if len(sections) != 2 { t.Fatalf("expected top and bottom manual sections only, got %#v", sections) } expected := map[string]string{ "user_top": "UserTopSection", "user_bottom": "UserBottomSection", } for _, section := range sections { alias, ok := expected[section.ID] if !ok { t.Fatalf("unexpected default manual section %#v", section) } if section.Alias != alias { t.Fatalf("expected alias %q for %q, got %q", alias, section.ID, section.Alias) } } } 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: "user_bottom", Alias: "UserBottomSection", InitialHTML: "

    "}, }, } 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 TestWikiTemplateEachBlockRendersExplicitClassProgressionTable(t *testing.T) { ctx := &wikiContext{ wikiDataProviders: map[string]wikiDataProviderDefinition{ "ClassProgression": {Source: "class_progression", Levels: "1-2"}, }, } page := wikiTemplatePage{ PageID: "classes:fighter", Category: "classes", Key: "classes:fighter", Title: "Fighter", Row: map[string]any{ "HitDie": "10", "AttackBonusTable": "cls_atk_1", }, } template := `
    {{#each ClassProgression}} {{/each}}
    LvlBABHP
    {{Level|ordinal}}{{BAB}}{{HPMin}}-{{HPMax}}
    ` got, err := ctx.renderWikiTemplateString("classes.html", template, page) if err != nil { t.Fatalf("render template: %v", err) } for _, want := range []string{ ``, ``, ``, } { if !strings.Contains(got, want) { t.Fatalf("expected rendered output to contain %q:\n%s", want, got) } } } func TestWikiTemplateClassProgressionProviderProjectsConfiguredClassFeats(t *testing.T) { root := t.TempDir() dataPath := filepath.Join(root, "data.yaml") writeFile(t, dataPath, ` providers: ClassProgression: source: class_progression levels: 1-4 class_feats: fields: GrantedFeats: when: "{{GrantedOnLevel > 0 && List == 3}}" AvailableFeats: when: "{{GrantedOnLevel > 0 && List != 3}}" `+"\n") providers, err := loadWikiDataProviders(dataPath) if err != nil { t.Fatalf("load data providers: %v", err) } ctx := testWikiTableContext(t, "") ctx.wikiDataPath = dataPath ctx.wikiDataProviders = providers ctx.featRows["feat:weapon_focus"] = map[string]any{"FEAT": map[string]any{"tlk": map[string]any{"text": "Weapon Focus"}}} ctx.classFeatTables["classes/feats:fighter"] = 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_specialization"}, "GrantedOnLevel": "4", "List": "1"}, {"FeatIndex": map[string]any{"id": "feat:weapon_focus"}, "GrantedOnLevel": "4", "List": "1"}, {"FeatIndex": map[string]any{"id": "feat:weapon_proficiency_simple"}, "GrantedOnLevel": "1", "List": "3"}, }} 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"}, }, } got, err := ctx.renderWikiTemplateString("classes.html", `{{#each ClassProgression}} {{Level}}|{{GrantedFeats|join:", "|wiki}}|{{AvailableFeats|join:", "|wiki}}|{{BonusFeat}} {{/each}}`, page) if err != nil { t.Fatalf("render template: %v", err) } for _, expected := range []string{ `1|[[Feats/Literate|Literate]], [[Feats/Simple_Weapon_Proficiency|Simple Weapon Proficiency]]||1`, `4||[[Feats/Weapon_Specialization|Weapon Specialization]], [[Feats/Weapon_Focus|Weapon Focus]]|1`, } { if !strings.Contains(got, expected) { t.Fatalf("expected %q in rendered progression:\n%s", expected, got) } } } func TestWikiTemplateClassProgressionProviderUsesDefaultNWNAttackSequences(t *testing.T) { ctx := &wikiContext{ wikiDataPath: "test-data.yaml", wikiDataProviders: map[string]wikiDataProviderDefinition{ "ClassProgression": {Source: "class_progression", Levels: "1-16"}, }, } page := wikiTemplatePage{ PageID: "classes:fighter", Category: "classes", Key: "classes:fighter", Title: "Fighter", Row: map[string]any{ "AttackBonusTable": "cls_atk_1", }, } got, err := ctx.renderWikiTemplateString("classes.html", `{{#each ClassProgression}}{{Level}}:{{BABValue}}:{{AttackCount}}:{{AttackSequence}} {{/each}}`, page) if err != nil { t.Fatalf("render template: %v", err) } for _, expected := range []string{ "1:1:1:+1", "6:6:2:+6/+1", "11:11:3:+11/+6/+1", "16:16:4:+16/+11/+6/+1", } { if !strings.Contains(got, expected) { t.Fatalf("expected %q in rendered progression:\n%s", expected, got) } } } func TestWikiTemplateClassProgressionProviderSupportsFixedAttacksAndOverrides(t *testing.T) { root := t.TempDir() dataPath := filepath.Join(root, "data.yaml") writeFile(t, dataPath, ` providers: ClassProgression: source: class_progression levels: 1-12 attacks: mode: fixed attacks: 2 step: -5 overrides: - classes: ["classes:fighter"] levels: 11 attacks: 3 `+"\n") providers, err := loadWikiDataProviders(dataPath) if err != nil { t.Fatalf("load data providers: %v", err) } ctx := &wikiContext{ wikiDataPath: dataPath, wikiDataProviders: providers, } page := wikiTemplatePage{ PageID: "classes:fighter", Category: "classes", Key: "classes:fighter", Title: "Fighter", Row: map[string]any{ "AttackBonusTable": "cls_atk_1", }, } got, err := ctx.renderWikiTemplateString("classes.html", `{{#each ClassProgression}}{{Level}}:{{AttackCount}}:{{AttackSequence}} {{/each}}`, page) if err != nil { t.Fatalf("render template: %v", err) } for _, expected := range []string{ "1:2:+1/-4", "6:2:+6/+1", "11:3:+11/+6/+1", "12:2:+12/+7", } { if !strings.Contains(got, expected) { t.Fatalf("expected %q in rendered progression:\n%s", expected, got) } } } func TestWikiTemplateCountHelperSupportsListGrammar(t *testing.T) { ctx := &wikiContext{} page := wikiTemplatePage{ PageID: "classes:test", Category: "classes", Key: "classes:test", Title: "Test Class", Row: map[string]any{ "AvailableFeats": []any{"One", "Two"}, }, } got, err := ctx.renderWikiTemplateString("classes.html", `{{AvailableFeats|join:", "}} {{if(count(AvailableFeats) > 1, "become", "becomes")}} available.`, page) if err != nil { t.Fatalf("render plural template: %v", err) } if got != "One, Two become available." { t.Fatalf("unexpected plural grammar: %q", got) } page.Row["AvailableFeats"] = []any{"One"} got, err = ctx.renderWikiTemplateString("classes.html", `{{AvailableFeats|join:", "}} {{if(count(AvailableFeats) > 1, "become", "becomes")}} available.`, page) if err != nil { t.Fatalf("render singular template: %v", err) } if got != "One becomes available." { t.Fatalf("unexpected singular grammar: %q", got) } } func TestWikiDataProvidersRejectEmptyClassFeatProjectionCondition(t *testing.T) { root := t.TempDir() dataPath := filepath.Join(root, "data.yaml") writeFile(t, dataPath, ` providers: ClassProgression: source: class_progression class_feats: fields: AvailableFeats: when: "" `+"\n") _, err := loadWikiDataProviders(dataPath) if err == nil { t.Fatal("expected invalid class feat projection condition") } for _, expected := range []string{"data.yaml", "ClassProgression", "AvailableFeats"} { if !strings.Contains(err.Error(), expected) { t.Fatalf("expected error to contain %q, got %v", expected, err) } } } func TestWikiDataProvidersRejectInvalidAttackOverrideConfig(t *testing.T) { root := t.TempDir() dataPath := filepath.Join(root, "data.yaml") writeFile(t, dataPath, ` providers: ClassProgression: source: class_progression attacks: mode: fixed attacks: 2 overrides: - classes: ["classes:fighter"] levels: 11 attacks: 3 add: 1 `+"\n") _, err := loadWikiDataProviders(dataPath) if err == nil { t.Fatal("expected invalid attack override config") } for _, expected := range []string{"data.yaml", "ClassProgression", "attacks", "override"} { if !strings.Contains(err.Error(), expected) { t.Fatalf("expected error to contain %q, got %v", expected, err) } } } func TestWikiDataProvidersRejectInvalidColumnProviderConfig(t *testing.T) { root := t.TempDir() dataPath := filepath.Join(root, "data.yaml") writeFile(t, dataPath, ` providers: ClassSkills: source: class_skills BrokenColumns: source: columns provider: ClassSkills columns: 0 items_per_column: 2 `+"\n") _, err := loadWikiDataProviders(dataPath) if err == nil { t.Fatal("expected invalid column provider config") } for _, expected := range []string{"data.yaml", "BrokenColumns", "columns"} { if !strings.Contains(err.Error(), expected) { t.Fatalf("expected error to contain %q, got %v", expected, err) } } } func TestWikiTemplateEachBlockRendersClassSkillsAsIndividualListItems(t *testing.T) { ctx := &wikiContext{ wikiDataProviders: map[string]wikiDataProviderDefinition{ "ClassSkills": {Source: "class_skills"}, }, classSkillTables: map[string]wikiTable{ "classes/skills:fighter": { Rows: []map[string]any{ {"SkillIndex": "1", "ClassSkill": "1"}, {"SkillIndex": "2", "ClassSkill": "1"}, {"SkillIndex": "3", "ClassSkill": "0"}, }, }, }, skillIDToKey: map[int]string{ 1: "skills:athletics", 2: "skills:parry", 3: "skills:secret", }, skillRows: map[string]map[string]any{ "skills:athletics": {"Name": "Athletics"}, "skills:parry": {"Name": "Parry"}, "skills:secret": {"Name": "Secret"}, }, } page := wikiTemplatePage{ PageID: "classes:fighter", Category: "classes", Key: "classes:fighter", Title: "Fighter", Row: map[string]any{ "SkillsTable": map[string]any{"table": "classes/skills:fighter"}, }, } template := `` got, err := ctx.renderWikiTemplateString("classes.html", template, page) if err != nil { t.Fatalf("render template: %v", err) } for _, want := range []string{ `
  • [[Skills/Athletics|Athletics]]
  • `, `
  • [[Skills/Parry|Parry]]
  • `, } { if !strings.Contains(got, want) { t.Fatalf("expected rendered output to contain %q:\n%s", want, got) } } for _, reject := range []string{"Athletics, ", "Secret"} { if strings.Contains(got, reject) { t.Fatalf("expected rendered output not to contain %q:\n%s", reject, got) } } } func TestWikiTemplateColumnProviderRendersNestedItemsAndForcedEmptyColumns(t *testing.T) { ctx := &wikiContext{ wikiDataProviders: map[string]wikiDataProviderDefinition{ "ClassSkills": { Source: "class_skills", }, "ClassSkillColumns": { Source: "columns", Provider: "ClassSkills", Columns: 3, ItemsPerColumn: 2, ForceColumns: true, }, }, classSkillTables: map[string]wikiTable{ "classes/skills:fighter": { Rows: []map[string]any{ {"SkillIndex": "1", "ClassSkill": "1"}, {"SkillIndex": "2", "ClassSkill": "1"}, {"SkillIndex": "3", "ClassSkill": "1"}, }, }, }, skillIDToKey: map[int]string{ 1: "skills:athletics", 2: "skills:parry", 3: "skills:ride", }, skillRows: map[string]map[string]any{ "skills:athletics": {"Name": "Athletics"}, "skills:parry": {"Name": "Parry"}, "skills:ride": {"Name": "Ride"}, }, } page := wikiTemplatePage{ PageID: "classes:fighter", Category: "classes", Key: "classes:fighter", Title: "Fighter", Row: map[string]any{ "SkillsTable": map[string]any{"table": "classes/skills:fighter"}, }, } template := `
    {{#each ClassSkillColumns}} {{/each}}
    ` got, err := ctx.renderWikiTemplateString("classes.html", template, page) if err != nil { t.Fatalf("render template: %v", err) } for _, want := range []string{ ``, ``, `
  • [[Feats/Darkvision|Darkvision]]
  • `, `
  • [[Feats/Daylight_aasimar|Daylight (aasimar)]]
  • `, } { if !strings.Contains(got, want) { t.Fatalf("expected rendered output to contain %q:\n%s", want, got) } } } func TestWikiTemplateDescriptionFiltersExtractParagraphsAndSections(t *testing.T) { ctx := &wikiContext{} page := wikiTemplatePage{ PageID: "feat:test", Category: "feat", Key: "feat:test", Title: "Test Feat", Row: map[string]any{ "DESCRIPTION": map[string]any{"tlk": map[string]any{"text": strings.Join([]string{ "Type of Feat: General", "", "Prerequisite: Dexterity 13+", "", "Specifics: You gain a +2 dodge bonus.", "The bonus applies only while unarmored.", "", "Use: Automatic.", }, "\n")}}, }, } tests := []struct { name string template string want string reject string }{ { name: "first paragraph", template: `
    {{DESCRIPTION|text|paragraph:first}}
    `, want: `
    Type of Feat: General
    `, reject: `Prerequisite`, }, { name: "specifics after marker as html", template: `
    {{DESCRIPTION|text|after:"Specifics:"|before:"Use:"|trim|html}}
    `, want: `

    You gain a +2 dodge bonus.
    The bonus applies only while unarmored.

    `, reject: `Use: Automatic`, }, { name: "first and last paragraphs", template: `
    {{DESCRIPTION|text|paragraphs:"first,last"|html}}
    `, want: `

    Type of Feat: General

    Use: Automatic.

    `, reject: `Specifics`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := ctx.renderWikiTemplateString("feat.html", tt.template, page) if err != nil { t.Fatalf("render template: %v", err) } if !strings.Contains(got, tt.want) { t.Fatalf("expected output to contain %q:\n%s", tt.want, got) } if tt.reject != "" && strings.Contains(got, tt.reject) { t.Fatalf("expected output not to contain %q:\n%s", tt.reject, 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{ `
    1st+11-10
    2nd+22-20
      `, `
    • [[Feats/Literate|Literate]]
    • `, `
    • [[Feats/Armor_Proficiency_light|Armor Proficiency (light)]]
    • `, } { if !strings.Contains(got, want) { t.Fatalf("expected rendered output to contain %q:\n%s", want, got) } } if strings.Contains(got, "Literate]], [[") { t.Fatalf("expected list filter not to render comma-separated links:\n%s", got) } } func TestWikiTemplateCanRenderRacialFactsAndFeatListInHTML(t *testing.T) { ctx := &wikiContext{ wikiDataProviders: map[string]wikiDataProviderDefinition{ "RaceFeats": {Source: "race_feats"}, }, classIDToKey: map[int]string{7: "classes:paladin"}, featIDToKey: map[int]string{ 10: "feat:darkvision", 11: "feat:daylight:aasimar", }, classRows: map[string]map[string]any{ "classes:paladin": {"Name": "Paladin"}, }, featRows: map[string]map[string]any{ "feat:darkvision": {"FEAT": "Darkvision"}, "feat:daylight:aasimar": {"FEAT": "Daylight (aasimar)"}, }, visibleWikiKeys: map[string]bool{ "classes:paladin": true, "feat:darkvision": true, "feat:daylight:aasimar": true, }, } page := wikiTemplatePage{ PageID: "racialtypes:aasimar", Category: "racialtypes", Key: "racialtypes:aasimar", Title: "Aasimar", Row: map[string]any{ "WISAdjust": "2", "CHAAdjust": "2", "Favored": "7", "FeatsTable": []any{"10", "11"}, }, } template := ` {{#if present(Favored)}} {{/if}}
      Ability Adjustments{{ability_adjustments()}}
      Favored Class{{Favored|ref:"classes"|wiki}}
        {{#each RaceFeats}}
      • {{FeatKey|link:FeatName|wiki}}
      • {{/each}}
      ` got, err := ctx.renderWikiTemplateString("racialtypes.html", template, page) if err != nil { t.Fatalf("render template: %v", err) } for _, want := range []string{ `
    Ability Adjustments+2 Wisdom, +2 CharismaFavored Class[[Classes/Paladin|Paladin]]
    `, ``, ``, ``, ``, ``, ``, ``, ``, } { 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 TestWikiVisibilityPolicyRejectsNullLikeFeatsAndHiddenReferences(t *testing.T) { root := testProjectRoot(t) for _, dir := range []string{ filepath.Join(root, "topdata", "data", "classes", "core"), filepath.Join(root, "topdata", "data", "classes", "feats"), filepath.Join(root, "topdata", "data", "classes", "skills"), filepath.Join(root, "topdata", "data", "feat"), filepath.Join(root, "topdata", "data", "skills"), filepath.Join(root, "topdata", "wiki"), filepath.Join(root, "topdata", "wiki", "templates", "pages"), } { mkdirAll(t, dir) } writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") writeFile(t, filepath.Join(root, "topdata", "wiki", "templates", "pages", "classes.html"), `

    {{title}}

    {{format:SkillList}} {{table:FeatProgression}}`+"\n") writeFile(t, filepath.Join(root, "topdata", "wiki", "tables.yaml"), ` tables: FeatProgression: source: class_feats columns: - key: feat label: Feat value: "{{link(FeatKey, FeatName)}}" allow_wiki_markup: true `+"\n") writeFile(t, filepath.Join(root, "topdata", "wiki", "visibility.yaml"), ` reference_sets: class_feats: target_dataset: feat sources: - dataset: classes table_field: FeatsTable table_kind: class_feats row_ref_field: FeatIndex datasets: classes: eligibility: when: "{{all_present(Name, Description)}}" include: - when: "{{PlayerClass == 1}}" feat: eligibility: when: "{{all_present(FEAT, DESCRIPTION)}}" include: - when: "{{ALLCLASSESCANUSE == 1}}" - reference_set: class_feats exclude: - when: "{{PreReqEpic == 1}}" skills: eligibility: when: "{{all_present(Name, Description)}}" include: - when: "{{true}}" exclude: - when: "{{HideFromLevelUp == 1}}" `+"\n") writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{ "output": "skills.2da", "columns": ["Label", "Name", "Description", "HideFromLevelUp"], "rows": [ {"id": 0, "key": "skills:athletics", "Label": "Athletics", "Name": {"tlk": {"text": "Athletics"}}, "Description": {"tlk": {"text": "Visible skill."}}, "HideFromLevelUp": "0"}, {"id": 1, "key": "skills:secret", "Label": "Secret", "Name": {"tlk": {"text": "Secret"}}, "Description": {"tlk": {"text": "Hidden skill."}}, "HideFromLevelUp": "1"} ] }`+"\n") writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:athletics":0,"skills:secret":1}`+"\n") writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{ "output": "feat.2da", "columns": ["LABEL", "FEAT", "DESCRIPTION", "ALLCLASSESCANUSE", "PreReqEpic"], "rows": [ {"id": 0, "key": "feat:visible", "LABEL": "Visible", "FEAT": {"tlk": {"text": "Visible Feat"}}, "DESCRIPTION": {"tlk": {"text": "Visible feat description."}}, "ALLCLASSESCANUSE": "1", "PreReqEpic": "0"}, {"id": 1, "key": "feat:epic", "LABEL": "Epic", "FEAT": {"tlk": {"text": "Epic Feat"}}, "DESCRIPTION": {"tlk": {"text": "Epic feat description."}}, "ALLCLASSESCANUSE": "1", "PreReqEpic": "1"}, {"id": 2, "key": "feat:nullfeat", "LABEL": "Null", "FEAT": "****", "DESCRIPTION": {"tlk": {"text": "Null feat description."}}, "ALLCLASSESCANUSE": "1", "PreReqEpic": "0"}, {"id": 3, "key": "feat:class_only", "LABEL": "ClassOnly", "FEAT": {"tlk": {"text": "Class Feat"}}, "DESCRIPTION": {"tlk": {"text": "Class feat description."}}, "ALLCLASSESCANUSE": "0", "PreReqEpic": "0"} ] }`+"\n") writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{"feat:visible":0,"feat:epic":1,"feat:nullfeat":2,"feat:class_only":3}`+"\n") writeFile(t, filepath.Join(root, "topdata", "data", "classes", "core", "base.json"), `{ "output": "classes.2da", "columns": ["Label", "Name", "Description", "PlayerClass", "SkillsTable", "FeatsTable"], "rows": [ {"id": 0, "key": "classes:fighter", "Label": "Fighter", "Name": {"tlk": {"text": "Fighter"}}, "Description": {"tlk": {"text": "Fighter text."}}, "PlayerClass": "1", "SkillsTable": {"table": "classes/skills:fighter"}, "FeatsTable": {"table": "classes/feats:fighter"}} ] }`+"\n") writeFile(t, filepath.Join(root, "topdata", "data", "classes", "core", "lock.json"), `{"classes:fighter":0}`+"\n") writeFile(t, filepath.Join(root, "topdata", "data", "classes", "skills", "fighter.json"), `{ "key": "classes/skills:fighter", "output": "cls_skill_fight.2da", "columns": ["SkillIndex", "ClassSkill"], "rows": [ {"SkillIndex": {"id": "skills:athletics"}, "ClassSkill": "1"}, {"SkillIndex": {"id": "skills:secret"}, "ClassSkill": "1"} ] }`+"\n") writeFile(t, filepath.Join(root, "topdata", "data", "classes", "feats", "fighter.json"), `{ "key": "classes/feats:fighter", "output": "cls_feat_fight.2da", "columns": ["FeatIndex", "GrantedOnLevel", "List"], "rows": [ {"FeatIndex": {"id": "feat:class_only"}, "GrantedOnLevel": "1", "List": "3"}, {"FeatIndex": {"id": "feat:epic"}, "GrantedOnLevel": "1", "List": "3"}, {"FeatIndex": {"id": "feat:nullfeat"}, "GrantedOnLevel": "1", "List": "3"} ] }`+"\n") proj := testProject(root) proj.Config.TopData.ReferenceBuilder = "" proj.Config.TopData.Wiki.VisibilityFile = "visibility.yaml" if _, err := buildWiki(proj, BuildResult{}, true, nil); err != nil { t.Fatalf("buildWiki failed: %v", err) } for _, path := range []string{ filepath.Join(root, ".cache", "wiki", "pages", "skills", "secret.html"), filepath.Join(root, ".cache", "wiki", "pages", "feat", "epic.html"), filepath.Join(root, ".cache", "wiki", "pages", "feat", "nullfeat.html"), } { if _, err := os.Stat(path); !os.IsNotExist(err) { t.Fatalf("expected hidden or ineligible wiki page %s to be omitted, got %v", path, err) } } for _, path := range []string{ filepath.Join(root, ".cache", "wiki", "pages", "feat", "Visible_Feat.html"), filepath.Join(root, ".cache", "wiki", "pages", "feat", "Class_Feat.html"), } { if _, err := os.Stat(path); err != nil { t.Fatalf("expected visible wiki page %s: %v", path, err) } } classPage, err := os.ReadFile(filepath.Join(root, ".cache", "wiki", "pages", "classes", "Fighter.html")) if err != nil { t.Fatalf("read class page: %v", err) } classText := string(classPage) for _, hidden := range []string{"Secret", "Epic Feat", "Null"} { if strings.Contains(classText, hidden) { t.Fatalf("expected hidden %q reference to be omitted from class page:\n%s", hidden, classText) } } for _, visible := range []string{"[[Skills/Athletics|Athletics]]", "[[Feats/Class_Feat|Class Feat]]"} { if !strings.Contains(classText, visible) { t.Fatalf("expected visible %q reference in class page:\n%s", visible, classText) } } indexRaw, err := os.ReadFile(filepath.Join(root, ".cache", "wiki", "page-index.json")) if err != nil { t.Fatalf("read page index: %v", err) } if strings.Contains(string(indexRaw), `"page_id": "skills:secret"`) || strings.Contains(string(indexRaw), `"page_id": "feat:epic"`) || strings.Contains(string(indexRaw), `"page_id": "feat:nullfeat"`) { t.Fatalf("expected hidden and ineligible pages out of page index:\n%s", string(indexRaw)) } } func TestWikiVisibilityPredicatesTreatNullLikeFieldsAsAbsent(t *testing.T) { policy, err := parseWikiVisibilityDefinitions([]byte(` datasets: feat: eligibility: when: "{{all_present(FEAT, DESCRIPTION)}}" include: - when: "{{true}}" `), "visibility.yaml") if err != nil { t.Fatalf("parse visibility policy: %v", err) } ctx := &wikiContext{} for name, row := range map[string]map[string]any{ "missing": {"DESCRIPTION": "Description"}, "null": {"FEAT": nil, "DESCRIPTION": "Description"}, "nwn-null": {"FEAT": "****", "DESCRIPTION": "Description"}, "empty": {"FEAT": "", "DESCRIPTION": "Description"}, "present": {"FEAT": "Feat", "DESCRIPTION": "Description"}, "missing-desc": {"FEAT": "Feat"}, } { got, err := ctx.evalWikiVisibilityPredicate(policy.Datasets["feat"].Eligibility.When, row) if err != nil { t.Fatalf("%s predicate failed: %v", name, err) } if want := name == "present"; got != want { t.Fatalf("%s expected eligibility %v, got %v", name, want, got) } } } func TestWikiVisibilityPolicyRejectsInvalidDatasetAndReferenceSet(t *testing.T) { tests := []struct { name string yaml string want string }{ { name: "unknown dataset", yaml: ` datasets: traps: include: - when: "{{true}}" `, want: `unknown dataset "traps"`, }, { name: "unknown reference set", yaml: ` datasets: feat: include: - reference_set: missing `, want: `unknown reference_set "missing"`, }, { name: "bad table kind", yaml: ` reference_sets: feats: target_dataset: feat sources: - dataset: classes table_field: FeatsTable table_kind: unsupported row_ref_field: FeatIndex datasets: feat: include: - reference_set: feats `, want: `unsupported table_kind`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := parseWikiVisibilityDefinitions([]byte(tt.yaml), "visibility.yaml") if err == nil || !strings.Contains(err.Error(), tt.want) { t.Fatalf("expected %q error, got %v", tt.want, err) } }) } } func TestWikiVisibilityCanExcludeUnreachableFeatPrerequisites(t *testing.T) { policy, err := parseWikiVisibilityDefinitions([]byte(` datasets: feat: include: - when: "{{true}}" exclude: - when: "{{hidden_ref(PREREQFEAT1, \"feat\")}}" - when: "{{hidden_ref(REQSKILL, \"skills\")}}" - when: "{{all_hidden_refs(OrReqFeat0, OrReqFeat1, OrReqFeat2, OrReqFeat3, OrReqFeat4, \"feat\")}}" `), "visibility.yaml") if err != nil { t.Fatalf("parse visibility policy: %v", err) } ctx := &wikiContext{ visibilityPolicy: &policy, visibleWikiKeys: map[string]bool{ "feat:visible": true, "feat:hidden": false, "skills:visible": true, "skills:hidden": false, }, featIDToKey: map[int]string{1: "feat:visible", 2: "feat:hidden"}, skillIDToKey: map[int]string{1: "skills:visible", 2: "skills:hidden"}, } definition := policy.Datasets["feat"] featRow := func(values map[string]any) map[string]any { row := map[string]any{"FEAT": "Test Feat", "DESCRIPTION": "Description"} for key, value := range values { row[key] = value } return row } for name, tt := range map[string]struct { row map[string]any want bool }{ "visible required feat": { row: featRow(map[string]any{"PREREQFEAT1": 1}), want: true, }, "hidden required feat": { row: featRow(map[string]any{"PREREQFEAT1": 2}), want: false, }, "missing required feat is unreachable": { row: featRow(map[string]any{"PREREQFEAT1": 999}), want: false, }, "visible required skill": { row: featRow(map[string]any{"REQSKILL": "skills:visible"}), want: true, }, "hidden required skill": { row: featRow(map[string]any{"REQSKILL": "skills:hidden"}), want: false, }, "null prerequisite fields are ignored": { row: featRow(map[string]any{"PREREQFEAT1": "****", "REQSKILL": "****"}), want: true, }, "or group with visible option remains reachable": { row: featRow(map[string]any{"OrReqFeat0": 2, "OrReqFeat1": 1}), want: true, }, "or group with only hidden options is unreachable": { row: featRow(map[string]any{"OrReqFeat0": 2, "OrReqFeat1": "feat:missing"}), want: false, }, "empty or group is ignored": { row: featRow(map[string]any{"OrReqFeat0": "****", "OrReqFeat1": "****"}), want: true, }, } { got, err := ctx.evaluateWikiVisibility("feat", "feat:"+name, tt.row, definition, nil) if err != nil { t.Fatalf("%s visibility evaluation failed: %v", name, err) } if got != tt.want { t.Fatalf("%s expected visibility %v, got %v", name, tt.want, got) } } } func TestWikiVisibilityIndexUsesBuiltVisibilityForReferenceHelpers(t *testing.T) { policy, err := parseWikiVisibilityDefinitions([]byte(` datasets: feat: include: - when: "{{true}}" exclude: - when: "{{hidden_ref(PREREQFEAT1, \"feat\")}}" `), "visibility.yaml") if err != nil { t.Fatalf("parse visibility policy: %v", err) } ctx := &wikiContext{ visibilityPolicy: &policy, featRows: map[string]map[string]any{ "feat:zbase": { "FEAT": "Base Feat", "DESCRIPTION": "Description", }, "feat:dependent": { "FEAT": "Dependent Feat", "DESCRIPTION": "Description", "PREREQFEAT1": 1, }, "feat:chain_dependent": { "FEAT": "Chain Dependent Feat", "DESCRIPTION": "Description", "PREREQFEAT1": 2, }, "feat:missing_prereq": { "FEAT": "Missing Prereq Feat", "DESCRIPTION": "Description", "PREREQFEAT1": 999, }, }, featIDToKey: map[int]string{1: "feat:zbase", 2: "feat:dependent"}, } visible, err := ctx.buildWikiVisibilityIndex(&policy) if err != nil { t.Fatalf("build visibility index: %v", err) } if !visible["feat:zbase"] { t.Fatalf("expected base feat to be visible") } if !visible["feat:dependent"] { t.Fatalf("expected feat with visible prerequisite to remain visible") } if !visible["feat:chain_dependent"] { t.Fatalf("expected feat with transitive visible prerequisite to remain visible") } if visible["feat:missing_prereq"] { t.Fatalf("expected feat with missing prerequisite to be hidden") } } func TestWikiVisibilityMetadataOverrideCannotBypassEligibility(t *testing.T) { policy, err := parseWikiVisibilityDefinitions([]byte(` datasets: feat: eligibility: when: "{{present(FEAT)}}" include: - when: "{{ALLCLASSESCANUSE == 1}}" exclude: - when: "{{PreReqEpic == 1}}" `), "visibility.yaml") if err != nil { t.Fatalf("parse visibility policy: %v", err) } ctx := &wikiContext{} definition := policy.Datasets["feat"] for name, tt := range map[string]struct { row map[string]any want bool }{ "exclude overridden": { row: map[string]any{ "FEAT": "Visible", "DESCRIPTION": "Description", "PreReqEpic": "1", "ALLCLASSESCANUSE": "1", "meta": map[string]any{"wiki": map[string]any{"generate": "1"}}, }, want: true, }, "eligibility preserved": { row: map[string]any{ "FEAT": "****", "DESCRIPTION": "Description", "ALLCLASSESCANUSE": "1", "meta": map[string]any{"wiki": map[string]any{"generate": "1"}}, }, want: false, }, } { got, err := ctx.evaluateWikiVisibility("feat", "feat:"+name, tt.row, definition, nil) if err != nil { t.Fatalf("%s visibility evaluation failed: %v", name, err) } if got != tt.want { t.Fatalf("%s expected visibility %v, got %v", name, tt.want, got) } } } 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 TestWikiPageIDForKeyNormalizesSlashSeparatorsOnly(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) } if got := wikiPageIDForKey("feat:bardic_music_inspire_competence"); got != "feat:bardic_music_inspire_competence" { t.Fatalf("expected underscore to remain in page ID leaf, got %q", got) } if got := wikiPageIDForKey("feat:bardic_music/inspire_competence"); got != "feat:bardic_music_inspire_competence" { t.Fatalf("expected slash-authored key to stay in one page ID leaf, got %q", got) } } func TestWikiPageOutputRelPathUsesCanonicalTitleLeaf(t *testing.T) { if got, want := wikiPageOutputRelPath("feat:bardic_music_inspire_competence", "Inspire Competence"), filepath.FromSlash("feat/Inspire_Competence.html"); got != want { t.Fatalf("expected underscore page ID to stay in one file leaf, got %q want %q", got, want) } if got, want := wikiPageOutputRelPath(wikiPageIDForKey("feat:bardic_music/inspire_competence"), "Inspire Competence"), filepath.FromSlash("feat/Inspire_Competence.html"); got != want { t.Fatalf("expected slash-authored key to stay in one file leaf, got %q want %q", got, want) } } 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 TestSaveWikiPageIndexRejectsDuplicatePublicPaths(t *testing.T) { err := saveWikiPageIndex(filepath.Join(t.TempDir(), "page-index.json"), wikiPageIndex{ Version: wikiGeneratorVersion, Pages: []wikiPageIndexEntry{ {PageID: "spells:darkness", PublicPath: "Spells/Darkness", OutputPath: "pages/spells/darkness.html"}, {PageID: "spells:gwildshape:driderdarkness", PublicPath: "Spells/Darkness", OutputPath: "pages/spells/gwildshape/driderdarkness.html"}, }, }) if err == nil || !strings.Contains(err.Error(), "duplicate wiki page-index public_path") { t.Fatalf("expected duplicate public_path validation error, got %v", err) } } func TestSaveWikiPageIndexRejectsFoldedDuplicatePublicPaths(t *testing.T) { err := saveWikiPageIndex(filepath.Join(t.TempDir(), "page-index.json"), wikiPageIndex{ Version: wikiGeneratorVersion, Pages: []wikiPageIndexEntry{ {PageID: "spells:fear", PublicPath: "Magic/Fear", OutputPath: "pages/spells/fear.html"}, {PageID: "spells:accented_fear", PublicPath: "magic/fear", OutputPath: "pages/spells/accented_fear.html"}, }, }) if err == nil || !strings.Contains(err.Error(), "duplicate wiki page-index public_path") { t.Fatalf("expected folded duplicate public_path validation error, got %v", err) } } func TestPublicWikiPathSplitsTitlePathWithoutWhitespaceAroundSeparator(t *testing.T) { ctx := &wikiContext{namespaceTitles: map[string]string{"spells": "Magic"}} if got := ctx.publicWikiPath("spells", "Parent::Child"); got != "Magic/Parent/Child" { t.Fatalf("expected NodeBB-style title path split, got %q", got) } if got := wikiPageOutputRelPath("spells:parent_child", "Parent::Child"); got != filepath.FromSlash("spells/Parent/Child.html") { t.Fatalf("expected title-based output path split, got %q", got) } } func TestParseWikiPagePathDeclarationsRejectsLegacySlugOverrides(t *testing.T) { _, err := parseWikiPagePathDeclarations([]byte(` page_paths: slug_overrides: - page_id: "spells:pdk:fear" slug: "pdk-fear" `), "wiki.yaml") if err == nil || !strings.Contains(err.Error(), `page_paths.slug_overrides is retired`) { t.Fatalf("expected retired slug override error, got %v", err) } } func TestCanonicalWikiSegmentPreservesCaseAndUnderscoreSpaces(t *testing.T) { tests := map[string]string{ `Inspire Competence`: "Inspire_Competence", `Grandmaster's Battle Momentum`: "Grandmasters_Battle_Momentum", `Bigby’s Clenched Fist`: "Bigbys_Clenched_Fist", `"Quoted" ‘Curly’ “Title”`: "Quoted_Curly_Title", `Hardiness vs. Enchantments`: "Hardiness_vs_Enchantments", `Clairaudience/Clairvoyance`: "Clairaudience_Clairvoyance", `Moon-on-a-Stick`: "Moon_on_a_Stick", `Élite & Noble Houses`: "Elite_Noble_Houses", `Rock 'n' Roll`: "Rock_n_Roll", `Melf's Acid Arrow`: "Melfs_Acid_Arrow", `Alpha!@#$%^&*()[]-=_+/?.,<>` + "`" + `~|\Omega`: "Alpha_Omega", `Æther Œuvre Øresund Straße Þorn Łódź Đelta`: "Aether_Oeuvre_Oresund_Strasse_Thorn_Lodz_Delta", } for input, want := range tests { if got := canonicalWikiSegment(input); got != want { t.Fatalf("canonicalWikiSegment(%q) = %q, want %q", input, got, want) } } } func TestCanonicalWikiSegmentFoldedKeyMatchesPluginFixture(t *testing.T) { tests := []struct { source string canonical string folded string }{ {source: "Inspire Competence", canonical: "Inspire_Competence", folded: "inspire competence"}, {source: "Grandmaster's Battle Momentum", canonical: "Grandmasters_Battle_Momentum", folded: "grandmasters battle momentum"}, {source: "Æther Œuvre Øresund Straße Þorn Łódź Đelta", canonical: "Aether_Oeuvre_Oresund_Strasse_Thorn_Lodz_Delta", folded: "aether oeuvre oresund strasse thorn lodz delta"}, } for _, tt := range tests { if got := canonicalWikiSegment(tt.source); got != tt.canonical { t.Fatalf("canonicalWikiSegment(%q) = %q, want %q", tt.source, got, tt.canonical) } if got := canonicalWikiSegmentFoldedKey(tt.source); got != tt.folded { t.Fatalf("canonicalWikiSegmentFoldedKey(%q) = %q, want %q", tt.source, got, tt.folded) } } } func TestWikiStatusPagesEmitCanonicalLinks(t *testing.T) { ctx := &wikiContext{} report := renderWikiStatusReportPage(map[string]map[string]any{ "skills": { "status": wikiStatusVanilla, "new": 0, "modified": 0, "vanilla": 1, "total": 1, }, }, []string{"skills"}, ctx) if !strings.Contains(report, "[[Skills|Skills]]") || strings.Contains(report, "[[skills:index|Skills]]") { t.Fatalf("expected status report namespace link to use canonical public path, got:\n%s", report) } listing := renderWikiStatusListingPage("Skills Vanilla Pages", []string{"skills:inspire_competence", "feat:grandmaster_battle_momentum"}, map[string]string{ "skills:inspire_competence": "Inspire Competence", "feat:grandmaster_battle_momentum": "Grandmaster's Battle Momentum", }, ctx) for _, want := range []string{ "[[Skills/Inspire_Competence|Inspire Competence]]", "[[Feats/Grandmasters_Battle_Momentum|Grandmaster's Battle Momentum]]", } { if !strings.Contains(listing, want) { t.Fatalf("expected status listing canonical link %q, got:\n%s", want, listing) } } if strings.Contains(listing, "[[skills:inspire_competence|") || strings.Contains(listing, "[[feat:grandmaster_battle_momentum|") { t.Fatalf("expected status listing to omit generated page IDs as public targets, got:\n%s", listing) } customCtx := &wikiContext{ namespaceTitles: map[string]string{ "spells": "Magic", }, } customReport := renderWikiStatusReportPage(map[string]map[string]any{ "spells": { "status": wikiStatusNew, "new": 1, "modified": 0, "vanilla": 0, "total": 1, }, }, []string{"spells"}, customCtx) if !strings.Contains(customReport, "[[Magic|Magic]]") || strings.Contains(customReport, "[[Spells|Spells]]") { t.Fatalf("expected custom namespace status report link to use declared canonical title, got:\n%s", customReport) } customListing := renderWikiStatusListingPage("Magic New Pages", []string{"spells:fear"}, map[string]string{ "spells:fear": "Fear", }, customCtx) if !strings.Contains(customListing, "[[Magic/Fear|Fear]]") || strings.Contains(customListing, "[[Spells/Fear|Fear]]") { t.Fatalf("expected custom namespace status listing link to use declared canonical title, got:\n%s", customListing) } } func TestPublicWikiTargetUsesCanonicalNamespaceAndTitlePath(t *testing.T) { ctx := &wikiContext{ namespaceTitles: map[string]string{ "feat": "Feats", }, namespacePaths: map[string]string{ "feat": "Wiki/Feats", }, rowsByKey: map[string]map[string]any{ "feat:hardiness_versus_enchantments": {"FEAT": map[string]any{"tlk": map[string]any{"text": "Hardiness vs. Enchantments"}}}, }, } if got, want := ctx.publicWikiTargetForKey("feat:hardiness_versus_enchantments", "Display Alias"), "Wiki/Feats/Hardiness_vs_Enchantments"; got != want { t.Fatalf("expected canonical title path %q, got %q", want, got) } } func TestPublicWikiTargetUsesTargetTitleBeforeDisplayLabel(t *testing.T) { ctx := &wikiContext{ namespaceTitles: map[string]string{ "feat": "Feats", }, rowsByKey: map[string]map[string]any{ "feat:hardiness:versus:enchantments": {"FEAT": "Hardiness vs. Enchantments"}, }, } if got, want := ctx.publicWikiTargetForKey("feat:hardiness:versus:enchantments", "Display Alias"), "Feats/Hardiness_vs_Enchantments"; got != want { t.Fatalf("expected target title slug %q, got %q", want, got) } } 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[[Feats/Literate|Literate]], [[Feats/Simple_Weapon_Proficiency|Simple Weapon Proficiency]]1-10[[Feats/Weapon_Specialization|Weapon Specialization]] first available.