package topdata import ( "encoding/json" "os" "path/filepath" "strings" "testing" "time" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" ) func TestBuildNativeGeneratesAndSkipsWikiPages(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "skills")) writeFile(t, filepath.Join(root, "topdata", "base_dialog.json"), "{}\n") writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{ "output": "skills.2da", "columns": ["Label", "Name", "Description", "HideFromLevelUp", "Untrained", "KeyAbility", "ArmorCheckPenalty", "Constant"], "rows": [ { "id": 0, "key": "skills:athletics", "Label": "Athletics", "Name": {"tlk": {"key": "skills:athletics.name", "text": "Athletics"}}, "Description": {"tlk": {"key": "skills:athletics.description", "text": "Ability: Strength.\n\nUses:\n- Climb\n- Swim"}}, "HideFromLevelUp": "0", "Untrained": "1", "KeyAbility": "STR", "ArmorCheckPenalty": "1", "Constant": "SKILL_ATHLETICS" } ] }`+"\n") writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{"skills:athletics":0}`+"\n") proj := testProject(root) proj.Config.TopData.ReferenceBuilder = "" result, err := BuildNative(proj, nil) if err != nil { t.Fatalf("BuildNative failed: %v", err) } generatedHTMLCount := countGeneratedWikiHTMLFiles(t, filepath.Join(root, ".cache", "wiki", "pages")) if result.WikiPages != generatedHTMLCount { t.Fatalf("expected wiki page count to match generated HTML files, got result=%d files=%d", result.WikiPages, generatedHTMLCount) } pagePath := filepath.Join(root, ".cache", "wiki", "pages", "skills", "Athletics.html") got, err := os.ReadFile(pagePath) if err != nil { t.Fatalf("read generated page: %v", err) } text := string(got) if !strings.Contains(text, "
This is generated.
Generated description.
| Hit Die | d6 |
|---|
| Lvl | BAB | HP |
|---|---|---|
| {{Level|ordinal}} | {{BAB}} | {{HPMin}}-{{HPMax}} |
| 1st | +1 | 1-10 | ||
| 2nd | +2 | 2-20 |
| {{Label}} | {{Value|wiki}} |
|---|
| {{Label}} | {{Value|wiki}} |
|---|
{{Value|wiki}}
{{/each}}`, page) if err != nil { t.Fatalf("render template: %v", err) } for _, expected := range []string{ `[[Feats/Extra_Music|Extra Music]], [[Feats/Lingering_Song|Lingering Song]]
`, } { if !strings.Contains(got, expected) { t.Fatalf("expected %q in rendered class facts:\n%s", expected, got) } } } func TestWikiTemplateExpressionCanRenderIndefiniteArticles(t *testing.T) { ctx := &wikiContext{} page := wikiTemplatePage{ PageID: "classes:adept", Category: "classes", Key: "classes:adept", Title: "Adept", Row: map[string]any{ "Lower": map[string]any{"tlk": map[string]any{"text": "adept"}}, "SpellcastingAbil": "INT", }, } got, err := ctx.renderWikiTemplateString("classes.html", `{{indefinite_article(text(Lower))}} {{text(Lower)}} / {{indefinite_article(ability_name(SpellcastingAbil))}} {{ability_name(SpellcastingAbil)}}`, page) if err != nil { t.Fatalf("render template: %v", err) } if got != "an adept / an Intelligence" { t.Fatalf("expected vowel-sound articles, got %q", got) } } func TestWikiTemplateClassProgressionProviderUsesDefaultNWNAttackSequences(t *testing.T) { ctx := &wikiContext{ wikiDataPath: "test-data.yaml", wikiDataProviders: map[string]wikiDataProviderDefinition{ "ClassProgression": {Source: "class_progression", Levels: "1-16"}, }, } page := wikiTemplatePage{ PageID: "classes:fighter", Category: "classes", Key: "classes:fighter", Title: "Fighter", Row: map[string]any{ "AttackBonusTable": "cls_atk_1", }, } got, err := ctx.renderWikiTemplateString("classes.html", `{{#each ClassProgression}}{{Level}}:{{BABValue}}:{{AttackCount}}:{{AttackSequence}} {{/each}}`, page) if err != nil { t.Fatalf("render template: %v", err) } for _, expected := range []string{ "1:1:1:+1", "6:6:2:+6/+1", "11:11:3:+11/+6/+1", "16:16:4:+16/+11/+6/+1", } { if !strings.Contains(got, expected) { t.Fatalf("expected %q in rendered progression:\n%s", expected, got) } } } func TestWikiTemplateClassProgressionProviderSupportsFixedAttacksAndOverrides(t *testing.T) { root := t.TempDir() dataPath := filepath.Join(root, "data.yaml") writeFile(t, dataPath, ` providers: ClassProgression: source: class_progression levels: 1-12 attacks: mode: fixed attacks: 2 step: -5 overrides: - classes: ["classes:fighter"] levels: 11 attacks: 3 `+"\n") providers, err := loadWikiDataProviders(dataPath) if err != nil { t.Fatalf("load data providers: %v", err) } ctx := &wikiContext{ wikiDataPath: dataPath, wikiDataProviders: providers, } page := wikiTemplatePage{ PageID: "classes:fighter", Category: "classes", Key: "classes:fighter", Title: "Fighter", Row: map[string]any{ "AttackBonusTable": "cls_atk_1", }, } got, err := ctx.renderWikiTemplateString("classes.html", `{{#each ClassProgression}}{{Level}}:{{AttackCount}}:{{AttackSequence}} {{/each}}`, page) if err != nil { t.Fatalf("render template: %v", err) } for _, expected := range []string{ "1:2:+1/-4", "6:2:+6/+1", "11:3:+11/+6/+1", "12:2:+12/+7", } { if !strings.Contains(got, expected) { t.Fatalf("expected %q in rendered progression:\n%s", expected, got) } } } func TestWikiTemplateCountHelperSupportsListGrammar(t *testing.T) { ctx := &wikiContext{} page := wikiTemplatePage{ PageID: "classes:test", Category: "classes", Key: "classes:test", Title: "Test Class", Row: map[string]any{ "AvailableFeats": []any{"One", "Two"}, }, } got, err := ctx.renderWikiTemplateString("classes.html", `{{AvailableFeats|join:", "}} {{if(count(AvailableFeats) > 1, "become", "becomes")}} available.`, page) if err != nil { t.Fatalf("render plural template: %v", err) } if got != "One, Two become available." { t.Fatalf("unexpected plural grammar: %q", got) } page.Row["AvailableFeats"] = []any{"One"} got, err = ctx.renderWikiTemplateString("classes.html", `{{AvailableFeats|join:", "}} {{if(count(AvailableFeats) > 1, "become", "becomes")}} available.`, page) if err != nil { t.Fatalf("render singular template: %v", err) } if got != "One becomes available." { t.Fatalf("unexpected singular grammar: %q", got) } } func TestWikiDataProvidersRejectEmptyClassFeatProjectionCondition(t *testing.T) { root := t.TempDir() dataPath := filepath.Join(root, "data.yaml") writeFile(t, dataPath, ` providers: ClassProgression: source: class_progression class_feats: fields: AvailableFeats: when: "" `+"\n") _, err := loadWikiDataProviders(dataPath) if err == nil { t.Fatal("expected invalid class feat projection condition") } for _, expected := range []string{"data.yaml", "ClassProgression", "AvailableFeats"} { if !strings.Contains(err.Error(), expected) { t.Fatalf("expected error to contain %q, got %v", expected, err) } } } func TestWikiDataProvidersRejectInvalidAttackOverrideConfig(t *testing.T) { root := t.TempDir() dataPath := filepath.Join(root, "data.yaml") writeFile(t, dataPath, ` providers: ClassProgression: source: class_progression attacks: mode: fixed attacks: 2 overrides: - classes: ["classes:fighter"] levels: 11 attacks: 3 add: 1 `+"\n") _, err := loadWikiDataProviders(dataPath) if err == nil { t.Fatal("expected invalid attack override config") } for _, expected := range []string{"data.yaml", "ClassProgression", "attacks", "override"} { if !strings.Contains(err.Error(), expected) { t.Fatalf("expected error to contain %q, got %v", expected, err) } } } func TestWikiDataProvidersRejectInvalidColumnProviderConfig(t *testing.T) { root := t.TempDir() dataPath := filepath.Join(root, "data.yaml") writeFile(t, dataPath, ` providers: ClassSkills: source: class_skills BrokenColumns: source: columns provider: ClassSkills columns: 0 items_per_column: 2 `+"\n") _, err := loadWikiDataProviders(dataPath) if err == nil { t.Fatal("expected invalid column provider config") } for _, expected := range []string{"data.yaml", "BrokenColumns", "columns"} { if !strings.Contains(err.Error(), expected) { t.Fatalf("expected error to contain %q, got %v", expected, err) } } } func TestWikiTemplateEachBlockRendersClassSkillsAsIndividualListItems(t *testing.T) { ctx := &wikiContext{ wikiDataProviders: map[string]wikiDataProviderDefinition{ "ClassSkills": {Source: "class_skills"}, }, classSkillTables: map[string]wikiTable{ "classes/skills:fighter": { Rows: []map[string]any{ {"SkillIndex": "1", "ClassSkill": "1"}, {"SkillIndex": "2", "ClassSkill": "1"}, {"SkillIndex": "3", "ClassSkill": "0"}, }, }, }, skillIDToKey: map[int]string{ 1: "skills:athletics", 2: "skills:parry", 3: "skills:secret", }, skillRows: map[string]map[string]any{ "skills:athletics": {"Name": "Athletics", "KeyAbility": "STR"}, "skills:parry": {"Name": "Parry", "KeyAbility": "DEX"}, "skills:secret": {"Name": "Secret", "KeyAbility": "INT"}, }, } page := wikiTemplatePage{ PageID: "classes:fighter", Category: "classes", Key: "classes:fighter", Title: "Fighter", Row: map[string]any{ "SkillsTable": map[string]any{"table": "classes/skills:fighter"}, }, } template := `| Ability Adjustments | {{ability_adjustments()}} |
|---|---|
| Favored Class | {{Favored|ref:"classes"|wiki}} |
You gain a +2 dodge bonus.
The bonus applies only while unarmored.
Type of Feat: General
Use: Automatic.
| Lvl | `, `Saves | `, `1st | `, `+1 | `, `2 | `, `[[Feats/Literate|Literate]], [[Feats/Simple_Weapon_Proficiency|Simple Weapon Proficiency]] | `, `1-10 | `, `[[Feats/Weapon_Specialization|Weapon Specialization]] first available. Manual level note. | `, } { if !strings.Contains(got, expected) { t.Fatalf("expected %q in rendered table:\n%s", expected, got) } } } func TestWikiTemplateRendersConfiguredClassProgressionSpellColumns(t *testing.T) { ctx := testWikiTableContext(t, ` tables: ClassProgression: source: class_progression class: "wiki-table wiki-class-progression" rows: levels: 1-2 columns: - key: level label: Lvl value: "{{Level}}" - key: per_day label: Base spells per day when: "{{HasSpellsPerDay}}" children: - key: per_day_0 label: 0 value: "{{SpellsPerDay0}}" empty: "-" - key: per_day_1 label: 1 value: "{{SpellsPerDay1}}" empty: "-" - key: known label: Known spells when: "{{HasSpellsKnown}}" children: - key: known_0 label: 0 value: "{{SpellsKnown0}}" empty: "-" - key: known_1 label: 1 value: "{{SpellsKnown1}}" empty: "-" `) ctx.classSpellGainTables["classes/spellsgained:bard"] = wikiTable{Rows: []map[string]any{ {"Level": 1, "SpellLevel0": 2, "SpellLevel1": 1}, {"Level": 2, "SpellLevel0": 3, "SpellLevel1": nil}, }} ctx.classSpellKnownTables["classes/spellsknown:bard"] = wikiTable{Rows: []map[string]any{ {"Level": 1, "SpellLevel0": 4, "SpellLevel1": 2}, {"Level": 2, "SpellLevel0": 5, "SpellLevel1": 3}, }} page := wikiTemplatePage{ PageID: "classes:bard", Category: "classes", Key: "classes:bard", Title: "Bard", Row: map[string]any{ "SpellCaster": 1, "SpellGainTable": map[string]any{"table": "classes/spellsgained:bard"}, "SpellKnownTable": map[string]any{"table": "classes/spellsknown:bard"}, }, } got, err := ctx.renderWikiTemplateString("classes.html", `{{table:ClassProgression}}`, page) if err != nil { t.Fatalf("render table template: %v", err) } for _, expected := range []string{ `Base spells per day | `, `Known spells | `, `
|---|---|---|---|---|
| 1 | 2 | 1 | 4 | 2 |
| 2 | 3 | - | 5 | 3 | Base spells per day | `) { t.Fatalf("expected only two visible spell columns:\n%s", got) } if strings.Contains(got, `2 | `) { t.Fatalf("expected unused spell level 2 column to be hidden:\n%s", got) } } func TestWikiTemplateOmitsSpellColumnsForPreparedAndNonCasterClasses(t *testing.T) { ctx := testWikiTableContext(t, ` tables: ClassProgression: source: class_progression rows: levels: 1 columns: - key: level label: Lvl value: "{{Level}}" - key: per_day label: Base spells per day when: "{{HasSpellsPerDay}}" children: - key: per_day_0 label: 0 value: "{{SpellsPerDay0}}" - key: known label: Known spells when: "{{HasSpellsKnown}}" children: - key: known_0 label: 0 value: "{{SpellsKnown0}}" `) ctx.classSpellGainTables["classes/spellsgained:cleric"] = wikiTable{Rows: []map[string]any{ {"Level": 1, "SpellLevel0": 3}, }} prepared := wikiTemplatePage{ PageID: "classes:cleric", Category: "classes", Key: "classes:cleric", Title: "Cleric", Row: map[string]any{ "SpellCaster": 1, "SpellGainTable": map[string]any{"table": "classes/spellsgained:cleric"}, }, } nonCaster := wikiTemplatePage{ PageID: "classes:fighter", Category: "classes", Key: "classes:fighter", Title: "Fighter", Row: map[string]any{"SpellCaster": 0}, } gotPrepared, err := ctx.renderWikiTemplateString("classes.html", `{{table:ClassProgression}}`, prepared) if err != nil { t.Fatalf("render prepared caster table: %v", err) } if !strings.Contains(gotPrepared, `Base spells per day`) || strings.Contains(gotPrepared, `Known spells`) { t.Fatalf("expected prepared caster to render per-day but not known columns:\n%s", gotPrepared) } gotNonCaster, err := ctx.renderWikiTemplateString("classes.html", `{{table:ClassProgression}}`, nonCaster) if err != nil { t.Fatalf("render non-caster table: %v", err) } if strings.Contains(gotNonCaster, `Base spells per day`) || strings.Contains(gotNonCaster, `Known spells`) { t.Fatalf("expected non-caster to omit spell columns:\n%s", gotNonCaster) } } func TestWikiTemplateRendersClassSpellbookProviderBySpellLevel(t *testing.T) { root := t.TempDir() dataPath := filepath.Join(root, "data.yaml") writeFile(t, dataPath, ` providers: ClassSpellbook: source: class_spellbook `+"\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.spellRows = map[string]map[string]any{ "spells:light": {"Name": map[string]any{"tlk": map[string]any{"text": "Light"}}}, "spells:cure_light_wounds": {"Name": map[string]any{"tlk": map[string]any{"text": "Cure Light Wounds"}}}, "spells:bless": {"Name": map[string]any{"tlk": map[string]any{"text": "Bless"}}}, } ctx.classSpellbooks = map[string]classSpellbookSpec{ "classes:acolyte": { Class: "classes:acolyte", Levels: map[int][]string{ 0: {"spells:light"}, 1: {"spells:cure_light_wounds", "spells:bless"}, }, }, } page := wikiTemplatePage{ PageID: "classes:acolyte", Category: "classes", Key: "classes:acolyte", Title: "Acolyte", Row: map[string]any{"SpellCaster": 1}, } got, err := ctx.renderWikiTemplateString("classes.html", `{{#each ClassSpellbook}}