Implemented the wiki pipeline to a shippable local state across `toolkit/`, `module/`, and the NodeBB wiki plugin.
**Scope Audited**
- Toolkit config/project loading, wiki renderer, deployer, app command wiring.
- Module `nwn-tool.yaml`, wiki source/docs, release script, topdata docs.
- Plugin sanitizer/API storage contract docs and sanitizer tests.
**Changes Made**
- Added topdata-owned wiki declarations under `module/topdata/wiki/`.
- Added `topdata.wiki.*` config fields, validation, effective config output.
- Switched generated wiki pages to `.html` with HTML comment managed/manual markers.
- Added `page-index.json` generation and deterministic metadata.
- Added manual-section merge preservation and stale page `report`/`archive` handling.
- Added namespace `category_env` loading from `topdata/wiki/namespaces.yaml`, while keeping `--category`/`NODEBB_WIKI_CATEGORIES` overrides.
- Updated release deployment to keep dry-run first and make stale cleanup opt-in via `SOW_MODULE_WIKI_DEPLOY_STALE_POLICY`.
- Updated plugin sanitizer to preserve only `sow-topdata-wiki` comments.
**Configuration/Schema Impact**
- `module/nwn-tool.yaml` now declares wiki source, renderer, link strategy, templates, manual sections, managed marker policy, and stale policy.
- Deploy manifest entries now support stale/archive metadata: `stale`, `last_seen_hash`, `archived_hash`, `title`, `namespace`, `tid`, `pid`, `cid`.
**Compatibility Impact**
- Legacy Markdown managed markers are still readable for migration.
- Generated output is now HTML, not Markdown.
- Existing mapped pages update by `pid`; creates still require explicit `--create`.
**Tests Added/Updated**
- Go tests for wiki config validation, HTML rendering, page index, manual merge, stale report/archive, app summary output.
- Plugin sanitizer test for generated topdata bot HTML markers/subset.
**Validation Performed**
- `go test ./internal/project ./internal/topdata ./internal/app` passed.
- `./build-tool.sh` passed.
- `SOW_TOOLS_DEV_BINARY=../toolkit/tools/sow-toolkit ./validate-topdata.sh` passed.
- `SOW_TOOLS_DEV_BINARY=../toolkit/tools/sow-toolkit ./build-wiki.sh --force` passed, generating 1345 entity pages.
- `deploy-wiki --dry-run --create` with dummy endpoint/token and namespace CID env vars passed locally: 1377 planned creates, 0 stale/drift.
- `node tests/wiki-html-sanitizer.test.js` passed.
**Remaining Risks**
- Full plugin `npm test` is blocked by an existing unrelated drawer CSS contract failure in `tests/wiki-article-drawers.test.js`.
- Controlled live migration was not performed because it requires a non-production NodeBB namespace and credentials.
- Direct `PUT /api/v3/posts/{pid}` save-filter behavior is documented as needing live confirmation against the deployed NodeBB/plugin stack.
Reviewed-on: https://gitea.westgate.pw/ShadowsOverWestgate/sow-tools/pulls/3
Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
Co-committed-by: vickydotbat <vickydotbat@tutamail.com>
270 lines
10 KiB
Go
270 lines
10 KiB
Go
package topdata
|
|
|
|
import (
|
|
"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)
|
|
}
|
|
if result.WikiPages != 1 {
|
|
t.Fatalf("expected one generated entity page, got %d", result.WikiPages)
|
|
}
|
|
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="notes" -->`) || !strings.Contains(text, "<h2>See Also</h2>") {
|
|
t.Fatalf("expected generated page to include manual section placeholders:\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)
|
|
}
|
|
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 _, 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 != 1 {
|
|
t.Fatalf("expected a single generated skill wiki page after rebuild, got %d", second.WikiPages)
|
|
}
|
|
}
|
|
|
|
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 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)
|
|
}
|
|
if result.WikiPages != 1 {
|
|
t.Fatalf("expected one generated wiki page when requested, got %d", result.WikiPages)
|
|
}
|
|
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)
|
|
}
|
|
}
|