From 2586b9193e556a9ed3aba782d0c081b485898483 Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Fri, 26 Jun 2026 13:18:20 +0200 Subject: [PATCH] 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//, 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 --- internal/project/project.go | 9 ++ internal/project/project_test.go | 14 +++ internal/topdata/autogen.go | 121 ++++++++++++++++--- internal/topdata/autogen_cdn_channel_test.go | 30 +++++ internal/topdata/native.go | 10 +- internal/topdata/parity_guard_test.go | 44 +++++++ internal/topdata/parts_cdn_channel_test.go | 87 +++++++++++++ internal/topdata/parts_discovery.go | 18 ++- internal/topdata/parts_override_test.go | 51 ++++++++ 9 files changed, 363 insertions(+), 21 deletions(-) create mode 100644 internal/topdata/parts_cdn_channel_test.go create mode 100644 internal/topdata/parts_override_test.go diff --git a/internal/project/project.go b/internal/project/project.go index 50a8b55..ee843ee 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -1581,6 +1581,15 @@ func validateAutogenConfig(cfg AutogenConfig) []error { func validateAutogenConsumerSources(consumers []AutogenConsumerConfig) []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 { if strings.TrimSpace(c.Source.Kind) != "cdn_channel" { continue diff --git a/internal/project/project_test.go b/internal/project/project_test.go index b68e921..8ceaa0e 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -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 { proj, err := Load(root) if err != nil { diff --git a/internal/topdata/autogen.go b/internal/topdata/autogen.go index 7513f44..65334c7 100644 --- a/internal/topdata/autogen.go +++ b/internal/topdata/autogen.go @@ -299,23 +299,34 @@ func resolveCDNChannelManifest(p *project.Project, consumer project.AutogenConsu progress = func(string) {} } - // 1. Offline / air-gapped override: a vfxs.yml path or a manifest-repo - // checkout root containing assets/vfxs.yml. Skips the network entirely. + // Manifest basename + provenance label are derived from config, not hardcoded, + // 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/. Skips the network entirely. if envName := strings.TrimSpace(src.OfflineOverrideEnv); envName != "" { if override := strings.TrimSpace(os.Getenv(envName)); override != "" { - vfxsPath := override + manifestPath := override 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 { - 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) if err != nil { 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 } } @@ -359,13 +370,13 @@ func resolveCDNChannelManifest(p *project.Project, consumer project.AutogenConsu if tag == "" { 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) vstatus, vbody, err := httpGetStatus(manifestURL) 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 { case http.StatusOK: @@ -377,27 +388,103 @@ func resolveCDNChannelManifest(p *project.Project, consumer project.AutogenConsu if marker := strings.TrimSpace(src.ReleaseMarkerPath); marker != "" { markerURL := join(marker, tag) 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: - 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) 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 } -// 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//.../.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// for the consumer's 4 accessory groups, stripping the leading // 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. -func filterCDNChannelEntries(raw []byte, consumer project.AutogenConsumerConfig) ([]autogenManifestEntry, error) { +func filterVFXCDNChannelEntries(raw []byte, consumer project.AutogenConsumerConfig) ([]autogenManifestEntry, error) { var manifest struct { Assets *[]struct { Path string `yaml:"path"` diff --git a/internal/topdata/autogen_cdn_channel_test.go b/internal/topdata/autogen_cdn_channel_test.go index efa042a..2a41f0a 100644 --- a/internal/topdata/autogen_cdn_channel_test.go +++ b/internal/topdata/autogen_cdn_channel_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" "path/filepath" "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 { return errors.Is(err, errAutogenManifestUnavailable) } diff --git a/internal/topdata/native.go b/internal/topdata/native.go index d28964d..e689f3d 100644 --- a/internal/topdata/native.go +++ b/internal/topdata/native.go @@ -460,7 +460,15 @@ func buildNativeUnchecked(p *project.Project, opts NativeBuildOptions, progress if err != nil { 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 { return BuildResult{}, err } diff --git a/internal/topdata/parity_guard_test.go b/internal/topdata/parity_guard_test.go index 0408f85..d1e7ff6 100644 --- a/internal/topdata/parity_guard_test.go +++ b/internal/topdata/parity_guard_test.go @@ -1,6 +1,8 @@ package topdata import ( + "os" + "path/filepath" "testing" "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) } } + +// 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) + } +} diff --git a/internal/topdata/parts_cdn_channel_test.go b/internal/topdata/parts_cdn_channel_test.go new file mode 100644 index 0000000..5260594 --- /dev/null +++ b/internal/topdata/parts_cdn_channel_test.go @@ -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") + } +} diff --git a/internal/topdata/parts_discovery.go b/internal/topdata/parts_discovery.go index 87ad0d5..7ef316a 100644 --- a/internal/topdata/parts_discovery.go +++ b/internal/topdata/parts_discovery.go @@ -21,6 +21,7 @@ var supportedPartCategories = []string{ "chest", "foot", "forearm", + "hand", "leg", "neck", "pelvis", @@ -35,6 +36,7 @@ var partDatasetToAssetCategory = map[string]string{ "chest": "chest", "foot": "foot", "forearm": "forearm", + "hand": "hand", "legs": "leg", "neck": "neck", "pelvis": "pelvis", @@ -390,6 +392,18 @@ func applyPartOverrides(sourceDir string, collected []nativeCollectedDataset) ([ 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) { result := make([]nativeCollectedDataset, len(collected)) copy(result, collected) @@ -430,9 +444,7 @@ func applyPartOverridesWithConfig(sourceDir string, collected []nativeCollectedD } row, ok := rowByID[rowID] if !ok { - row = createDefaultPartRowForDataset(rowID, dataset.Dataset.Name, dataset.Columns, cfg) - rows = append(rows, row) - rowByID[rowID] = row + 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) } for field, value := range override { if field == "id" { diff --git a/internal/topdata/parts_override_test.go b/internal/topdata/parts_override_test.go new file mode 100644 index 0000000..c3d55ba --- /dev/null +++ b/internal/topdata/parts_override_test.go @@ -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) + } +}