Files
sow-tools/internal/topdata/parts_cdn_channel_test.go
T
archvillainette 06e5893734
test / test (push) Successful in 1m26s
build-binaries / build-binaries (push) Successful in 2m12s
build-image / publish (push) Successful in 38s
feat(topdata): parts autogen from CDN part.yml channel (Phase 2) (#25)
Phase 2 of the parts-row autogen design — Crucible (sow-tools) side only.

Resolves parts 2DA rows from the immutable \`part.yml\` published with the asset HAK release, over the anonymous Bunny CDN channel, with the parts consumer **required** (no fail-open).

## Changes
- **Resolver (T3):** generalize \`resolveCDNChannelManifest\` — derive manifest basename + provenance label from config; drop hardcoded \`assets/vfxs.yml\` so \`part.yml\` resolves through the same path. VFX behavior unchanged.
- **Parts filter (T4):** \`filterCDNChannelEntries\` dispatches on consumer mode; new \`filterPartsCDNChannelEntries\` implements the inventory contract — \`restype: mdl\` under \`part/<supported-cat>/\`, row id from trailing digits, dedup l/r/race/gender variants by (category,rowID), sort by source, **zero accepted rows = hard fail**, reject row id 0 / non-numeric / malformed / no-assets.
- **Category (T5):** add \`hand\` + \`parts/hand\` mapping (12 datasets; \`leg→legs\` already present).
- **Pipeline (T6):** native build runs one explicit augment → normalize → override sequence under the configured \`parts_rows\` policy; overrides may update an existing/discovered row but **never synthesize** one (orphan override now errors).
- **Validation (T7):** reject more than one \`parts_rows\` consumer.

## Tests
9 new tests: parts filter contract, 12-dataset coverage, offline manifest-basename, orphan-override reject + discovered-row update, and a parity guard proving a bare CDN-only parts build needs no wrapper/token/NWN_ROOT/checkout.

Gate green: \`go test ./internal/topdata/... ./internal/project/...\` + full \`go build ./... && go test ./...\`.

## Scope / ordering
Phase 2 is inert until **Phase 1** (sow-assets-manifest publishes \`part.yml\`) ships and the operational **Gate** (release publish/promote) runs, then **Phase 3** enables the sow-topdata consumer. Do not enable the consumer against a channel whose current release predates \`part.yml\`.

Plan: \`sow-topdata/docs/superpowers/plans/2026-06-25-topdata-parts-autogen-channel.md\`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #25
Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
Co-committed-by: vickydotbat <vickydotbat@tutamail.com>
2026-06-26 12:12:06 +00:00

88 lines
2.8 KiB
Go

package topdata
import (
"testing"
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
)
func projectPartsConsumer() project.AutogenConsumerConfig {
return project.AutogenConsumerConfig{ID: "parts", Producer: "parts", Mode: "parts_rows"}
}
const partYML = `assets:
- {path: part/belt/m/pfa0_belt017.mdl, restype: mdl}
- {path: part/hand/m/pfh0_handl105.mdl, restype: mdl}
- {path: part/hand/m/pfh0_handr105.mdl, restype: mdl}
- {path: part/leg/m/pfa0_legl001.mdl, restype: mdl}
- {path: part/leg/m/pfa0_legr001.mdl, restype: mdl}
- {path: part/cloak/m/cloak001.mdl, restype: mdl}
- {path: part/belt/m/readme.txt, restype: txt}
`
func TestFilterPartsKeepsSupportedDedupsVariants(t *testing.T) {
consumer := projectPartsConsumer()
entries, err := filterCDNChannelEntries([]byte(partYML), consumer)
if err != nil {
t.Fatalf("filter: %v", err)
}
inv := autogenPartsInventory(entries)
if _, ok := inv["belt"][17]; !ok {
t.Errorf("missing belt 17")
}
if len(inv["hand"]) != 1 {
t.Errorf("hand should dedup l/r to 1 row, got %d", len(inv["hand"]))
}
if _, ok := inv["hand"][105]; !ok {
t.Errorf("missing hand 105")
}
if len(inv["leg"]) != 1 {
t.Errorf("leg should dedup l/r to 1 row, got %d", len(inv["leg"]))
}
if _, ok := inv["cloak"]; ok {
t.Errorf("cloak must be ignored")
}
}
func TestFilterPartsRejectsZeroAccepted(t *testing.T) {
_, err := filterCDNChannelEntries([]byte("assets:\n - {path: part/cloak/m/cloak001.mdl, restype: mdl}\n"), projectPartsConsumer())
if err == nil {
t.Fatal("expected hard error on zero accepted part rows")
}
}
func TestFilterPartsRejectsRowZeroAndNonNumeric(t *testing.T) {
for _, doc := range []string{
"assets:\n - {path: part/belt/m/pfa0_belt000.mdl, restype: mdl}\n",
"assets:\n - {path: part/belt/m/pfa0_belt.mdl, restype: mdl}\n",
} {
if _, err := filterCDNChannelEntries([]byte(doc), projectPartsConsumer()); err == nil {
t.Fatalf("expected error for %q", doc)
}
}
}
func TestFilterPartsRejectsMalformedAndMissingAssets(t *testing.T) {
if _, err := filterCDNChannelEntries([]byte("assets:\n - path: [unterminated"), projectPartsConsumer()); err == nil {
t.Fatal("expected malformed error")
}
if _, err := filterCDNChannelEntries([]byte("other: 1\n"), projectPartsConsumer()); err == nil {
t.Fatal("expected no-assets error")
}
}
func TestSupportedPartCategoriesCoverTwelveDatasets(t *testing.T) {
want := []string{"belt", "bicep", "chest", "foot", "forearm", "hand", "leg", "neck", "pelvis", "robe", "shin", "shoulder"}
for _, c := range want {
if !isSupportedPartCategory(c) {
t.Errorf("category %q not supported", c)
}
}
if partDatasetToAssetCategory["hand"] != "hand" {
t.Errorf("parts/hand must map to asset category hand")
}
if partDatasetToAssetCategory["legs"] != "leg" {
t.Errorf("parts/legs must map to asset category leg")
}
}