diff --git a/README.md b/README.md index 8ba03fe..e2589c3 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,19 @@ generated_assets: derive: kind: trailing_numeric_suffix group_from: first_path_segment + parts_rows: + row_defaults: + COSTMODIFIER: "0" + acbonus: + default: + strategy: descending_row_id_sort_key + max_row_id: 999 + divisor: 100 + format: "%.2f" + datasets: + chest: + strategy: fixed + value: "0.00" inventory: source_extensions: [] # empty means built-in NWN source extensions @@ -255,6 +268,13 @@ native-topdata subset packaged as HAK resources. It builds only `.2da` output, injects those generated files into `build-haks`, and rejects TLK-backed text because this path does not produce TLK packages. +For `parts_rows` generated assets, `parts_rows.acbonus` can normalize final +ACBONUS values before module overrides are applied. The +`descending_row_id_sort_key` strategy writes toolset ordering values from row +IDs, and per-dataset policies such as `chest: fixed 0.00` can override that +default. Authored source ACBONUS values are not authoritative when this policy +is configured; files under `parts/modules` remain the final override layer. + Extraction is deliberately guarded because it writes source files from binary archives. `extract.archives` is a list of glob patterns relative to `paths.build`; the default is only the configured module `.mod`. HAK extraction @@ -401,7 +421,7 @@ The current ownership split is: - owns the user-facing module and topdata wrapper commands - `sow-assets` - owns canonical binary asset source under `assets/` - - owns generated HAK manifests and parts manifests under `staging/` + - owns generated HAK manifests and HAK-side parts 2DA assets - owns the user-facing asset wrapper commands Consumer wrapper resolution is intentionally aligned: diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index b710328..a4637d3 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -2246,6 +2246,20 @@ func TestBuildHAKsGeneratesParts2DAAssetsFromLocalModels(t *testing.T) { "derive": { "kind": "trailing_numeric_suffix", "group_from": "first_path_segment" + }, + "parts_rows": { + "row_defaults": {"COSTMODIFIER": "0"}, + "acbonus": { + "default": { + "strategy": "descending_row_id_sort_key", + "max_row_id": 999, + "divisor": 100, + "format": "%.2f" + }, + "datasets": { + "chest": {"strategy": "fixed", "value": "0.00"} + } + } } } } @@ -2267,7 +2281,8 @@ func TestBuildHAKsGeneratesParts2DAAssetsFromLocalModels(t *testing.T) { "output": "parts_belt.2da", "columns": ["COSTMODIFIER", "ACBONUS"], "rows": [ - {"id": 0, "COSTMODIFIER": 1, "ACBONUS": "0.25"} + {"id": 0, "COSTMODIFIER": 1, "ACBONUS": "0.25"}, + {"id": 17, "COSTMODIFIER": 0, "ACBONUS": "0.90"} ] } `) @@ -2309,7 +2324,12 @@ func TestBuildHAKsGeneratesParts2DAAssetsFromLocalModels(t *testing.T) { t.Fatalf("read generated parts 2da: %v", err) } partsText := string(partsRaw) - if !strings.Contains(partsText, "0\t1\t0.25") || !strings.Contains(partsText, "18\t0\t0.75") { + for _, want := range []string{"0\t1\t9.99", "17\t0\t9.82", "18\t0\t0.75"} { + if !strings.Contains(partsText, want) { + t.Fatalf("expected generated parts 2da to contain %q after normalization and overrides, got:\n%s", want, partsText) + } + } + if strings.Contains(partsText, "17\t0\t0.90") { t.Fatalf("expected authored and generated rows in parts 2da, got:\n%s", partsText) } } diff --git a/internal/project/project.go b/internal/project/project.go index ab0c7a9..749f32d 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -291,10 +291,34 @@ type AutogenConsumerConfig struct { Root string `json:"root" yaml:"root"` Include []string `json:"include" yaml:"include"` Derive AutogenDeriveConfig `json:"derive" yaml:"derive"` + PartsRows PartsRowsConfig `json:"parts_rows" yaml:"parts_rows"` Manifest AutogenManifestConfig `json:"manifest" yaml:"manifest"` LocalOverrideRoot string `json:"local_override_root,omitempty" yaml:"local_override_root,omitempty"` } +type PartsRowsConfig struct { + RowDefaults map[string]string `json:"row_defaults" yaml:"row_defaults"` + ACBonus PartsRowsACBonusConfig `json:"acbonus" yaml:"acbonus"` + Datasets map[string]PartsRowsDatasetConfig `json:"datasets" yaml:"datasets"` +} + +type PartsRowsDatasetConfig struct { + ACBonus PartsRowsACBonusPolicy `json:"acbonus" yaml:"acbonus"` +} + +type PartsRowsACBonusConfig struct { + Default PartsRowsACBonusPolicy `json:"default" yaml:"default"` + Datasets map[string]PartsRowsACBonusPolicy `json:"datasets" yaml:"datasets"` +} + +type PartsRowsACBonusPolicy struct { + Strategy string `json:"strategy" yaml:"strategy"` + MaxRowID int `json:"max_row_id" yaml:"max_row_id"` + Divisor int `json:"divisor" yaml:"divisor"` + Format string `json:"format" yaml:"format"` + Value string `json:"value" yaml:"value"` +} + type AutogenDeriveConfig struct { Kind string `json:"kind" yaml:"kind"` GroupFrom string `json:"group_from,omitempty" yaml:"group_from,omitempty"` @@ -1363,10 +1387,93 @@ func validateGeneratedConfig(cfg GeneratedConfig) []error { } failures = append(failures, validateGlobList(fieldPrefix+".autogen.include", top2da.Autogen.Include)...) failures = append(failures, validateAutogenDeriveConfig(fieldPrefix+".autogen.derive", top2da.Autogen.Derive)...) + if strings.TrimSpace(top2da.Autogen.Mode) == "parts_rows" && partsRowsConfigConfigured(top2da.Autogen.PartsRows) { + failures = append(failures, validatePartsRowsConfig(fieldPrefix+".autogen.parts_rows", top2da.Autogen.PartsRows)...) + } } return failures } +func partsRowsConfigConfigured(cfg PartsRowsConfig) bool { + if len(cfg.RowDefaults) > 0 { + return true + } + if strings.TrimSpace(cfg.ACBonus.Default.Strategy) != "" { + return true + } + if len(cfg.ACBonus.Datasets) > 0 { + return true + } + if len(cfg.Datasets) > 0 { + return true + } + return false +} + +func validatePartsRowsConfig(fieldPrefix string, cfg PartsRowsConfig) []error { + var failures []error + failures = append(failures, validatePartsRowsACBonusPolicy(fieldPrefix+".acbonus.default", cfg.ACBonus.Default, false)...) + for dataset, policy := range cfg.ACBonus.Datasets { + dataset = strings.TrimSpace(dataset) + if dataset == "" { + failures = append(failures, fmt.Errorf("%s.acbonus.datasets contains an empty dataset key", fieldPrefix)) + continue + } + failures = append(failures, validatePartsRowsACBonusPolicy(fieldPrefix+".acbonus.datasets."+dataset, policy, true)...) + } + for dataset, datasetCfg := range cfg.Datasets { + dataset = strings.TrimSpace(dataset) + if dataset == "" { + failures = append(failures, fmt.Errorf("%s.datasets contains an empty dataset key", fieldPrefix)) + continue + } + failures = append(failures, validatePartsRowsACBonusPolicy(fieldPrefix+".datasets."+dataset+".acbonus", datasetCfg.ACBonus, true)...) + } + return failures +} + +func validatePartsRowsACBonusPolicy(fieldPrefix string, policy PartsRowsACBonusPolicy, allowEmpty bool) []error { + strategy := strings.TrimSpace(policy.Strategy) + if strategy == "" { + if allowEmpty { + return nil + } + return []error{fmt.Errorf("%s.strategy is required", fieldPrefix)} + } + switch strategy { + case "descending_row_id_sort_key": + var failures []error + if policy.MaxRowID <= 0 { + failures = append(failures, fmt.Errorf("%s.max_row_id must be greater than 0", fieldPrefix)) + } + if policy.Divisor <= 0 { + failures = append(failures, fmt.Errorf("%s.divisor must be greater than 0", fieldPrefix)) + } + if err := validatePartsRowsFormat(policy.Format); err != nil { + failures = append(failures, fmt.Errorf("%s.format %q is invalid: %w", fieldPrefix, policy.Format, err)) + } + return failures + case "fixed": + if strings.TrimSpace(policy.Value) == "" { + return []error{fmt.Errorf("%s.value is required for fixed strategy", fieldPrefix)} + } + return nil + default: + return []error{fmt.Errorf("%s.strategy %q is not supported", fieldPrefix, policy.Strategy)} + } +} + +func validatePartsRowsFormat(format string) error { + if strings.TrimSpace(format) == "" { + return fmt.Errorf("must not be empty") + } + formatted := fmt.Sprintf(format, 1.25) + if strings.Contains(formatted, "%!") { + return fmt.Errorf("must format a numeric value") + } + return nil +} + func validateMusicConfig(cfg MusicConfig) []error { var failures []error if cfg.Defaults != nil { diff --git a/internal/project/project_test.go b/internal/project/project_test.go index 1d2c5db..08801c6 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -882,6 +882,15 @@ func TestValidateLayoutAcceptsGeneratedTopData2DAConfig(t *testing.T) { Root: "part", Include: []string{"**/*.mdl"}, Derive: AutogenDeriveConfig{Kind: "trailing_numeric_suffix", GroupFrom: "first_path_segment"}, + PartsRows: PartsRowsConfig{ + RowDefaults: map[string]string{"COSTMODIFIER": "0"}, + ACBonus: PartsRowsACBonusConfig{ + Default: PartsRowsACBonusPolicy{Strategy: "descending_row_id_sort_key", MaxRowID: 999, Divisor: 100, Format: "%.2f"}, + Datasets: map[string]PartsRowsACBonusPolicy{ + "chest": {Strategy: "fixed", Value: "0.00"}, + }, + }, + }, }, }, }, @@ -934,6 +943,63 @@ func TestValidateLayoutRejectsEscapingGeneratedTopData2DAConfig(t *testing.T) { } } +func TestValidateLayoutRejectsInvalidGeneratedPartsRowsACBonusConfig(t *testing.T) { + root := t.TempDir() + mkdirAll(t, filepath.Join(root, "assets")) + mkdirAll(t, filepath.Join(root, "build")) + proj := Project{ + Root: root, + Config: Config{ + Module: ModuleConfig{Name: "Test", ResRef: "testmod"}, + Paths: PathConfig{Assets: "assets"}, + Generated: GeneratedConfig{ + TopData2DA: []GeneratedTopData2DAConfig{ + { + ID: "parts", + Source: "topdata", + Output: "{paths.cache}/generated-assets/parts-2da", + IncludeDatasets: []string{"parts/**"}, + PackageRoot: "part", + Autogen: AutogenConsumerConfig{ + ID: "parts", + Mode: "parts_rows", + Root: "part", + Include: []string{"**/*.mdl"}, + Derive: AutogenDeriveConfig{Kind: "trailing_numeric_suffix", GroupFrom: "first_path_segment"}, + PartsRows: PartsRowsConfig{ + ACBonus: PartsRowsACBonusConfig{ + Default: PartsRowsACBonusPolicy{Strategy: "descending_row_id_sort_key", MaxRowID: 0, Divisor: -1, Format: "%d"}, + Datasets: map[string]PartsRowsACBonusPolicy{ + "chest": {Strategy: "fixed"}, + "robe": {Strategy: "unknown"}, + }, + }, + }, + }, + }, + }, + }, + }, + } + + err := proj.ValidateLayout() + if err == nil { + t.Fatal("expected invalid parts_rows acbonus config to fail") + } + text := err.Error() + for _, want := range []string{ + "generated_assets.topdata_2da[0].autogen.parts_rows.acbonus.default.max_row_id", + "generated_assets.topdata_2da[0].autogen.parts_rows.acbonus.default.divisor", + "generated_assets.topdata_2da[0].autogen.parts_rows.acbonus.default.format", + "generated_assets.topdata_2da[0].autogen.parts_rows.acbonus.datasets.chest.value", + "generated_assets.topdata_2da[0].autogen.parts_rows.acbonus.datasets.robe.strategy", + } { + if !strings.Contains(text, want) { + t.Fatalf("expected validation error containing %q, got %v", want, err) + } + } +} + func TestValidateLayoutRejectsDuplicateHAKNames(t *testing.T) { root := t.TempDir() mkdirAll(t, filepath.Join(root, "src")) diff --git a/internal/topdata/generated_assets.go b/internal/topdata/generated_assets.go index 1cfe1da..2019d36 100644 --- a/internal/topdata/generated_assets.go +++ b/internal/topdata/generated_assets.go @@ -69,7 +69,11 @@ func buildGenerated2DAAssetGroup(p *project.Project, cfg project.GeneratedTopDat if err != nil { return nil, err } - collected, err = applyPartOverrides(sourceDir, collected) + collected, err = normalizePartsRowsACBonus(collected, consumer.PartsRows) + if err != nil { + return nil, err + } + collected, err = applyPartOverridesWithConfig(sourceDir, collected, consumer.PartsRows) if err != nil { return nil, err } @@ -127,7 +131,7 @@ func applyAutogenConsumersForGeneratedAssets(collected []nativeCollectedDataset, } switch consumer.Mode { case "parts_rows": - return augmentWithAutogeneratedParts(collected, autogenPartsInventory(entries)), nil + return augmentWithAutogeneratedPartsWithConfig(collected, autogenPartsInventory(entries), consumer.PartsRows), nil default: return nil, fmt.Errorf("unsupported generated topdata 2DA autogen mode %q", consumer.Mode) } diff --git a/internal/topdata/parts_discovery.go b/internal/topdata/parts_discovery.go index 27da121..90afd8b 100644 --- a/internal/topdata/parts_discovery.go +++ b/internal/topdata/parts_discovery.go @@ -212,11 +212,24 @@ func CreateDefaultPartRow(rowID int) map[string]any { } func CreateDefaultPartRowForColumns(rowID int, columns []string) map[string]any { + return createDefaultPartRowForDataset(rowID, "", columns, project.PartsRowsConfig{}) +} + +func createDefaultPartRowForDataset(rowID int, datasetName string, columns []string, cfg project.PartsRowsConfig) map[string]any { row := map[string]any{ "id": rowID, "COSTMODIFIER": "0", "ACBONUS": "0.00", } + for field, value := range cfg.RowDefaults { + if strings.TrimSpace(field) == "" { + continue + } + row[field] = value + } + if value, ok := configuredPartsRowsACBonusValue(rowID, datasetName, cfg); ok { + row["ACBONUS"] = value + } for _, column := range columns { if strings.HasPrefix(column, "HIDE") { row[column] = "0" @@ -236,12 +249,27 @@ func isUnsetPartValue(value any) bool { } func applyDiscoveredPartDefaults(row map[string]any, columns []string) { + applyDiscoveredPartDefaultsWithConfig(row, "", columns, project.PartsRowsConfig{}) +} + +func applyDiscoveredPartDefaultsWithConfig(row map[string]any, datasetName string, columns []string, cfg project.PartsRowsConfig) { if isUnsetPartValue(row["COSTMODIFIER"]) { row["COSTMODIFIER"] = "0" } + for field, value := range cfg.RowDefaults { + if strings.TrimSpace(field) == "" { + continue + } + if isUnsetPartValue(row[field]) { + row[field] = value + } + } if isUnsetPartValue(row["ACBONUS"]) { row["ACBONUS"] = "0.00" } + if value, ok := configuredPartsRowsACBonusValue(rowIDFromPartRow(row), datasetName, cfg); ok { + row["ACBONUS"] = value + } for _, column := range columns { if !strings.HasPrefix(column, "HIDE") { continue @@ -252,6 +280,11 @@ func applyDiscoveredPartDefaults(row map[string]any, columns []string) { } } +func rowIDFromPartRow(row map[string]any) int { + rowID, _ := row["id"].(int) + return rowID +} + func resolvePartsRoot(root string) (string, error) { if strings.TrimSpace(root) == "" { return "", nil @@ -354,6 +387,10 @@ func loadPartOverrides(path string) ([]map[string]any, error) { } func applyPartOverrides(sourceDir string, collected []nativeCollectedDataset) ([]nativeCollectedDataset, error) { + return applyPartOverridesWithConfig(sourceDir, collected, project.PartsRowsConfig{}) +} + +func applyPartOverridesWithConfig(sourceDir string, collected []nativeCollectedDataset, cfg project.PartsRowsConfig) ([]nativeCollectedDataset, error) { result := make([]nativeCollectedDataset, len(collected)) copy(result, collected) for i, dataset := range result { @@ -393,7 +430,7 @@ func applyPartOverrides(sourceDir string, collected []nativeCollectedDataset) ([ } row, ok := rowByID[rowID] if !ok { - row = CreateDefaultPartRowForColumns(rowID, dataset.Columns) + row = createDefaultPartRowForDataset(rowID, dataset.Dataset.Name, dataset.Columns, cfg) rows = append(rows, row) rowByID[rowID] = row } @@ -415,6 +452,88 @@ func applyPartOverrides(sourceDir string, collected []nativeCollectedDataset) ([ return result, nil } +func normalizePartsRowsACBonus(collected []nativeCollectedDataset, cfg project.PartsRowsConfig) ([]nativeCollectedDataset, error) { + if !partsRowsACBonusConfigured(cfg) && len(cfg.RowDefaults) == 0 { + return collected, nil + } + result := make([]nativeCollectedDataset, len(collected)) + copy(result, collected) + for i, dataset := range result { + if !strings.HasPrefix(dataset.Dataset.Name, "parts/") { + continue + } + rows := make([]map[string]any, len(dataset.Rows)) + for index, row := range dataset.Rows { + cloned := cloneRowMap(row) + for field, value := range cfg.RowDefaults { + if strings.TrimSpace(field) == "" { + continue + } + if isUnsetPartValue(cloned[field]) { + cloned[field] = value + } + } + rowID, ok := cloned["id"].(int) + if ok { + if value, configured := configuredPartsRowsACBonusValue(rowID, dataset.Dataset.Name, cfg); configured { + cloned["ACBONUS"] = value + } + } + rows[index] = cloned + } + result[i].Rows = rows + } + return result, nil +} + +func partsRowsACBonusConfigured(cfg project.PartsRowsConfig) bool { + if strings.TrimSpace(cfg.ACBonus.Default.Strategy) != "" { + return true + } + if len(cfg.ACBonus.Datasets) > 0 { + return true + } + for _, dataset := range cfg.Datasets { + if strings.TrimSpace(dataset.ACBonus.Strategy) != "" { + return true + } + } + return false +} + +func configuredPartsRowsACBonusValue(rowID int, datasetName string, cfg project.PartsRowsConfig) (string, bool) { + policy, ok := partsRowsACBonusPolicyForDataset(datasetName, cfg) + if !ok { + return "", false + } + switch strings.TrimSpace(policy.Strategy) { + case "descending_row_id_sort_key": + format := strings.TrimSpace(policy.Format) + if format == "" { + format = "%.2f" + } + return fmt.Sprintf(format, float64(policy.MaxRowID-rowID)/float64(policy.Divisor)), true + case "fixed": + return policy.Value, true + default: + return "", false + } +} + +func partsRowsACBonusPolicyForDataset(datasetName string, cfg project.PartsRowsConfig) (project.PartsRowsACBonusPolicy, bool) { + category := extractPartCategory(datasetName) + if policy, ok := cfg.ACBonus.Datasets[category]; ok && strings.TrimSpace(policy.Strategy) != "" { + return policy, true + } + if dataset, ok := cfg.Datasets[category]; ok && strings.TrimSpace(dataset.ACBonus.Strategy) != "" { + return dataset.ACBonus, true + } + if strings.TrimSpace(cfg.ACBonus.Default.Strategy) != "" { + return cfg.ACBonus.Default, true + } + return project.PartsRowsACBonusPolicy{}, false +} + func loadPartOverridesForCategory(sourceDir, category string) ([]map[string]any, error) { overrides := []map[string]any{} @@ -503,6 +622,10 @@ func normalizePartOverrideValue(value any) any { // retain authored values unless they still use placeholder values, in which case // discovery activates them with engine-visible defaults. func augmentWithAutogeneratedParts(collected []nativeCollectedDataset, autogenerated map[string]map[int]struct{}) []nativeCollectedDataset { + return augmentWithAutogeneratedPartsWithConfig(collected, autogenerated, project.PartsRowsConfig{}) +} + +func augmentWithAutogeneratedPartsWithConfig(collected []nativeCollectedDataset, autogenerated map[string]map[int]struct{}, cfg project.PartsRowsConfig) []nativeCollectedDataset { if len(autogenerated) == 0 { return collected } @@ -538,11 +661,11 @@ func augmentWithAutogeneratedParts(collected []nativeCollectedDataset, autogener for rowID := range ids { if row, exists := existingRows[rowID]; exists { - applyDiscoveredPartDefaults(row, dataset.Columns) + applyDiscoveredPartDefaultsWithConfig(row, dataset.Dataset.Name, dataset.Columns, cfg) continue } if _, exists := existingRows[rowID]; !exists { - newRow := CreateDefaultPartRowForColumns(rowID, dataset.Columns) + newRow := createDefaultPartRowForDataset(rowID, dataset.Dataset.Name, dataset.Columns, cfg) newRows = append(newRows, newRow) } } diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index 60fb58d..a16edf6 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -10525,6 +10525,72 @@ func TestAugmentWithAutogeneratedPartsRespectsExistingIDs(t *testing.T) { } } +func TestNormalizePartsRowsACBonusUsesConfiguredPolicyBeforeOverrides(t *testing.T) { + collected := []nativeCollectedDataset{ + { + Dataset: nativeDataset{Name: "parts/belt", OutputName: "parts_belt.2da"}, + Columns: []string{"COSTMODIFIER", "ACBONUS"}, + Rows: []map[string]any{ + {"id": 1, "COSTMODIFIER": "5", "ACBONUS": "0.90"}, + {"id": 999, "COSTMODIFIER": "0", "ACBONUS": "****"}, + }, + }, + { + Dataset: nativeDataset{Name: "parts/chest", OutputName: "parts_chest.2da"}, + Columns: []string{"COSTMODIFIER", "ACBONUS"}, + Rows: []map[string]any{ + {"id": 14, "COSTMODIFIER": "0", "ACBONUS": "8.00"}, + }, + }, + { + Dataset: nativeDataset{Name: "parts/robe", OutputName: "parts_robe.2da"}, + Columns: []string{"COSTMODIFIER", "ACBONUS"}, + Rows: []map[string]any{ + {"id": 95, "COSTMODIFIER": "0", "ACBONUS": "0.00"}, + }, + }, + } + cfg := project.PartsRowsConfig{ + RowDefaults: map[string]string{"COSTMODIFIER": "0"}, + ACBonus: project.PartsRowsACBonusConfig{ + Default: project.PartsRowsACBonusPolicy{Strategy: "descending_row_id_sort_key", MaxRowID: 999, Divisor: 100, Format: "%.2f"}, + Datasets: map[string]project.PartsRowsACBonusPolicy{ + "chest": {Strategy: "fixed", Value: "0.00"}, + }, + }, + } + + got, err := normalizePartsRowsACBonus(collected, cfg) + if err != nil { + t.Fatalf("normalizePartsRowsACBonus failed: %v", err) + } + + beltRows := rowsByID(got[0].Rows) + if got := beltRows[1]["ACBONUS"]; got != "9.98" { + t.Fatalf("expected authored belt ACBONUS to be replaced with computed sort key 9.98, got %v", got) + } + if got := beltRows[999]["ACBONUS"]; got != "0.00" { + t.Fatalf("expected highest belt row to sort at 0.00, got %v", got) + } + chestRows := rowsByID(got[1].Rows) + if got := chestRows[14]["ACBONUS"]; got != "0.00" { + t.Fatalf("expected chest ACBONUS to be forced to 0.00, got %v", got) + } + robeRows := rowsByID(got[2].Rows) + if got := robeRows[95]["ACBONUS"]; got != "9.04" { + t.Fatalf("expected robe to use computed non-chest sort key, got %v", got) + } +} + +func rowsByID(rows []map[string]any) map[int]map[string]any { + out := map[int]map[string]any{} + for _, row := range rows { + id, _ := row["id"].(int) + out[id] = row + } + return out +} + func TestAugmentWithAutogeneratedPartsSortsRows(t *testing.T) { collected := []nativeCollectedDataset{ {