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>
230 lines
7.5 KiB
Go
230 lines
7.5 KiB
Go
package topdata
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
|
|
)
|
|
|
|
func cdnConsumer() project.AutogenConsumerConfig {
|
|
return project.AutogenConsumerConfig{
|
|
ID: "accessory_visualeffects", Producer: "accessory_visualeffects",
|
|
Dataset: "visualeffects", Mode: "accessory_visualeffects", Optional: true,
|
|
AccessoryVisualeffects: project.AccessoryVisualeffectsConfig{
|
|
Groups: map[string]project.AccessoryVisualeffectGroupConfig{
|
|
"head_accessories": {}, "chest_accessories": {},
|
|
"head_decorations": {}, "head_features": {},
|
|
},
|
|
},
|
|
Source: project.AutogenSourceConfig{
|
|
Kind: "cdn_channel",
|
|
ChannelsPath: "releases/haks/channels.json",
|
|
ManifestPath: "releases/haks/{tag}/vfxs.yml",
|
|
ReleaseMarkerPath: "releases/haks/{tag}/haks.json",
|
|
},
|
|
}
|
|
}
|
|
|
|
// cdnServer serves channels.json, vfxs.yml, and haks.json from in-memory maps.
|
|
// A key absent from the map returns 404.
|
|
func cdnServer(t *testing.T, files map[string]string) *httptest.Server {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, ok := files[r.URL.Path]
|
|
if !ok {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
fmt.Fprint(w, body)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
func TestResolveCDNChannelHappyPath(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
srv := cdnServer(t, map[string]string{
|
|
"/releases/haks/channels.json": `{"current":"v1.2.3"}`,
|
|
"/releases/haks/v1.2.3/vfxs.yml": `assets:
|
|
- path: vfxs/head_accessories/hat/hfx_bandana.mdl
|
|
restype: mdl
|
|
sha256: aaa
|
|
- path: vfxs/chest_accessories/cape/cfx_cloak.mdl
|
|
restype: mdl
|
|
sha256: bbb
|
|
- path: vfxs/head_accessories/hat/hfx_bandana.tga
|
|
restype: tga
|
|
sha256: ccc
|
|
- path: vfxs/weapons/sword.mdl
|
|
restype: mdl
|
|
sha256: ddd
|
|
`,
|
|
})
|
|
t.Setenv("BUNNY_CDN_BASE", srv.URL)
|
|
t.Setenv("SOW_TOPDATA_ASSET_CHANNEL", "current")
|
|
|
|
p := testProject(root)
|
|
c := cdnConsumer()
|
|
c.Source.CDNBaseEnv = "BUNNY_CDN_BASE"
|
|
c.Source.ChannelEnv = "SOW_TOPDATA_ASSET_CHANNEL"
|
|
|
|
m, err := resolveCDNChannelManifest(p, c, c.Source, nil)
|
|
if err != nil {
|
|
t.Fatalf("resolve failed: %v", err)
|
|
}
|
|
if len(m.Entries) != 2 {
|
|
t.Fatalf("want 2 mdl entries under target groups, got %d: %#v", len(m.Entries), m.Entries)
|
|
}
|
|
// Sorted by source: chest_accessories/... before head_accessories/...
|
|
if m.Entries[0].Source != "chest_accessories/cape/cfx_cloak.mdl" {
|
|
t.Fatalf("unexpected first entry: %#v", m.Entries[0])
|
|
}
|
|
if m.Entries[1].Source != "head_accessories/hat/hfx_bandana.mdl" ||
|
|
m.Entries[1].Group != "head_accessories" ||
|
|
m.Entries[1].Subgroup != "hat" ||
|
|
m.Entries[1].ModelStem != "hfx_bandana" {
|
|
t.Fatalf("unexpected derived entry: %#v", m.Entries[1])
|
|
}
|
|
if m.Ref != "v1.2.3" {
|
|
t.Fatalf("want ref v1.2.3, got %q", m.Ref)
|
|
}
|
|
}
|
|
|
|
func TestResolveCDNChannelFailOpen(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
c := cdnConsumer()
|
|
c.Source.CDNBaseEnv = "BUNNY_CDN_BASE"
|
|
c.Source.ChannelEnv = "SOW_TOPDATA_ASSET_CHANNEL"
|
|
t.Setenv("SOW_TOPDATA_ASSET_CHANNEL", "current")
|
|
|
|
t.Run("channels.json unreachable", func(t *testing.T) {
|
|
t.Setenv("BUNNY_CDN_BASE", "http://127.0.0.1:0") // unroutable
|
|
_, err := resolveCDNChannelManifest(testProject(root), c, c.Source, nil)
|
|
if err == nil || !errorIsUnavailable(err) {
|
|
t.Fatalf("want fail-open (unavailable), got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("channel absent", func(t *testing.T) {
|
|
srv := cdnServer(t, map[string]string{"/releases/haks/channels.json": `{"testing":"v9"}`})
|
|
t.Setenv("BUNNY_CDN_BASE", srv.URL)
|
|
_, err := resolveCDNChannelManifest(testProject(root), c, c.Source, nil)
|
|
if err == nil || !errorIsUnavailable(err) {
|
|
t.Fatalf("want fail-open (unavailable), got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("vfxs 404 no release marker", func(t *testing.T) {
|
|
srv := cdnServer(t, map[string]string{"/releases/haks/channels.json": `{"current":"v1"}`})
|
|
t.Setenv("BUNNY_CDN_BASE", srv.URL)
|
|
_, err := resolveCDNChannelManifest(testProject(root), c, c.Source, nil)
|
|
if err == nil || !errorIsUnavailable(err) {
|
|
t.Fatalf("want fail-open (unavailable), got %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestResolveCDNChannelHardFail(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
c := cdnConsumer()
|
|
c.Source.CDNBaseEnv = "BUNNY_CDN_BASE"
|
|
c.Source.ChannelEnv = "SOW_TOPDATA_ASSET_CHANNEL"
|
|
t.Setenv("SOW_TOPDATA_ASSET_CHANNEL", "current")
|
|
|
|
t.Run("broken release: vfxs 404 + haks.json present", func(t *testing.T) {
|
|
srv := cdnServer(t, map[string]string{
|
|
"/releases/haks/channels.json": `{"current":"v1"}`,
|
|
"/releases/haks/v1/haks.json": `{}`,
|
|
})
|
|
t.Setenv("BUNNY_CDN_BASE", srv.URL)
|
|
_, err := resolveCDNChannelManifest(testProject(root), c, c.Source, nil)
|
|
if err == nil || errorIsUnavailable(err) {
|
|
t.Fatalf("want HARD fail, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("malformed vfxs.yml: no assets array", func(t *testing.T) {
|
|
srv := cdnServer(t, map[string]string{
|
|
"/releases/haks/channels.json": `{"current":"v1"}`,
|
|
"/releases/haks/v1/vfxs.yml": "generated_at: 2026-01-01\n",
|
|
})
|
|
t.Setenv("BUNNY_CDN_BASE", srv.URL)
|
|
_, err := resolveCDNChannelManifest(testProject(root), c, c.Source, nil)
|
|
if err == nil || errorIsUnavailable(err) {
|
|
t.Fatalf("want HARD fail, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("malformed vfxs.yml: syntactically broken YAML", func(t *testing.T) {
|
|
srv := cdnServer(t, map[string]string{
|
|
"/releases/haks/channels.json": `{"current":"v1"}`,
|
|
"/releases/haks/v1/vfxs.yml": "assets:\n - path: [unterminated",
|
|
})
|
|
t.Setenv("BUNNY_CDN_BASE", srv.URL)
|
|
_, err := resolveCDNChannelManifest(testProject(root), c, c.Source, nil)
|
|
if err == nil || errorIsUnavailable(err) {
|
|
t.Fatalf("want HARD fail on broken YAML, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestResolveCDNChannelOfflineOverride(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
vfxs := filepath.Join(root, "vfxs.yml")
|
|
writeFile(t, vfxs, `assets:
|
|
- path: vfxs/head_features/scar/hfx_scar.mdl
|
|
restype: mdl
|
|
sha256: zzz
|
|
`)
|
|
t.Setenv("SOW_VFXS_MANIFEST", vfxs)
|
|
c := cdnConsumer()
|
|
c.Source.OfflineOverrideEnv = "SOW_VFXS_MANIFEST"
|
|
|
|
m, err := resolveCDNChannelManifest(testProject(root), c, c.Source, nil)
|
|
if err != nil {
|
|
t.Fatalf("offline override failed: %v", err)
|
|
}
|
|
if len(m.Entries) != 1 || m.Entries[0].Source != "head_features/scar/hfx_scar.mdl" {
|
|
t.Fatalf("unexpected offline entries: %#v", m.Entries)
|
|
}
|
|
}
|
|
|
|
// TestResolveCDNChannelOfflineUsesManifestBasename proves the resolver derives
|
|
// the offline checkout filename from manifest_path's basename (part.yml here),
|
|
// not a hardcoded assets/vfxs.yml, and routes parts_rows through the parts filter.
|
|
func TestResolveCDNChannelOfflineUsesManifestBasename(t *testing.T) {
|
|
root := testProjectRoot(t)
|
|
dir := filepath.Join(root, "manifest-checkout")
|
|
if err := os.MkdirAll(filepath.Join(dir, "assets"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeFile(t, filepath.Join(dir, "assets", "part.yml"),
|
|
"assets:\n - {path: part/belt/m/pfa0_belt017.mdl, restype: mdl}\n")
|
|
t.Setenv("SOW_PART_MANIFEST", dir)
|
|
|
|
c := projectPartsConsumer()
|
|
c.Source = project.AutogenSourceConfig{
|
|
Kind: "cdn_channel",
|
|
ManifestPath: "releases/haks/{tag}/part.yml",
|
|
OfflineOverrideEnv: "SOW_PART_MANIFEST",
|
|
}
|
|
|
|
m, err := resolveCDNChannelManifest(testProject(root), c, c.Source, nil)
|
|
if err != nil {
|
|
t.Fatalf("resolve: %v", err)
|
|
}
|
|
if len(m.Entries) != 1 || m.Entries[0].RowID != 17 || m.Entries[0].Group != "belt" {
|
|
t.Fatalf("unexpected entries: %+v", m.Entries)
|
|
}
|
|
}
|
|
|
|
func errorIsUnavailable(err error) bool {
|
|
return errors.Is(err, errAutogenManifestUnavailable)
|
|
}
|