658 lines
25 KiB
Go
658 lines
25 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, `<!-- 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, `"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 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:weapon:proficiency:simple|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 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 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)
|
|
}
|
|
}
|