Prerequisite reachability filters

**Scope Audited**
- Module wiki visibility config and docs.
- Toolkit wiki expression engine and visibility tests.
- Feat prerequisite rendering/reference behavior.

**Changes Made**
- Added `hidden_ref(Field, "dataset")` and `all_hidden_refs(..., "dataset")` helpers in [wiki_tables.go](/home/vicky/Projects/nwnee-shadowsoverwestgate/toolkit/internal/topdata/wiki_tables.go:914).
- Added feat visibility exclusions in [visibility.yaml](/home/vicky/Projects/nwnee-shadowsoverwestgate/module/topdata/wiki/visibility.yaml:44) for:
  - `PREREQFEAT1`
  - `PREREQFEAT2`
  - `REQSKILL`
  - `REQSKILL2`
  - `OrReqFeat0..OrReqFeat4`
- Documented the helpers in [README.md](/home/vicky/Projects/nwnee-shadowsoverwestgate/module/topdata/wiki/README.md:53).
- Added coverage in [wiki_native_test.go](/home/vicky/Projects/nwnee-shadowsoverwestgate/toolkit/internal/topdata/wiki_native_test.go:646).

**Configuration/Schema Impact**
- No schema file change. `visibility.yaml` can now use the two new expression helpers.

**Compatibility Impact**
- Existing visibility expressions are unchanged.
- Missing or `****` prerequisite fields are ignored.
- Present references that resolve to hidden or missing wiki rows are treated as unreachable.

**Tests/Validation**
- Confirmed the new test failed before implementation on unknown helper.
- Ran `go test ./internal/topdata -run TestWikiVisibility`.
- Ran `go test ./internal/topdata`.
- Rebuilt toolkit with `./build-tool.sh`.
- Ran module wiki build with local toolkit:
  `SOW_TOOLS_DEV_BINARY=../toolkit/tools/sow-toolkit ./build-wiki.sh`
  Result: `wiki pages: 771`.
This commit is contained in:
2026-05-21 16:04:18 +02:00
parent f57a3c6ff2
commit 23ef63ca7b
2 changed files with 134 additions and 0 deletions
+84
View File
@@ -643,6 +643,90 @@ datasets:
} }
} }
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 TestWikiVisibilityMetadataOverrideCannotBypassEligibility(t *testing.T) { func TestWikiVisibilityMetadataOverrideCannotBypassEligibility(t *testing.T) {
policy, err := parseWikiVisibilityDefinitions([]byte(` policy, err := parseWikiVisibilityDefinitions([]byte(`
datasets: datasets:
+50
View File
@@ -911,11 +911,61 @@ func (p *wikiExprParser) callHelper(name string, args []any) (any, error) {
} }
} }
return false, nil return false, nil
case "hidden_ref":
if len(args) != 2 {
return nil, fmt.Errorf("hidden_ref expects 2 arguments")
}
hidden, err := p.hiddenWikiReference(args[0], stringifyWikiTableValue(args[1]))
if err != nil {
return nil, err
}
return hidden, nil
case "all_hidden_refs":
if len(args) < 2 {
return nil, fmt.Errorf("all_hidden_refs expects at least 2 arguments")
}
targetDataset := stringifyWikiTableValue(args[len(args)-1])
checked := false
for _, value := range args[:len(args)-1] {
if !presentWikiValue(value) {
continue
}
checked = true
hidden, err := p.hiddenWikiReference(value, targetDataset)
if err != nil {
return nil, err
}
if !hidden {
return false, nil
}
}
return checked, nil
default: default:
return nil, fmt.Errorf("unknown helper %q", name) return nil, fmt.Errorf("unknown helper %q", name)
} }
} }
func (p *wikiExprParser) hiddenWikiReference(value any, targetDataset string) (bool, error) {
targetDataset = strings.TrimSpace(targetDataset)
if !isWikiVisibilityDataset(targetDataset) {
return false, fmt.Errorf("unknown reference dataset %q", targetDataset)
}
if !presentWikiValue(value) {
return false, nil
}
if p.ctx == nil {
return false, nil
}
key := resolveReferenceKey(value, p.ctx.wikiIDMapForDataset(targetDataset))
if key == "" {
return true, nil
}
if !strings.HasPrefix(key, targetDataset+":") {
return true, nil
}
return !p.ctx.canReferenceWikiKey(key), nil
}
func presentWikiValue(value any) bool { func presentWikiValue(value any) bool {
if isNullLike(value) { if isNullLike(value) {
return false return false