package topdata import ( "bytes" "encoding/json" "os" "path/filepath" "strings" "testing" ) func TestRunConvertCommandInfersTemplateNamespaceAndOutput(t *testing.T) { root := testProjectRoot(t) writeFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": {"name": "Test", "resref": "test"}, "paths": {"source": "src", "assets": "assets", "build": "build"}, "topdata": {"source": "topdata", "build": "build/topdata"} }`+"\n") mkdirAll(t, filepath.Join(root, "topdata", "templates")) mkdirAll(t, filepath.Join(root, "topdata", "data", "appearance", "modules")) writeFile(t, filepath.Join(root, "topdata", "templates", "config.json"), `{ "tables": { "template_appearance": { "namespace": "appearance", "key_fields": ["LABEL"] } } }`+"\n") writeFile(t, filepath.Join(root, "topdata", "templates", "template_appearance.2da"), "2DA V2.0\n\nLABEL STRING_REF NAME\n960 \"Zombie, Ash, Hot\" **** Zombie_Ash_Hot\n961 \"Zombie, Ash, Done\" **** Zombie_Ash_Done\n") oldCwd, err := os.Getwd() if err != nil { t.Fatalf("getwd: %v", err) } t.Cleanup(func() { _ = os.Chdir(oldCwd) }) if err := os.Chdir(root); err != nil { t.Fatalf("chdir: %v", err) } var stdout bytes.Buffer if err := RunConvertCommand([]string{ "2da-to-module", "--name", "ashzombies", "topdata/templates/template_appearance.2da", }, &stdout); err != nil { t.Fatalf("RunConvertCommand failed: %v", err) } outputPath := filepath.Join(root, "topdata", "data", "appearance", "modules", "add_appearance_ashzombies.json") raw, err := os.ReadFile(outputPath) if err != nil { t.Fatalf("read output: %v", err) } text := string(raw) if !strings.Contains(text, `"appearance:zombie_ash_hot"`) || !strings.Contains(text, `"appearance:zombie_ash_done"`) { t.Fatalf("unexpected output:\n%s", text) } if !strings.Contains(stdout.String(), "converted entries: 2") { t.Fatalf("unexpected stdout: %s", stdout.String()) } } func TestRunConvertCommand2DAToJSONInfersTemplateKeys(t *testing.T) { root := testProjectRoot(t) writeFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": {"name": "Test", "resref": "test"}, "paths": {"source": "src", "assets": "assets", "build": "build"}, "topdata": {"source": "topdata", "build": "build/topdata"} }`+"\n") mkdirAll(t, filepath.Join(root, "topdata", "templates")) writeFile(t, filepath.Join(root, "topdata", "templates", "config.json"), `{ "tables": { "template_appearance": { "namespace": "appearance", "key_fields": ["LABEL"] } } }`+"\n") inputPath := filepath.Join(root, "topdata", "templates", "template_appearance.2da") writeFile(t, inputPath, "2DA V2.0\n\nLABEL STRING_REF NAME\n960 \"Zombie, Ash, Hot\" **** Zombie_Ash_Hot\n961 \"Zombie, Ash, Done\" **** Zombie_Ash_Done\n") oldCwd, err := os.Getwd() if err != nil { t.Fatalf("getwd: %v", err) } t.Cleanup(func() { _ = os.Chdir(oldCwd) }) if err := os.Chdir(root); err != nil { t.Fatalf("chdir: %v", err) } outputPath := filepath.Join(root, "out", "appearance.json") if err := RunConvertCommand([]string{ "2da-to-json", "topdata/templates/template_appearance.2da", outputPath, }, &bytes.Buffer{}); err != nil { t.Fatalf("RunConvertCommand failed: %v", err) } raw, err := os.ReadFile(outputPath) if err != nil { t.Fatalf("read output: %v", err) } var payload struct { Rows []map[string]any `json:"rows"` } if err := json.Unmarshal(raw, &payload); err != nil { t.Fatalf("unmarshal output: %v", err) } if got := payload.Rows[0]["key"]; got != "appearance:zombie_ash_hot" { t.Fatalf("expected first key to be inferred from template config, got %#v", got) } if got := payload.Rows[1]["key"]; got != "appearance:zombie_ash_done" { t.Fatalf("expected second key to be inferred from template config, got %#v", got) } } func TestRunConvertCommand2DAToJSONInfersOutputDatasetKeysFromConfig(t *testing.T) { root := testProjectRoot(t) writeFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": {"name": "Test", "resref": "test"}, "paths": {"source": "src", "assets": "assets", "build": "build"}, "topdata": {"source": "topdata", "build": "build/topdata"} }`+"\n") mkdirAll(t, filepath.Join(root, "topdata", "templates")) mkdirAll(t, filepath.Join(root, "topdata", "data", "soundset")) writeFile(t, filepath.Join(root, "topdata", "templates", "config.json"), `{ "tables": { "template_soundset": { "namespace": "soundset", "key_fields": ["RESREF", "LABEL"] } } }`+"\n") inputPath := filepath.Join(root, "scratch_soundset.2da") writeFile(t, inputPath, "2DA V2.0\n\nLABEL RESREF STRREF GENDER TYPE\n0 \"Bandit Male\" nw_bandit 100 1 4\n1 \"Bandit Female\" nw_bandit 101 2 4\n") oldCwd, err := os.Getwd() if err != nil { t.Fatalf("getwd: %v", err) } t.Cleanup(func() { _ = os.Chdir(oldCwd) }) if err := os.Chdir(root); err != nil { t.Fatalf("chdir: %v", err) } outputPath := filepath.Join(root, "topdata", "data", "soundset", "base.json") if err := RunConvertCommand([]string{ "2da-to-json", "scratch_soundset.2da", outputPath, }, &bytes.Buffer{}); err != nil { t.Fatalf("RunConvertCommand failed: %v", err) } raw, err := os.ReadFile(outputPath) if err != nil { t.Fatalf("read output: %v", err) } var payload struct { Rows []map[string]any `json:"rows"` } if err := json.Unmarshal(raw, &payload); err != nil { t.Fatalf("unmarshal output: %v", err) } if got := payload.Rows[0]["key"]; got != "soundset:nw_bandit" { t.Fatalf("expected first soundset key, got %#v", got) } if got := payload.Rows[1]["key"]; got != "soundset:nw_bandit_bandit_female" { t.Fatalf("expected second soundset key to use config disambiguation, got %#v", got) } } func TestRunConvertCommand2DAToJSONInfersAmbientTemplateKeysFromResource(t *testing.T) { root := testProjectRoot(t) writeFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": {"name": "Test", "resref": "test"}, "paths": {"source": "src", "assets": "assets", "build": "build"}, "topdata": {"source": "topdata", "build": "build/topdata"} }`+"\n") mkdirAll(t, filepath.Join(root, "topdata", "templates")) writeFile(t, filepath.Join(root, "topdata", "templates", "config.json"), `{ "tables": { "template_ambientmusic": { "namespace": "ambientmusic", "key_fields": ["Resource"] } } }`+"\n") inputPath := filepath.Join(root, "topdata", "templates", "template_ambientmusic.2da") writeFile(t, inputPath, "2DA V2.0\n\nDescription DisplayName Resource Stinger1 Stinger2 Stinger3\n0 61901 **** **** **** **** ****\n1 61842 **** mus_ruralday1 **** **** ****\n2 61843 **** mus_ruralday2 **** **** ****\n") oldCwd, err := os.Getwd() if err != nil { t.Fatalf("getwd: %v", err) } t.Cleanup(func() { _ = os.Chdir(oldCwd) }) if err := os.Chdir(root); err != nil { t.Fatalf("chdir: %v", err) } outputPath := filepath.Join(root, "out", "ambientmusic.json") if err := RunConvertCommand([]string{ "2da-to-json", "topdata/templates/template_ambientmusic.2da", outputPath, }, &bytes.Buffer{}); err != nil { t.Fatalf("RunConvertCommand failed: %v", err) } raw, err := os.ReadFile(outputPath) if err != nil { t.Fatalf("read output: %v", err) } var payload struct { Rows []map[string]any `json:"rows"` } if err := json.Unmarshal(raw, &payload); err != nil { t.Fatalf("unmarshal output: %v", err) } if _, ok := payload.Rows[0]["key"]; ok { t.Fatalf("expected null Resource row to remain unkeyed, got %#v", payload.Rows[0]["key"]) } if got := payload.Rows[1]["key"]; got != "ambientmusic:mus_ruralday1" { t.Fatalf("expected first populated resource row key, got %#v", got) } if got := payload.Rows[2]["key"]; got != "ambientmusic:mus_ruralday2" { t.Fatalf("expected second populated resource row key, got %#v", got) } } func TestRunConvertCommand2DAToJSONSuffixesDuplicateGeneratedKeys(t *testing.T) { root := testProjectRoot(t) writeFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": {"name": "Test", "resref": "test"}, "paths": {"source": "src", "assets": "assets", "build": "build"}, "topdata": {"source": "topdata", "build": "build/topdata"} }`+"\n") inputPath := filepath.Join(root, "dup.2da") writeFile(t, inputPath, "2DA V2.0\n\nResource\n1 cmp_reserved\n2 cmp_reserved\n") oldCwd, err := os.Getwd() if err != nil { t.Fatalf("getwd: %v", err) } t.Cleanup(func() { _ = os.Chdir(oldCwd) }) if err := os.Chdir(root); err != nil { t.Fatalf("chdir: %v", err) } outputPath := filepath.Join(root, "out", "ambientmusic.json") if err := RunConvertCommand([]string{ "2da-to-json", "--namespace", "ambientmusic", "--key-field", "Resource", inputPath, outputPath, }, &bytes.Buffer{}); err != nil { t.Fatalf("RunConvertCommand failed: %v", err) } raw, err := os.ReadFile(outputPath) if err != nil { t.Fatalf("read output: %v", err) } var payload struct { Rows []map[string]any `json:"rows"` } if err := json.Unmarshal(raw, &payload); err != nil { t.Fatalf("unmarshal output: %v", err) } if got := payload.Rows[0]["key"]; got != "ambientmusic:cmp_reserved" { t.Fatalf("expected first duplicate key to remain unsuffixed, got %#v", got) } if got := payload.Rows[1]["key"]; got != "ambientmusic:cmp_reserved_2" { t.Fatalf("expected second duplicate key to gain row-id suffix, got %#v", got) } } func TestConvert2DAToJSONSkipsConfiguredKeysWhenAnyKeyFieldIsNull(t *testing.T) { result, err := convert2DAToJSON(parsed2DA{ Columns: []string{"LABEL", "RESREF", "STRREF"}, Rows: []map[string]any{ {"id": 309, "LABEL": "Unused", "RESREF": "unused", "STRREF": 1}, {"id": 310, "LABEL": nullValue, "RESREF": "unused", "STRREF": 2}, }, }, "topdata/data/soundset/base.json", convertOptions{ Namespace: "soundset", KeyFields: []string{"RESREF", "LABEL"}, CollisionMode: "error", }) if err != nil { t.Fatalf("convert2DAToJSON failed: %v", err) } rows := result["rows"].([]map[string]any) if got := rows[0]["key"]; got != "soundset:unused" { t.Fatalf("expected first row key to use the primary configured key field, got %#v", got) } if _, ok := rows[1]["key"]; ok { t.Fatalf("expected second row to remain unkeyed when one configured key field is null, got %#v", rows[1]["key"]) } } func TestRunConvertCommandFailsOnUnkeyedRows(t *testing.T) { root := testProjectRoot(t) writeFile(t, filepath.Join(root, "nwn-tool.json"), `{ "module": {"name": "Test", "resref": "test"}, "paths": {"source": "src", "assets": "assets", "build": "build"}, "topdata": {"source": "topdata", "build": "build/topdata"} }`+"\n") mkdirAll(t, filepath.Join(root, "topdata", "templates")) writeFile(t, filepath.Join(root, "topdata", "templates", "config.json"), `{ "tables": { "template_appearance": { "namespace": "appearance", "key_fields": ["STRING_REF"] } } }`+"\n") writeFile(t, filepath.Join(root, "topdata", "templates", "template_appearance.2da"), "2DA V2.0\n\nLABEL STRING_REF NAME\n960 \"Zombie, Ash, Hot\" **** Zombie_Ash_Hot\n") oldCwd, err := os.Getwd() if err != nil { t.Fatalf("getwd: %v", err) } t.Cleanup(func() { _ = os.Chdir(oldCwd) }) if err := os.Chdir(root); err != nil { t.Fatalf("chdir: %v", err) } err = RunConvertCommand([]string{ "2da-to-module", "--name", "ashzombies", "topdata/templates/template_appearance.2da", }, &bytes.Buffer{}) if err == nil || !strings.Contains(err.Error(), "unable to generate keys for row ids 960") { t.Fatalf("expected unkeyed row failure, got %v", err) } } func TestConvert2DAToModuleFailsOnDuplicateGeneratedKeysByDefault(t *testing.T) { _, err := convert2DAToModule(parsed2DA{ Columns: []string{"LABEL"}, Rows: []map[string]any{ {"id": 1, "LABEL": "Zombie"}, {"id": 2, "LABEL": "Zombie"}, }, }, "topdata/data/appearance/modules/add_appearance_ashzombies.json", convertOptions{ Namespace: "appearance", CollisionMode: "error", }) if err == nil || !strings.Contains(err.Error(), `duplicate generated key "appearance:zombie"`) { t.Fatalf("expected duplicate generated key error, got %v", err) } } func TestParse2DAFileRejectsRowsWithTooManyFields(t *testing.T) { root := t.TempDir() inputPath := filepath.Join(root, "bad.2da") writeFile(t, inputPath, "2DA V2.0\n\nLABEL NAME\n0 one two three\n") _, err := parse2DAFile(inputPath) if err == nil || !strings.Contains(err.Error(), "has 3 values but only 2 columns are declared") { t.Fatalf("expected row width error, got %v", err) } }