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>
88 lines
2.8 KiB
Go
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")
|
|
}
|
|
}
|