1589 lines
56 KiB
Go
1589 lines
56 KiB
Go
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, "<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_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 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 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 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{
|
||
"[[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 TestParseWikiPagePathDeclarationsRejectsLegacySlugOverrides(t *testing.T) {
|
||
_, err := parseWikiPagePathDeclarations([]byte(`
|
||
page_paths:
|
||
slug_overrides:
|
||
- page_id: "spells:pdk:fear"
|
||
slug: "pdk-fear"
|
||
`), "wiki.yaml")
|
||
if err == nil || !strings.Contains(err.Error(), `page_paths.slug_overrides is retired`) {
|
||
t.Fatalf("expected retired slug override error, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestCanonicalWikiSegmentPreservesCaseAndUnderscoreSpaces(t *testing.T) {
|
||
tests := map[string]string{
|
||
`Inspire Competence`: "Inspire_Competence",
|
||
`Grandmaster's Battle Momentum`: "Grandmasters_Battle_Momentum",
|
||
`Bigby’s Clenched Fist`: "Bigbys_Clenched_Fist",
|
||
`"Quoted" ‘Curly’ “Title”`: "Quoted_Curly_Title",
|
||
`Hardiness vs. Enchantments`: "Hardiness_vs_Enchantments",
|
||
`Clairaudience/Clairvoyance`: "Clairaudience_Clairvoyance",
|
||
`Moon-on-a-Stick`: "Moon_on_a_Stick",
|
||
`Élite & Noble Houses`: "Elite_Noble_Houses",
|
||
`Rock 'n' Roll`: "Rock_n_Roll",
|
||
`Melf's Acid Arrow`: "Melfs_Acid_Arrow",
|
||
`Alpha!@#$%^&*()[]-=_+/?.,<>` + "`" + `~|\Omega`: "Alpha_Omega",
|
||
`Æther Œuvre Øresund Straße Þorn Łódź Đelta`: "Aether_Oeuvre_Oresund_Strasse_Thorn_Lodz_Delta",
|
||
}
|
||
for input, want := range tests {
|
||
if got := canonicalWikiSegment(input); got != want {
|
||
t.Fatalf("canonicalWikiSegment(%q) = %q, want %q", input, got, want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestPublicWikiTargetUsesCanonicalNamespaceAndTitlePath(t *testing.T) {
|
||
ctx := &wikiContext{
|
||
namespaceTitles: map[string]string{
|
||
"feat": "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"), "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)
|
||
}
|
||
}
|