package topdata import ( "encoding/json" "os" "path/filepath" "strings" "testing" "time" "git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" ) 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(proj) 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 TestWikiTemplateFactsProviderRendersYAMLDeclaredFactRows(t *testing.T) { root := t.TempDir() dataPath := filepath.Join(root, "data.yaml") writeFile(t, dataPath, ` providers: FeatFacts: source: facts facts: rows: - label: Prerequisites value: "{{feat_prerequisites()}}" - label: Minimum Attack Bonus value: "{{MINATTACKBONUS}}" - label: Armor Check Penalty value: "{{yes_no(ArmorCheckPenalty)}}" - label: Missing Optional value: "{{MissingField}}" `+"\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:power_attack"] = map[string]any{"FEAT": map[string]any{"tlk": map[string]any{"text": "Power Attack"}}} ctx.featIDToKey[28] = "feat:power_attack" ctx.skillRows = map[string]map[string]any{"skills:athletics": {"Name": map[string]any{"tlk": map[string]any{"text": "Athletics"}}}} ctx.skillIDToKey = map[int]string{3: "skills:athletics"} page := wikiTemplatePage{ PageID: "feat:cleave", Category: "feat", Key: "feat:cleave", Title: "Cleave", Row: map[string]any{ "MINSTR": "13", "PREREQFEAT1": 28, "REQSKILL": 3, "ReqSkillMinRanks": "4", "MINATTACKBONUS": "2", "ArmorCheckPenalty": "1", }, } got, err := ctx.renderWikiTemplateString("feat.html", `
    1st+11-10
    2nd+22-20
    {{#each FeatFacts}} {{/each}}
    {{Label}}{{Value|wiki}}
    `, page) if err != nil { t.Fatalf("render template: %v", err) } for _, expected := range []string{ `PrerequisitesStr 13, [[Feats/Power_Attack|Power Attack]], [[Skills/Athletics|Athletics]] 4 ranks`, `Minimum Attack Bonus2`, `Armor Check PenaltyYes`, } { if !strings.Contains(got, expected) { t.Fatalf("expected %q in rendered facts:\n%s", expected, got) } } if strings.Contains(got, "Missing Optional") { t.Fatalf("expected empty optional fact to be omitted:\n%s", got) } } func TestWikiTemplateClassFactsCanUseDerivedClassHelpers(t *testing.T) { root := t.TempDir() dataPath := filepath.Join(root, "data.yaml") writeFile(t, dataPath, ` providers: ClassFacts: source: facts facts: rows: - label: Hit Die value: "{{\"d\" + HitDie + \" (\" + HitDie + \" per level)\"}}" - label: Proficiencies value: "{{join(class_proficiencies(), \", \")}}" - label: Skill Points at 1st Level value: "{{\"(\" + SkillPointBase + \" + Int modifier) x4\"}}" - label: Skill Points at Each Additional Level value: "{{SkillPointBase + \" + Int modifier\"}}" - label: Saves value: "{{class_saves()}}" - label: Base Attack Bonus value: "{{class_bab()}}" - label: Spellcasting value: "{{\"To cast a spell, a \" + text(Lower) + \" must have a \" + ability_name(SpellcastingAbil) + \" score of 10 + the spell's level. For example, to cast a 2nd-level spell, a \" + text(Lower) + \" must have a \" + ability_name(SpellcastingAbil) + \" of 12.\"}}" when: "{{SpellCaster == 1}}" ClassBonusFeats: source: facts facts: rows: - label: Bonus Feats value: "{{join(class_selectable_feats(), \", \")}}" `+"\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:armor_proficiency_light"] = map[string]any{"FEAT": map[string]any{"tlk": map[string]any{"text": "Light Armor Proficiency"}}} ctx.featRows["feat:shield_proficiency"] = map[string]any{"FEAT": map[string]any{"tlk": map[string]any{"text": "Shield Proficiency"}}} ctx.featRows["feat:extra_music"] = map[string]any{"FEAT": map[string]any{"tlk": map[string]any{"text": "Extra Music"}}} ctx.featRows["feat:lingering_song"] = map[string]any{"FEAT": map[string]any{"tlk": map[string]any{"text": "Lingering Song"}}} ctx.classFeatTables["classes/feats:bard"] = wikiTable{OutputStem: "cls_feat_bard", Rows: []map[string]any{ {"FeatIndex": map[string]any{"id": "feat:literate"}, "GrantedOnLevel": "1", "List": "3"}, {"FeatIndex": map[string]any{"id": "feat:armor_proficiency_light"}, "GrantedOnLevel": "1", "List": "3"}, {"FeatIndex": map[string]any{"id": "feat:shield_proficiency"}, "GrantedOnLevel": "1", "List": "3"}, {"FeatIndex": map[string]any{"id": "feat:extra_music"}, "GrantedOnLevel": "-1", "List": "1"}, {"FeatIndex": map[string]any{"id": "feat:lingering_song"}, "GrantedOnLevel": "-1", "List": "2"}, }} ctx.classSaveTables["classes/saves:reflex_will"] = wikiTable{OutputStem: "cls_savthr_rw", Rows: []map[string]any{ {"Level": "1", "FortSave": "0", "RefSave": "2", "WillSave": "2"}, }} page := wikiTemplatePage{ PageID: "classes:bard", Category: "classes", Key: "classes:bard", Title: "Bard", Row: map[string]any{ "HitDie": "6", "SkillPointBase": "8", "Lower": map[string]any{"tlk": map[string]any{"text": "bard"}}, "SpellCaster": "1", "SpellcastingAbil": "CHA", "AttackBonusTable": "cls_atk_2", "FeatsTable": map[string]any{"table": "classes/feats:bard"}, "SavingThrowTable": map[string]any{"table": "classes/saves:reflex_will"}, }, } got, err := ctx.renderWikiTemplateString("classes.html", ` {{#each ClassFacts}}{{/each}}
    {{Label}}{{Value|wiki}}
    {{#each ClassBonusFeats}}

    {{Value|wiki}}

    {{/each}}`, page) if err != nil { t.Fatalf("render template: %v", err) } for _, expected := range []string{ `Hit Died6 (6 per level)`, `Proficiencies[[Feats/Light_Armor_Proficiency|Light Armor Proficiency]], [[Feats/Shield_Proficiency|Shield Proficiency]]`, `Skill Points at 1st Level(8 + Int modifier) x4`, `Skill Points at Each Additional Level8 + Int modifier`, `SavesHigh Reflex and Will`, `Base Attack Bonus+3/4`, `SpellcastingTo cast a spell, a bard must have a Charisma score of 10 + the spell's level. For example, to cast a 2nd-level spell, a bard must have a Charisma of 12.`, `

    [[Feats/Extra_Music|Extra Music]], [[Feats/Lingering_Song|Lingering Song]]

    `, } { if !strings.Contains(got, expected) { t.Fatalf("expected %q in rendered class facts:\n%s", expected, got) } } } func TestWikiTemplateExpressionCanRenderIndefiniteArticles(t *testing.T) { ctx := &wikiContext{} page := wikiTemplatePage{ PageID: "classes:adept", Category: "classes", Key: "classes:adept", Title: "Adept", Row: map[string]any{ "Lower": map[string]any{"tlk": map[string]any{"text": "adept"}}, "SpellcastingAbil": "INT", }, } got, err := ctx.renderWikiTemplateString("classes.html", `{{indefinite_article(text(Lower))}} {{text(Lower)}} / {{indefinite_article(ability_name(SpellcastingAbil))}} {{ability_name(SpellcastingAbil)}}`, page) if err != nil { t.Fatalf("render template: %v", err) } if got != "an adept / an Intelligence" { t.Fatalf("expected vowel-sound articles, got %q", 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", "KeyAbility": "STR"}, "skills:parry": {"Name": "Parry", "KeyAbility": "DEX"}, "skills:secret": {"Name": "Secret", "KeyAbility": "INT"}, }, } 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]] (Str)
  • `, `
  • [[Skills/Parry|Parry]] (Dex)
  • `, } { 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{ `