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

1429 lines
50 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 wiki_slug=athletics -->`) || !strings.Contains(text, `<!-- sow-topdata-wiki:managed:start hash="sha256:`) || !strings.Contains(text, "Ability: Strength.") || !strings.Contains(text, "<li>Climb</li>") {
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_slug": "athletics"`) || !strings.Contains(indexText, `"public_target": "skills/athletics"`) || !strings.Contains(indexText, `"output_path": "pages/skills/athletics.html"`) || !strings.Contains(indexText, `"edit_policy": "preserve_manual_sections"`) {
t.Fatalf("expected deterministic page-index metadata, got:\n%s", indexText)
}
if !strings.Contains(indexText, `"page_id": "meta:wikistatus"`) || !strings.Contains(indexText, `"output_path": "pages/meta/wikistatus.html"`) || !strings.Contains(indexText, `"edit_policy": "generated_only"`) {
t.Fatalf("expected status pages in page-index metadata, got:\n%s", indexText)
}
if _, err := os.Stat(filepath.Join(root, ".cache", "wiki", "state.json")); err != nil {
t.Fatalf("expected state file after first build: %v", err)
}
stateBefore, err := loadWikiState(filepath.Join(root, ".cache", "wiki", "state.json"))
if err != nil {
t.Fatalf("read state before second build: %v", err)
}
digestBefore, err := computeWikiSourceDigest(filepath.Join(root, "topdata"))
if err != nil {
t.Fatalf("compute digest before second build: %v", err)
}
if stateBefore.Digest != digestBefore {
t.Fatalf("expected state digest to match current digest before second build, got state=%s current=%s", stateBefore.Digest, digestBefore)
}
second, err := BuildNative(proj, nil)
if err != nil {
t.Fatalf("second BuildNative failed: %v", err)
}
if second.WikiPages != generatedHTMLCount {
t.Fatalf("expected generated wiki page count after rebuild to remain stable, got %d want %d", second.WikiPages, generatedHTMLCount)
}
}
func 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 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 TestWikiTemplateListFilterRendersSliceAsListItems(t *testing.T) {
ctx := &wikiContext{}
page := wikiTemplatePage{
PageID: "classes:fighter",
Category: "classes",
Key: "classes:fighter",
Title: "Fighter",
Row: map[string]any{
"GrantedFeats": []string{
"[[feat/literate|Literate]]",
"[[feat/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>[[feat/literate|Literate]]</li>`,
`<li>[[feat/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>[[feat/darkvision|Darkvision]]</li>`,
`<li>[[feat/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>[[feat/literate|Literate]], [[feat/simple-weapon-proficiency|Simple Weapon Proficiency]]</td>`,
`<td>1-10</td>`,
`<td>[[feat/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.html"),
filepath.Join(root, ".cache", "wiki", "pages", "feat", "class", "only.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]]", "[[feat/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 TestWikiPageIDForKeyNormalizesSlashAndUnderscoreSeparators(t *testing.T) {
got := wikiPageIDForKey("feat:special/attacks_bull_rush")
if want := "feat:special:attacks:bull:rush"; got != want {
t.Fatalf("expected normalized page ID %q, got %q", want, got)
}
}
func TestSaveWikiPageIndexRejectsDuplicateOutputPaths(t *testing.T) {
err := saveWikiPageIndex(filepath.Join(t.TempDir(), "page-index.json"), wikiPageIndex{
Version: wikiGeneratorVersion,
Pages: []wikiPageIndexEntry{
{PageID: "feat:special:attacks:bull:rush", OutputPath: "pages/feat/special/attacks/bull/rush.html"},
{PageID: "feat:special/attacks:bull:rush", OutputPath: "pages/feat/special/attacks/bull/rush.html"},
},
})
if err == nil || !strings.Contains(err.Error(), "duplicate wiki page-index output_path") {
t.Fatalf("expected duplicate output_path validation error, got %v", err)
}
}
func TestSaveWikiPageIndexRejectsDuplicatePublicTargets(t *testing.T) {
err := saveWikiPageIndex(filepath.Join(t.TempDir(), "page-index.json"), wikiPageIndex{
Version: wikiGeneratorVersion,
Pages: []wikiPageIndexEntry{
{PageID: "spells:darkness", PublicTarget: "spells/darkness", OutputPath: "pages/spells/darkness.html"},
{PageID: "spells:gwildshape:driderdarkness", PublicTarget: "spells/darkness", OutputPath: "pages/spells/gwildshape/driderdarkness.html"},
},
})
if err == nil || !strings.Contains(err.Error(), "duplicate wiki page-index public_target") {
t.Fatalf("expected duplicate public_target validation error, got %v", err)
}
}
func TestParseWikiPagePathOverridesRejectsInvalidAndDuplicateSlugs(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(), `invalid slug "Pdk Fear"`) {
t.Fatalf("expected invalid wiki slug error, got %v", err)
}
_, err = parseWikiPagePathDeclarations([]byte(`
page_paths:
slug_overrides:
- page_id: "spells:pdk:fear"
slug: "pdk-fear"
- page_id: "spells:pdk:fear"
slug: "fear-pdk"
`), "wiki.yaml")
if err == nil || !strings.Contains(err.Error(), `duplicate page_id "spells:pdk:fear"`) {
t.Fatalf("expected duplicate page ID error, got %v", err)
}
}
func TestWikiPageSlugOverridesChangeTargets(t *testing.T) {
ctx := &wikiContext{pageSlugOverrides: map[string]string{
"spells:pdk:fear": "pdk-fear",
}}
if got, want := ctx.publicWikiPageSlug("spells:pdk:fear", "Fear"), "pdk-fear"; got != want {
t.Fatalf("expected slug override %q, got %q", want, got)
}
}
func TestWikiSlugifyTitleNormalizesPunctuationAndSeparators(t *testing.T) {
tests := map[string]string{
`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",
}
for input, want := range tests {
if got := wikiSlugifyTitle(input); got != want {
t.Fatalf("wikiSlugifyTitle(%q) = %q, want %q", input, got, want)
}
}
}
func TestPublicWikiTargetUsesTargetTitleBeforeDisplayLabel(t *testing.T) {
ctx := &wikiContext{
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"), "feat/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)
}
}