feat(topdata): parts autogen from CDN part.yml channel
Phase 2 of the parts-row autogen design: Crucible 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). - generalize resolveCDNChannelManifest: derive manifest basename + provenance label from config; drop hardcoded assets/vfxs.yml so part.yml resolves too - add filterPartsCDNChannelEntries + mode dispatch implementing the inventory contract (restype mdl under part/<cat>/, trailing-digit row id, dedup l/r/race/gender variants, sort by source, zero accepted rows = hard fail) - support hand category + parts/hand dataset mapping (12 datasets total) - native pipeline: single augment -> normalize -> override sequence with the configured parts_rows policy; overrides may update but never synthesize a row - reject multiple parts_rows consumers in project validation - tests: parts filter contract, category coverage, offline basename, orphan override reject, and a parity guard proving a bare CDN-only parts build Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1581,6 +1581,15 @@ func validateAutogenConfig(cfg AutogenConfig) []error {
|
|||||||
|
|
||||||
func validateAutogenConsumerSources(consumers []AutogenConsumerConfig) []error {
|
func validateAutogenConsumerSources(consumers []AutogenConsumerConfig) []error {
|
||||||
var failures []error
|
var failures []error
|
||||||
|
partsRows := 0
|
||||||
|
for _, c := range consumers {
|
||||||
|
if strings.TrimSpace(c.Mode) == "parts_rows" {
|
||||||
|
partsRows++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if partsRows > 1 {
|
||||||
|
failures = append(failures, fmt.Errorf("at most one autogen consumer may use mode parts_rows; found %d (override precedence would be ambiguous)", partsRows))
|
||||||
|
}
|
||||||
for _, c := range consumers {
|
for _, c := range consumers {
|
||||||
if strings.TrimSpace(c.Source.Kind) != "cdn_channel" {
|
if strings.TrimSpace(c.Source.Kind) != "cdn_channel" {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1864,6 +1864,20 @@ func TestValidateAutogenConsumerSourcesCDNChannel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateRejectsMultiplePartsRowsConsumers(t *testing.T) {
|
||||||
|
consumers := []AutogenConsumerConfig{
|
||||||
|
{ID: "parts-a", Mode: "parts_rows"},
|
||||||
|
{ID: "parts-b", Mode: "parts_rows"},
|
||||||
|
}
|
||||||
|
if failures := validateAutogenConsumerSources(consumers); len(failures) == 0 {
|
||||||
|
t.Fatal("expected error for two parts_rows consumers")
|
||||||
|
}
|
||||||
|
single := []AutogenConsumerConfig{{ID: "parts", Mode: "parts_rows"}}
|
||||||
|
if failures := validateAutogenConsumerSources(single); len(failures) != 0 {
|
||||||
|
t.Fatalf("single parts_rows consumer must validate, got %v", failures)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func loadAndValidate(root string) error {
|
func loadAndValidate(root string) error {
|
||||||
proj, err := Load(root)
|
proj, err := Load(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+104
-17
@@ -299,23 +299,34 @@ func resolveCDNChannelManifest(p *project.Project, consumer project.AutogenConsu
|
|||||||
progress = func(string) {}
|
progress = func(string) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Offline / air-gapped override: a vfxs.yml path or a manifest-repo
|
// Manifest basename + provenance label are derived from config, not hardcoded,
|
||||||
// checkout root containing assets/vfxs.yml. Skips the network entirely.
|
// so the same resolver serves vfxs.yml (accessory VFX) and part.yml (parts).
|
||||||
|
manifestBase := filepath.Base(filepath.FromSlash(strings.TrimSpace(src.ManifestPath)))
|
||||||
|
if manifestBase == "." || manifestBase == "" || manifestBase == string(filepath.Separator) {
|
||||||
|
manifestBase = "manifest.yml"
|
||||||
|
}
|
||||||
|
label := strings.TrimSpace(consumer.ID)
|
||||||
|
if label == "" {
|
||||||
|
label = "autogen"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Offline / air-gapped override: a manifest file path or a manifest-repo
|
||||||
|
// checkout root containing assets/<manifestBase>. Skips the network entirely.
|
||||||
if envName := strings.TrimSpace(src.OfflineOverrideEnv); envName != "" {
|
if envName := strings.TrimSpace(src.OfflineOverrideEnv); envName != "" {
|
||||||
if override := strings.TrimSpace(os.Getenv(envName)); override != "" {
|
if override := strings.TrimSpace(os.Getenv(envName)); override != "" {
|
||||||
vfxsPath := override
|
manifestPath := override
|
||||||
if info, err := os.Stat(override); err == nil && info.IsDir() {
|
if info, err := os.Stat(override); err == nil && info.IsDir() {
|
||||||
vfxsPath = filepath.Join(override, "assets", "vfxs.yml")
|
manifestPath = filepath.Join(override, "assets", manifestBase)
|
||||||
}
|
}
|
||||||
raw, err := os.ReadFile(vfxsPath)
|
raw, err := os.ReadFile(manifestPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("offline vfxs override %s: %w", vfxsPath, err)
|
return nil, fmt.Errorf("offline %s override %s: %w", manifestBase, manifestPath, err)
|
||||||
}
|
}
|
||||||
entries, err := filterCDNChannelEntries(raw, consumer)
|
entries, err := filterCDNChannelEntries(raw, consumer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
progress(fmt.Sprintf("Using offline vfxs override for %s from %s...", consumer.ID, vfxsPath))
|
progress(fmt.Sprintf("Using offline %s override for %s from %s...", manifestBase, consumer.ID, manifestPath))
|
||||||
return &autogenManifest{ID: consumer.Producer, Ref: "local", Entries: entries}, nil
|
return &autogenManifest{ID: consumer.Producer, Ref: "local", Entries: entries}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -359,13 +370,13 @@ func resolveCDNChannelManifest(p *project.Project, consumer project.AutogenConsu
|
|||||||
if tag == "" {
|
if tag == "" {
|
||||||
return nil, unavailableAutogenManifest(fmt.Errorf("channel %q absent in channels.json (%s)", channel, channelsURL))
|
return nil, unavailableAutogenManifest(fmt.Errorf("channel %q absent in channels.json (%s)", channel, channelsURL))
|
||||||
}
|
}
|
||||||
progress(fmt.Sprintf("Accessory VFX: channel %s -> tag %s", channel, tag))
|
progress(fmt.Sprintf("%s: channel %s -> tag %s", label, channel, tag))
|
||||||
|
|
||||||
// 5. vfxs.yml for the tag.
|
// 5. per-tag manifest for the tag.
|
||||||
manifestURL := join(src.ManifestPath, tag)
|
manifestURL := join(src.ManifestPath, tag)
|
||||||
vstatus, vbody, err := httpGetStatus(manifestURL)
|
vstatus, vbody, err := httpGetStatus(manifestURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, unavailableAutogenManifest(fmt.Errorf("vfxs.yml unreachable %s: %w", manifestURL, err))
|
return nil, unavailableAutogenManifest(fmt.Errorf("%s unreachable %s: %w", manifestBase, manifestURL, err))
|
||||||
}
|
}
|
||||||
switch vstatus {
|
switch vstatus {
|
||||||
case http.StatusOK:
|
case http.StatusOK:
|
||||||
@@ -377,27 +388,103 @@ func resolveCDNChannelManifest(p *project.Project, consumer project.AutogenConsu
|
|||||||
if marker := strings.TrimSpace(src.ReleaseMarkerPath); marker != "" {
|
if marker := strings.TrimSpace(src.ReleaseMarkerPath); marker != "" {
|
||||||
markerURL := join(marker, tag)
|
markerURL := join(marker, tag)
|
||||||
if mstatus, _, merr := httpGetStatus(markerURL); merr == nil && mstatus == http.StatusOK {
|
if mstatus, _, merr := httpGetStatus(markerURL); merr == nil && mstatus == http.StatusOK {
|
||||||
return nil, fmt.Errorf("release %s has %s but no vfxs.yml (%s) — accessory rows would silently vanish; backfill/republish vfxs.yml for %s", tag, marker, manifestURL, tag)
|
return nil, fmt.Errorf("release %s has %s but no %s (%s) — rows would silently vanish; backfill/republish %s for %s", tag, marker, manifestBase, manifestURL, manifestBase, tag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil, unavailableAutogenManifest(fmt.Errorf("vfxs.yml 404 %s (no published release for %s)", manifestURL, tag))
|
return nil, unavailableAutogenManifest(fmt.Errorf("%s 404 %s (no published release for %s)", manifestBase, manifestURL, tag))
|
||||||
default:
|
default:
|
||||||
return nil, unavailableAutogenManifest(fmt.Errorf("vfxs.yml HTTP %d %s", vstatus, manifestURL))
|
return nil, unavailableAutogenManifest(fmt.Errorf("%s HTTP %d %s", manifestBase, vstatus, manifestURL))
|
||||||
}
|
}
|
||||||
|
|
||||||
entries, err := filterCDNChannelEntries(vbody, consumer)
|
entries, err := filterCDNChannelEntries(vbody, consumer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err // malformed vfxs.yml = HARD fail
|
return nil, err // malformed manifest = HARD fail
|
||||||
}
|
}
|
||||||
progress(fmt.Sprintf("Accessory VFX: resolved %d model entries from %s", len(entries), manifestURL))
|
progress(fmt.Sprintf("%s: resolved %d model entries from %s", label, len(entries), manifestURL))
|
||||||
return &autogenManifest{ID: consumer.Producer, Ref: tag, Entries: entries}, nil
|
return &autogenManifest{ID: consumer.Producer, Ref: tag, Entries: entries}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// filterCDNChannelEntries parses vfxs.yml and keeps restype==mdl assets under
|
// filterCDNChannelEntries dispatches per-tag manifest parsing on the consumer
|
||||||
|
// mode. parts_rows consumes part.yml (the parts inventory contract); every other
|
||||||
|
// mode consumes vfxs.yml (the accessory-VFX contract).
|
||||||
|
func filterCDNChannelEntries(raw []byte, consumer project.AutogenConsumerConfig) ([]autogenManifestEntry, error) {
|
||||||
|
switch strings.TrimSpace(consumer.Mode) {
|
||||||
|
case "parts_rows":
|
||||||
|
return filterPartsCDNChannelEntries(raw)
|
||||||
|
default:
|
||||||
|
return filterVFXCDNChannelEntries(raw, consumer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterPartsCDNChannelEntries parses part.yml and keeps restype==mdl assets
|
||||||
|
// under part/<supported-category>/.../<stem><digits>.mdl. The asset category is
|
||||||
|
// the path segment after part/; the row ID is the trailing decimal of the
|
||||||
|
// filename stem. Body/race/gender/left-right variants dedup by (category,rowID).
|
||||||
|
// Zero accepted rows is a HARD fail (silent-drop guard).
|
||||||
|
func filterPartsCDNChannelEntries(raw []byte) ([]autogenManifestEntry, error) {
|
||||||
|
var manifest struct {
|
||||||
|
Assets *[]struct {
|
||||||
|
Path string `yaml:"path"`
|
||||||
|
Restype string `yaml:"restype"`
|
||||||
|
} `yaml:"assets"`
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal(raw, &manifest); err != nil {
|
||||||
|
return nil, fmt.Errorf("malformed part.yml (cannot parse): %w", err)
|
||||||
|
}
|
||||||
|
if manifest.Assets == nil {
|
||||||
|
return nil, fmt.Errorf("malformed part.yml (no assets array)")
|
||||||
|
}
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
var entries []autogenManifestEntry
|
||||||
|
for _, a := range *manifest.Assets {
|
||||||
|
if a.Restype != "mdl" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := filepath.ToSlash(strings.TrimSpace(a.Path))
|
||||||
|
if !strings.HasPrefix(path, "part/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rel := strings.TrimPrefix(path, "part/")
|
||||||
|
segs := strings.Split(rel, "/")
|
||||||
|
if len(segs) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
category := segs[0]
|
||||||
|
if !isSupportedPartCategory(category) {
|
||||||
|
continue // ignores _masters, cloak, head, helm, tail, wings
|
||||||
|
}
|
||||||
|
stem := strings.TrimSuffix(segs[len(segs)-1], ".mdl")
|
||||||
|
match := trailingNumberRegex.FindString(stem)
|
||||||
|
if match == "" {
|
||||||
|
return nil, fmt.Errorf("part.yml: supported-category model %q has no trailing row number", path)
|
||||||
|
}
|
||||||
|
rowID, err := strconv.Atoi(match)
|
||||||
|
if err != nil || rowID == 0 {
|
||||||
|
return nil, fmt.Errorf("part.yml: supported-category model %q resolves to invalid row id %q", path, match)
|
||||||
|
}
|
||||||
|
key := category + "/" + strconv.Itoa(rowID)
|
||||||
|
if _, dup := seen[key]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
entries = append(entries, autogenManifestEntry{
|
||||||
|
Source: rel, Group: category, ModelStem: stem, RowID: rowID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return nil, fmt.Errorf("part.yml: zero supported part rows after filtering")
|
||||||
|
}
|
||||||
|
slices.SortFunc(entries, func(a, b autogenManifestEntry) int {
|
||||||
|
return strings.Compare(a.Source, b.Source)
|
||||||
|
})
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterVFXCDNChannelEntries parses vfxs.yml and keeps restype==mdl assets under
|
||||||
// vfxs/<group>/ for the consumer's 4 accessory groups, stripping the leading
|
// vfxs/<group>/ for the consumer's 4 accessory groups, stripping the leading
|
||||||
// vfxs/ from each source path. A present-but-malformed manifest (unparseable, or
|
// vfxs/ from each source path. A present-but-malformed manifest (unparseable, or
|
||||||
// no assets array) is an error; an empty assets array yields zero entries.
|
// no assets array) is an error; an empty assets array yields zero entries.
|
||||||
func filterCDNChannelEntries(raw []byte, consumer project.AutogenConsumerConfig) ([]autogenManifestEntry, error) {
|
func filterVFXCDNChannelEntries(raw []byte, consumer project.AutogenConsumerConfig) ([]autogenManifestEntry, error) {
|
||||||
var manifest struct {
|
var manifest struct {
|
||||||
Assets *[]struct {
|
Assets *[]struct {
|
||||||
Path string `yaml:"path"`
|
Path string `yaml:"path"`
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -194,6 +195,35 @@ func TestResolveCDNChannelOfflineOverride(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
func errorIsUnavailable(err error) bool {
|
||||||
return errors.Is(err, errAutogenManifestUnavailable)
|
return errors.Is(err, errAutogenManifestUnavailable)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -460,7 +460,15 @@ func buildNativeUnchecked(p *project.Project, opts NativeBuildOptions, progress
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return BuildResult{}, err
|
return BuildResult{}, err
|
||||||
}
|
}
|
||||||
collected, err = applyPartOverrides(sourceDir, collected)
|
// Single explicit parts sequence: augment (inside applyAutogenConsumers) ->
|
||||||
|
// normalize -> apply overrides exactly once, all under the one configured
|
||||||
|
// parts_rows consumer's policy.
|
||||||
|
partsCfg := partsRowsConfigForProject(p)
|
||||||
|
collected, err = normalizePartsRowsACBonus(collected, partsCfg)
|
||||||
|
if err != nil {
|
||||||
|
return BuildResult{}, err
|
||||||
|
}
|
||||||
|
collected, err = applyPartOverridesWithConfig(sourceDir, collected, partsCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return BuildResult{}, err
|
return BuildResult{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package topdata
|
package topdata
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
|
||||||
@@ -54,3 +56,45 @@ func TestParityGuardCDNChannelProducesAccessoryRows(t *testing.T) {
|
|||||||
t.Fatalf("PARITY GUARD: expected lock id for %s, got %#v", wantKey, got[0].LockData)
|
t.Fatalf("PARITY GUARD: expected lock id for %s, got %#v", wantKey, got[0].LockData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PARITY GUARD (parts): a bare project whose only parts source is the offline
|
||||||
|
// CDN-equivalent (SOW_PART_MANIFEST -> a part.yml), with no NWN_ROOT, no token,
|
||||||
|
// no asset checkout, must produce parts rows from the filtered inventory. If
|
||||||
|
// parts resolution ever drifts back into a wrapper, this fails.
|
||||||
|
func TestParityGuardCDNChannelProducesPartsRows(t *testing.T) {
|
||||||
|
root := testProjectRoot(t)
|
||||||
|
checkout := filepath.Join(root, "manifest-checkout")
|
||||||
|
if err := os.MkdirAll(filepath.Join(checkout, "assets"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeFile(t, filepath.Join(checkout, "assets", "part.yml"),
|
||||||
|
"assets:\n - {path: part/belt/m/pfa0_belt017.mdl, restype: mdl}\n - {path: part/belt/m/pfa0_belt018.mdl, restype: mdl}\n")
|
||||||
|
t.Setenv("SOW_PART_MANIFEST", checkout)
|
||||||
|
t.Setenv("NWN_ROOT", "")
|
||||||
|
|
||||||
|
c := projectPartsConsumer()
|
||||||
|
c.Source = project.AutogenSourceConfig{
|
||||||
|
Kind: "cdn_channel",
|
||||||
|
ChannelsPath: "releases/haks/channels.json",
|
||||||
|
ManifestPath: "releases/haks/{tag}/part.yml",
|
||||||
|
ReleaseMarkerPath: "releases/haks/{tag}/haks.json",
|
||||||
|
OfflineOverrideEnv: "SOW_PART_MANIFEST",
|
||||||
|
}
|
||||||
|
|
||||||
|
p := testProject(root)
|
||||||
|
p.Config.Autogen.Consumers = []project.AutogenConsumerConfig{c}
|
||||||
|
|
||||||
|
collected := []nativeCollectedDataset{partsDatasetWithIDs("parts/belt", nil)}
|
||||||
|
|
||||||
|
got, err := applyAutogenConsumers(p, collected, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("applyAutogenConsumers failed: %v", err)
|
||||||
|
}
|
||||||
|
ids := map[int]bool{}
|
||||||
|
for _, row := range got[0].Rows {
|
||||||
|
ids[row["id"].(int)] = true
|
||||||
|
}
|
||||||
|
if !ids[17] || !ids[18] {
|
||||||
|
t.Fatalf("PARITY GUARD: expected belt rows 17 and 18 from inventory, got %#v", got[0].Rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ var supportedPartCategories = []string{
|
|||||||
"chest",
|
"chest",
|
||||||
"foot",
|
"foot",
|
||||||
"forearm",
|
"forearm",
|
||||||
|
"hand",
|
||||||
"leg",
|
"leg",
|
||||||
"neck",
|
"neck",
|
||||||
"pelvis",
|
"pelvis",
|
||||||
@@ -35,6 +36,7 @@ var partDatasetToAssetCategory = map[string]string{
|
|||||||
"chest": "chest",
|
"chest": "chest",
|
||||||
"foot": "foot",
|
"foot": "foot",
|
||||||
"forearm": "forearm",
|
"forearm": "forearm",
|
||||||
|
"hand": "hand",
|
||||||
"legs": "leg",
|
"legs": "leg",
|
||||||
"neck": "neck",
|
"neck": "neck",
|
||||||
"pelvis": "pelvis",
|
"pelvis": "pelvis",
|
||||||
@@ -390,6 +392,18 @@ func applyPartOverrides(sourceDir string, collected []nativeCollectedDataset) ([
|
|||||||
return applyPartOverridesWithConfig(sourceDir, collected, project.PartsRowsConfig{})
|
return applyPartOverridesWithConfig(sourceDir, collected, project.PartsRowsConfig{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// partsRowsConfigForProject returns the PartsRows policy of the single configured
|
||||||
|
// parts_rows autogen consumer, or the zero value if none. Project validation
|
||||||
|
// guarantees at most one parts_rows consumer, so the first match is canonical.
|
||||||
|
func partsRowsConfigForProject(p *project.Project) project.PartsRowsConfig {
|
||||||
|
for _, c := range p.Config.Autogen.Consumers {
|
||||||
|
if strings.TrimSpace(c.Mode) == "parts_rows" {
|
||||||
|
return c.PartsRows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return project.PartsRowsConfig{}
|
||||||
|
}
|
||||||
|
|
||||||
func applyPartOverridesWithConfig(sourceDir string, collected []nativeCollectedDataset, cfg project.PartsRowsConfig) ([]nativeCollectedDataset, error) {
|
func applyPartOverridesWithConfig(sourceDir string, collected []nativeCollectedDataset, cfg project.PartsRowsConfig) ([]nativeCollectedDataset, error) {
|
||||||
result := make([]nativeCollectedDataset, len(collected))
|
result := make([]nativeCollectedDataset, len(collected))
|
||||||
copy(result, collected)
|
copy(result, collected)
|
||||||
@@ -430,9 +444,7 @@ func applyPartOverridesWithConfig(sourceDir string, collected []nativeCollectedD
|
|||||||
}
|
}
|
||||||
row, ok := rowByID[rowID]
|
row, ok := rowByID[rowID]
|
||||||
if !ok {
|
if !ok {
|
||||||
row = createDefaultPartRowForDataset(rowID, dataset.Dataset.Name, dataset.Columns, cfg)
|
return nil, fmt.Errorf("parts/%s override %d targets row id %d which is neither a baseline row nor a discovered model row; author the row in data/parts/%s.json instead", category, index, rowID, category)
|
||||||
rows = append(rows, row)
|
|
||||||
rowByID[rowID] = row
|
|
||||||
}
|
}
|
||||||
for field, value := range override {
|
for field, value := range override {
|
||||||
if field == "id" {
|
if field == "id" {
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package topdata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
|
||||||
|
)
|
||||||
|
|
||||||
|
func partsDatasetWithIDs(name string, ids []int) nativeCollectedDataset {
|
||||||
|
rows := make([]map[string]any, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
rows = append(rows, map[string]any{"id": id, "COSTMODIFIER": "0", "ACBONUS": "0.00"})
|
||||||
|
}
|
||||||
|
return nativeCollectedDataset{
|
||||||
|
Dataset: nativeDataset{Name: name, Kind: nativeDatasetBase},
|
||||||
|
Columns: []string{"COSTMODIFIER", "ACBONUS"},
|
||||||
|
Rows: rows,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyPartOverridesRejectsOrphan(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, "data", "parts", "overrides"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeFile(t, filepath.Join(dir, "data", "parts", "overrides", "belt.json"),
|
||||||
|
`{"overrides": [{"id": 999, "COSTMODIFIER": "5"}]}`)
|
||||||
|
collected := []nativeCollectedDataset{partsDatasetWithIDs("parts/belt", []int{17})}
|
||||||
|
if _, err := applyPartOverridesWithConfig(dir, collected, project.PartsRowsConfig{}); err == nil {
|
||||||
|
t.Fatal("expected orphan override to fail, not synthesize a row")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyPartOverridesUpdatesDiscoveredRow(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, "data", "parts", "overrides"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeFile(t, filepath.Join(dir, "data", "parts", "overrides", "belt.json"),
|
||||||
|
`{"overrides": [{"id": 17, "COSTMODIFIER": "5"}]}`)
|
||||||
|
collected := []nativeCollectedDataset{partsDatasetWithIDs("parts/belt", []int{17})}
|
||||||
|
out, err := applyPartOverridesWithConfig(dir, collected, project.PartsRowsConfig{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("override: %v", err)
|
||||||
|
}
|
||||||
|
if got := out[0].Rows[0]["COSTMODIFIER"]; got != "5" {
|
||||||
|
t.Fatalf("row 17 COSTMODIFIER want 5, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user