From 4199d615a3d449ce732ebc9cf2fd687838fbcc62 Mon Sep 17 00:00:00 2001 From: vickydotbat Date: Thu, 14 May 2026 19:59:15 +0200 Subject: [PATCH] Unit test --- README.md | 10 +++ internal/topdata/topdata_test.go | 142 +++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) diff --git a/README.md b/README.md index 105842b..6145aaf 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,16 @@ Or run directly with Go: GOCACHE="$PWD/.cache/go-build" go run ./cmd/nwn-tool --help ``` +When changing native topdata id allocation, lockfile persistence, generated +families, or null-and-relocate behavior, run the repeat-build determinism test: + +```bash +go test ./internal/topdata -run TestBuildNativeKeepsGeneratedIDsStableAcrossRepeatedBuilds +``` + +That test builds the same topdata project ten times in one process and asserts +that relocated/generated ids stay pinned across every pass. + ## Repository Layout ```text diff --git a/internal/topdata/topdata_test.go b/internal/topdata/topdata_test.go index b6464af..d096dc8 100644 --- a/internal/topdata/topdata_test.go +++ b/internal/topdata/topdata_test.go @@ -8897,6 +8897,148 @@ func TestBuildBaseitemsAllowsKeyOnlyOverrideBeforeSameKeyIsRelocatedAndReadded(t } } +func TestBuildNativeKeepsGeneratedIDsStableAcrossRepeatedBuilds(t *testing.T) { + root := testProjectRoot(t) + writeFeatGeneratedHarness(t, root, map[string]string{ + "skill_focus.json": `{ + "family": "skill_focus", + "family_key": "skill_focus", + "template": "masterfeats:skillfocus", + "name_prefix": "Skill Focus", + "label_prefix": "FEAT_SKILL_FOCUS", + "constant_prefix": "FEAT_SKILL_FOCUS", + "default_fields": { + "CRValue": "0.5", + "ReqSkillMinRanks": "1", + "ALLCLASSESCANUSE": "1", + "TOOLSCATEGORIES": "6", + "PreReqEpic": "0", + "ReqAction": "1" + }, + "apply_after_modules": true, + "child_ref_field": "REQSKILL", + "child_source": { + "dataset": "skills", + "predicate": "accessible" + } +}` + "\n", + }, map[string]int{ + "feat:skill_focus_intimidate": 1333, + }) + mkdirAll(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "10_vanillaoverrides")) + writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "base.json"), `{ + "output": "baseitems.2da", + "columns": ["label", "MaxRange"], + "rows": [ + {"id": 58, "key": "baseitems:shortspear", "label": "shortspear", "MaxRange": "100"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "lock.json"), `{ + "baseitems:shortspear": 126 +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "10_vanillaoverrides", "ovr_baseitems_spear.json"), `{ + "overrides": [ + { + "id": 58, + "key": "baseitems:spear", + "label": "spear" + } + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "baseitems", "modules", "add_baseitems_shortspear.json"), `{ + "entries": { + "baseitems:shortspear": { + "label": "shortspear", + "MaxRange": "255" + } + } +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "skills", "base.json"), `{ + "output": "skills.2da", + "columns": ["Label", "Name", "Description", "Icon", "Untrained", "KeyAbility", "ArmorCheckPenalty", "AllClassesCanUse", "Category", "MaxCR", "Constant", "HostileSkill", "HideFromLevelUp"], + "rows": [ + {"id": 18, "key": "skills:intimidate", "Label": "Intimidate", "Name": "8756", "Description": "8786", "Icon": "isk_x2inti", "Untrained": "1", "KeyAbility": "CHA", "ArmorCheckPenalty": "0", "AllClassesCanUse": "1", "Category": "****", "MaxCR": "****", "Constant": "SKILL_INTIMIDATE", "HostileSkill": "1", "HideFromLevelUp": "0"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "skills", "lock.json"), `{ + "skills:intimidate": 18 +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "feat", "base.json"), `{ + "output": "feat.2da", + "compare_reference": false, + "columns": [ + "LABEL","FEAT","DESCRIPTION","ICON","MINATTACKBONUS","MINSTR","MINDEX","MININT","MINWIS","MINCON","MINCHA", + "MINSPELLLVL","PREREQFEAT1","PREREQFEAT2","GAINMULTIPLE","EFFECTSSTACK","ALLCLASSESCANUSE","CATEGORY","MAXCR", + "SPELLID","SUCCESSOR","CRValue","USESPERDAY","MASTERFEAT","TARGETSELF","OrReqFeat0","OrReqFeat1","OrReqFeat2", + "OrReqFeat3","OrReqFeat4","REQSKILL","ReqSkillMinRanks","REQSKILL2","ReqSkillMinRanks2","Constant","TOOLSCATEGORIES", + "HostileFeat","MinLevel","MinLevelClass","MaxLevel","MinFortSave","PreReqEpic","ReqAction" + ], + "rows": [ + {"id": 39, "key": "feat:stunning_fist", "LABEL": "FEAT_STUNNING_FIST", "MASTERFEAT": "****", "Constant": "FEAT_STUNNING_FIST"}, + {"id": 916, "key": "feat:skill_focus_intimidate", "LABEL": "FEAT_SKILL_FOCUS_INTIMIDATE", "REQSKILL": 24, "MASTERFEAT": 4, "Constant": "FEAT_SKILL_FOCUS_INTIMIDATE"} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "feat", "lock.json"), `{ + "feat:skill_focus_intimidate": 1333, + "feat:stunning_fist": 1116 +}`+"\n") + mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules", "00_removed")) + writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "00_removed", "rmv_feat_intimidate.json"), `{ + "overrides": [ + {"id": 916, "null": true, "key": null} + ] +}`+"\n") + writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "00_removed", "rmv_feat_stunningfist.json"), `{ + "overrides": [ + {"id": 39, "null": true, "key": null} + ] +}`+"\n") + mkdirAll(t, filepath.Join(root, "topdata", "data", "feat", "modules", "activecombat")) + writeFile(t, filepath.Join(root, "topdata", "data", "feat", "modules", "activecombat", "add_feat_stunning_fist.json"), `{ + "entries": { + "feat:stunning_fist": { + "LABEL": "FEAT_STUNNING_FIST", + "Constant": "FEAT_STUNNING_FIST" + } + } +}`+"\n") + mkdirAll(t, filepath.Join(root, "reference")) + writeFile(t, filepath.Join(root, "reference", "build.py"), "print('ok')\n") + + expectedBaseitems := map[string]int{ + "baseitems:spear": 58, + "baseitems:shortspear": 126, + } + expectedFeat := map[string]int{ + "feat:skill_focus_intimidate": 1333, + "feat:stunning_fist": 1116, + } + for run := 1; run <= 10; run++ { + if _, err := BuildNative(testProject(root), nil); err != nil { + t.Fatalf("BuildNative run %d failed: %v", run, err) + } + assertLockIDs(t, run, filepath.Join(root, "topdata", "data", "baseitems", "lock.json"), expectedBaseitems) + assertLockIDs(t, run, filepath.Join(root, "topdata", "data", "feat", "lock.json"), expectedFeat) + } +} + +func assertLockIDs(t *testing.T, run int, path string, expected map[string]int) { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("run %d: read lockfile %s: %v", run, path, err) + } + lockData := map[string]int{} + if err := json.Unmarshal(raw, &lockData); err != nil { + t.Fatalf("run %d: parse lockfile %s: %v\n%s", run, path, err, string(raw)) + } + for key, want := range expected { + if got := lockData[key]; got != want { + t.Fatalf("run %d: expected %s to stay locked to id %d, got %d in %s:\n%s", run, key, want, got, path, string(raw)) + } + } +} + func TestBuildSkillsIgnoresStaleBaseSpanLockDuringInitialLoad(t *testing.T) { root := testProjectRoot(t) mkdirAll(t, filepath.Join(root, "topdata", "data", "skills", "modules"))