Files
sow-tools/internal/topdata/wiki_native_test.go
T

2090 lines
73 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, "<h1>Athletics</h1>") || !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, `<!-- sow-topdata-wiki:page=skills:athletics -->`) || strings.Contains(text, `wiki_slug=`) || !strings.Contains(text, `<!-- sow-topdata-wiki:managed:start hash="sha256:`) || !strings.Contains(text, "Ability: Strength.") || !strings.Contains(text, "Uses:<br>- Climb<br>- Swim") {
t.Fatalf("expected HTML managed markers and description to be rendered into wiki page:\n%s", text)
}
if strings.Contains(text, "[//]: #") || strings.Contains(text, "<WRAP>") {
t.Fatalf("expected generated HTML to omit DokuWiki-era syntax:\n%s", text)
}
if !strings.Contains(text, `<!-- sow-topdata-wiki:manual:start id="user_bottom" -->`) {
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, "<h2>See Also</h2>") {
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), "<th>Vanilla</th><td>1</td>") {
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 TestRenderNodeBBManagedHTMLPreservesHeadinglessTemplateHTML(t *testing.T) {
source := strings.TrimSpace(`
<p class="wiki-callout wiki-callout--status">This is generated.</p>
<!-- sow-topdata-wiki:manual:start id="user_top" -->
<p></p>
<!-- sow-topdata-wiki:manual:end id="user_top" -->
<p>Generated description.</p>
<table class="wiki-facts"><tbody><tr><th scope="row">Hit Die</th><td>d6</td></tr></tbody></table>
<h3>Class Skills</h3>
<div class="wiki-media-row"><ul><li>[[Skills/Heal|Heal]]</li></ul></div>
`)
got := renderNodeBBManagedHTML("classes:adept", source, []wikiManualSection{
{ID: "user_top", Alias: "UserTopSection", InitialHTML: "<p></p>"},
{ID: "user_bottom", Alias: "UserBottomSection", InitialHTML: "<p></p>"},
})
for _, want := range []string{
`<table class="wiki-facts">`,
`<h3>Class Skills</h3>`,
`<div class="wiki-media-row">`,
`<li>[[Skills/Heal|Heal]]</li>`,
} {
if !strings.Contains(got, want) {
t.Fatalf("expected generated HTML to preserve %q:\n%s", want, got)
}
}
if strings.Contains(got, "&lt;table") || strings.Contains(got, "&lt;div") || strings.Contains(got, "<p>&lt;") {
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 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: "<p></p>"},
{ID: "user_bottom", Alias: "UserBottomSection", InitialHTML: "<p></p>"},
},
}
got, err := ctx.renderWikiTemplateString("classes.html", `<h1>{{title}}</h1>
{{UserTopSection}}
<p>Hit Die: {{HitDie}}</p>
<p>Skill Points: {{SkillPointBase}}</p>
{{format:StatusCallout}}
{{format:Description}}
{{UserBottomSection}}`, page)
if err != nil {
t.Fatalf("render template: %v", err)
}
if !strings.Contains(got, "<h1>Acolyte</h1>\n<!-- sow-topdata-wiki:manual:start id=\"user_top\" -->") {
t.Fatalf("expected preserved top section directly after title, got:\n%s", got)
}
for _, expected := range []string{
"<p>Hit Die: 6</p>",
"<p>Skill Points: 4</p>",
"This is a new class!",
"<p>Acolyte description.</p>",
`<!-- sow-topdata-wiki:manual:start id="user_bottom" -->`,
} {
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", `<p>{{HitDie}}</p>`, 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", `<p>{{field:HitDie|default=unknown}}</p>`, page)
if err != nil {
t.Fatalf("expected defaulted field to render, got %v", err)
}
if got != "<p>unknown</p>" {
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 := `<table class="wiki-table wiki-class-progression">
<thead>
<tr><th>Lvl</th><th>BAB</th><th>HP</th></tr>
</thead>
<tbody>
{{#each ClassProgression}}
<tr><td>{{Level|ordinal}}</td><td>{{BAB}}</td><td>{{HPMin}}-{{HPMax}}</td></tr>
{{/each}}
</tbody>
</table>`
got, err := ctx.renderWikiTemplateString("classes.html", template, page)
if err != nil {
t.Fatalf("render template: %v", err)
}
for _, want := range []string{
`<table class="wiki-table wiki-class-progression">`,
`<tr><td>1st</td><td>+1</td><td>1-10</td></tr>`,
`<tr><td>2nd</td><td>+2</td><td>2-20</td></tr>`,
} {
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 := `<ul class="wiki-list wiki-class-skills">
{{#each ClassSkills}}
<li>{{SkillKey|link:SkillName|wiki}}</li>
{{/each}}
</ul>`
got, err := ctx.renderWikiTemplateString("classes.html", template, page)
if err != nil {
t.Fatalf("render template: %v", err)
}
for _, want := range []string{
`<li>[[Skills/Athletics|Athletics]]</li>`,
`<li>[[Skills/Parry|Parry]]</li>`,
} {
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 := `<div class="wiki-list-columns wiki-list-columns--3">
{{#each ClassSkillColumns}}
<ul data-index="{{Index}}" data-count="{{Count}}" class="wiki-list wiki-class-skills">
{{#each Items}}
<li>{{SkillKey|link:SkillName|wiki}}</li>
{{/each}}
</ul>
{{/each}}
</div>`
got, err := ctx.renderWikiTemplateString("classes.html", template, page)
if err != nil {
t.Fatalf("render template: %v", err)
}
for _, want := range []string{
`<ul data-index="1" data-count="2" class="wiki-list wiki-class-skills">`,
`<li>[[Skills/Athletics|Athletics]]</li>`,
`<li>[[Skills/Parry|Parry]]</li>`,
`<ul data-index="2" data-count="1" class="wiki-list wiki-class-skills">`,
`<li>[[Skills/Ride|Ride]]</li>`,
`<ul data-index="3" data-count="0" class="wiki-list wiki-class-skills">`,
} {
if !strings.Contains(got, want) {
t.Fatalf("expected rendered output to contain %q:\n%s", want, got)
}
}
if gotCount := strings.Count(got, `<ul data-index=`); gotCount != 3 {
t.Fatalf("expected exactly 3 forced columns, got %d:\n%s", gotCount, got)
}
}
func TestWikiTemplateColumnProviderAddsOverflowColumnsBeyondConfiguredCount(t *testing.T) {
ctx := &wikiContext{
wikiDataProviders: map[string]wikiDataProviderDefinition{
"ClassSkills": {
Source: "class_skills",
},
"ClassSkillColumns": {
Source: "columns",
Provider: "ClassSkills",
Columns: 2,
ItemsPerColumn: 2,
},
},
classSkillTables: map[string]wikiTable{
"classes/skills:fighter": {
Rows: []map[string]any{
{"SkillIndex": "1", "ClassSkill": "1"},
{"SkillIndex": "2", "ClassSkill": "1"},
{"SkillIndex": "3", "ClassSkill": "1"},
{"SkillIndex": "4", "ClassSkill": "1"},
{"SkillIndex": "5", "ClassSkill": "1"},
},
},
},
skillIDToKey: map[int]string{
1: "skills:athletics",
2: "skills:parry",
3: "skills:ride",
4: "skills:survival",
5: "skills:heal",
},
skillRows: map[string]map[string]any{
"skills:athletics": {"Name": "Athletics"},
"skills:parry": {"Name": "Parry"},
"skills:ride": {"Name": "Ride"},
"skills:survival": {"Name": "Survival"},
"skills:heal": {"Name": "Heal"},
},
}
page := wikiTemplatePage{
PageID: "classes:fighter",
Category: "classes",
Key: "classes:fighter",
Title: "Fighter",
Row: map[string]any{
"SkillsTable": map[string]any{"table": "classes/skills:fighter"},
},
}
got, err := ctx.renderWikiTemplateString("classes.html", `{{#each ClassSkillColumns}}[{{Index}}:{{Count}}]{{/each}}`, page)
if err != nil {
t.Fatalf("render template: %v", err)
}
if got != "[1:2][2:2][3:1]" {
t.Fatalf("expected overflow column after configured columns, got %q", got)
}
}
func TestWikiTemplateListFilterRendersSliceAsListItems(t *testing.T) {
ctx := &wikiContext{}
page := wikiTemplatePage{
PageID: "classes:fighter",
Category: "classes",
Key: "classes:fighter",
Title: "Fighter",
Row: map[string]any{
"GrantedFeats": []string{
"[[Feats/Literate|Literate]]",
"[[Feats/Armor_Proficiency_light|Armor Proficiency (light)]]",
},
},
}
got, err := ctx.renderWikiTemplateString("classes.html", `<td>{{GrantedFeats|list:"wiki-list wiki-inline-list"}}</td>`, page)
if err != nil {
t.Fatalf("render template: %v", err)
}
for _, want := range []string{
`<td><ul class="wiki-list wiki-inline-list">`,
`<li>[[Feats/Literate|Literate]]</li>`,
`<li>[[Feats/Armor_Proficiency_light|Armor Proficiency (light)]]</li>`,
} {
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 := `<table class="wiki-facts">
<tbody>
<tr><th scope="row">Ability Adjustments</th><td>{{ability_adjustments()}}</td></tr>
{{#if present(Favored)}}
<tr><th scope="row">Favored Class</th><td>{{Favored|ref:"classes"|wiki}}</td></tr>
{{/if}}
</tbody>
</table>
<ul class="wiki-race-feats">
{{#each RaceFeats}}
<li>{{FeatKey|link:FeatName|wiki}}</li>
{{/each}}
</ul>`
got, err := ctx.renderWikiTemplateString("racialtypes.html", template, page)
if err != nil {
t.Fatalf("render template: %v", err)
}
for _, want := range []string{
`<th scope="row">Ability Adjustments</th><td>+2 Wisdom, +2 Charisma</td>`,
`<th scope="row">Favored Class</th><td>[[Classes/Paladin|Paladin]]</td>`,
`<li>[[Feats/Darkvision|Darkvision]]</li>`,
`<li>[[Feats/Daylight_aasimar|Daylight (aasimar)]]</li>`,
} {
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: `<div>{{DESCRIPTION|text|paragraph:first}}</div>`,
want: `<div>Type of Feat: General</div>`,
reject: `Prerequisite`,
},
{
name: "specifics after marker as html",
template: `<section>{{DESCRIPTION|text|after:"Specifics:"|before:"Use:"|trim|html}}</section>`,
want: `<section><p>You gain a +2 dodge bonus.<br>The bonus applies only while unarmored.</p></section>`,
reject: `Use: Automatic`,
},
{
name: "first and last paragraphs",
template: `<div>{{DESCRIPTION|text|paragraphs:"first,last"|html}}</div>`,
want: `<div><p>Type of Feat: General</p><p>Use: Automatic.</p></div>`,
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", `<h1>{{title}}</h1>
{{table:ClassProgression}}`, page)
if err != nil {
t.Fatalf("render table template: %v", err)
}
for _, expected := range []string{
`<table class="wiki-table wiki-class-progression">`,
`<th scope="col" rowspan="2">Lvl</th>`,
`<th scope="colgroup" colspan="3">Saves</th>`,
`<td>1st</td>`,
`<td>+1</td>`,
`<td>2</td>`,
`<td>[[Feats/Literate|Literate]], [[Feats/Simple_Weapon_Proficiency|Simple Weapon Proficiency]]</td>`,
`<td>1-10</td>`,
`<td>[[Feats/Weapon_Specialization|Weapon Specialization]] first available.</td>`,
} {
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"), `<h1>{{title}}</h1>
{{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{
`<!-- sow-topdata-wiki:page=classes:acolyte -->`,
`<!-- sow-topdata-wiki:managed:start hash="sha256:first" -->`,
`<h1>Acolyte</h1>`,
`<!-- sow-topdata-wiki:manual:start id="user_top" -->`,
`<p>Original user edit.</p>`,
`<!-- sow-topdata-wiki:manual:end id="user_top" -->`,
`<p>Generated.</p>`,
`<!-- sow-topdata-wiki:managed:end -->`,
}, "\n")
second := strings.Replace(first, "<p>Original user edit.</p>", "<p>Changed by a user.</p>", 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, `<h1>{{title}}</h1>`+"\n")
before, err := computeWikiSourceDigest(root)
if err != nil {
t.Fatalf("compute initial digest: %v", err)
}
writeFile(t, templatePath, `<h1>{{title}}</h1><p>Changed</p>`+"\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",
`Bigbys 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 &amp; Noble Houses`: "Elite_Noble_Houses",
`Rock 'n' Roll`: "Rock_n_Roll",
`Melf&#39;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)
}
}