From aa06754579156e7b213a7691b5523265769cdcae Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Sun, 5 Apr 2026 21:34:35 +0200 Subject: [PATCH] =?UTF-8?q?Add=20map=20consistency=20test=20=F0=9F=97=BA?= =?UTF-8?q?=EF=B8=8F=20(#3592)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: Because we regularly have problems with incorrectly added maps: Add `tests/MapConsistency.test.ts` with 10 tests that verify every map is correctly registered across all required files: - **main.go** - every `GameMapType` has an entry in the map generator registry and vice versa - **map-generator assets** - each map folder contains exactly `image.png` + `info.json` - **mapCategories** - every map belongs to at least one category in `Game.ts` - **frequency** - every map (except exempted ones) has a playlist frequency in `MapPlaylist.ts`, and no unknown keys exist - **en.json** - every map has a translation entry - **resources/maps/** - every map has `manifest.json`, `map.bin`, `map4x.bin`, `map16x.bin`, `thumbnail.webp` - **Excess folders** - no orphaned directories in `resources/maps/` or `map-generator/assets/maps/` - **Nations consistency** - nation names and coordinates match between `info.json` and `manifest.json` Exempted from frequency check: `GiantWorldMap`, `Oceania`, `BaikalNukeWars`, `Tourney1–4`. ## Please complete the following: - [X] I have added screenshots for all UI updates - [X] I process any text displayed to the user through translateText() and I've added it to the en.json file - [X] I have added relevant tests to the test directory - [X] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: FloPinguin --- tests/MapConsistency.test.ts | 340 +++++++++++++++++++++++++++++++++++ 1 file changed, 340 insertions(+) create mode 100644 tests/MapConsistency.test.ts diff --git a/tests/MapConsistency.test.ts b/tests/MapConsistency.test.ts new file mode 100644 index 000000000..f813a0d6b --- /dev/null +++ b/tests/MapConsistency.test.ts @@ -0,0 +1,340 @@ +import fs from "fs"; +import path from "path"; +import { GameMapName, GameMapType, mapCategories } from "../src/core/game/Game"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** Converts a GameMapName enum key to its folder name (lowercase key). */ +function toFolderName(key: GameMapName): string { + return key.toLowerCase(); +} + +const ROOT = path.resolve(__dirname, ".."); +const MAP_GEN_MAPS = path.join(ROOT, "map-generator", "assets", "maps"); +const RESOURCES_MAPS = path.join(ROOT, "resources", "maps"); +const MAIN_GO = path.join(ROOT, "map-generator", "main.go"); +const EN_JSON = path.join(ROOT, "resources", "lang", "en.json"); +const MAP_PLAYLIST = path.join(ROOT, "src", "server", "MapPlaylist.ts"); + +const allMapKeys = Object.keys(GameMapType) as GameMapName[]; + +// Maps excluded from the frequency requirement (not part of regular playlists). +const FREQUENCY_EXEMPTIONS: Set = new Set([ + "GiantWorldMap", + "Oceania", + "BaikalNukeWars", + "Tourney1", + "Tourney2", + "Tourney3", + "Tourney4", +]); + +/** Parse the main.go maps registry and return the set of non-test map folder names. */ +function getMainGoMaps(): Set { + const content = fs.readFileSync(MAIN_GO, "utf8"); + const names = new Set(); + // Match lines like {Name: "africa"} or {Name: "africa", IsTest: true} + const re = /\{Name:\s*"([^"]+)"(?:,\s*IsTest:\s*true)?\}/g; + let m: RegExpExecArray | null; + while ((m = re.exec(content)) !== null) { + // Check if it's a test map + if (!m[0].includes("IsTest: true")) { + names.add(m[1]); + } + } + return names; +} + +/** Get the en.json map translation keys. */ +function getEnJsonMapKeys(): Set { + const content = JSON.parse(fs.readFileSync(EN_JSON, "utf8")); + const mapSection = content.map as Record; + // Exclude meta keys that aren't actual maps. + const metaKeys = new Set(["map", "featured", "all", "random"]); + return new Set(Object.keys(mapSection).filter((k) => !metaKeys.has(k))); +} + +/** Get all maps listed in the mapCategories from Game.ts. */ +function getCategorizedMaps(): Set { + const result = new Set(); + for (const maps of Object.values(mapCategories)) { + for (const map of maps) { + result.add(map as string); + } + } + return result; +} + +/** Parse the frequency record keys from MapPlaylist.ts. */ +function getFrequencyKeys(): Set { + const content = fs.readFileSync(MAP_PLAYLIST, "utf8"); + // Extract the frequency block + const freqMatch = content.match(/const frequency[\s\S]*?\{([\s\S]*?)\};/); + if (!freqMatch) { + throw new Error( + `Failed to parse frequency record from MapPlaylist.ts (first 200 chars: ${content.slice(0, 200)})`, + ); + } + const keys = new Set(); + const re = /(\w+):/g; + let m: RegExpExecArray | null; + while ((m = re.exec(freqMatch[1])) !== null) { + keys.add(m[1]); + } + return keys; +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("Map consistency", () => { + test("Every GameMapType is registered in main.go", () => { + const mainGoMaps = getMainGoMaps(); + const errors: string[] = []; + for (const key of allMapKeys) { + const folder = toFolderName(key); + if (!mainGoMaps.has(folder)) { + errors.push(`${key} (folder "${folder}") is missing from main.go`); + } + } + if (errors.length > 0) { + throw new Error("Maps missing from main.go:\n" + errors.join("\n")); + } + }); + + test("Every main.go map has a GameMapType entry", () => { + const mainGoMaps = getMainGoMaps(); + const folderToKey = new Map(allMapKeys.map((k) => [toFolderName(k), k])); + const errors: string[] = []; + for (const folder of mainGoMaps) { + if (!folderToKey.has(folder)) { + errors.push( + `main.go map "${folder}" has no matching GameMapType entry`, + ); + } + } + if (errors.length > 0) { + throw new Error( + "main.go maps missing from GameMapType:\n" + errors.join("\n"), + ); + } + }); + + test("Every GameMapType has map-generator assets (image.png + info.json only)", () => { + const errors: string[] = []; + for (const key of allMapKeys) { + const folder = toFolderName(key); + const dir = path.join(MAP_GEN_MAPS, folder); + + if (!fs.existsSync(dir)) { + errors.push( + `${key}: directory "${folder}" missing in map-generator/assets/maps/`, + ); + continue; + } + + const files = fs.readdirSync(dir).sort(); + const expected = ["image.png", "info.json"]; + if ( + files.length !== expected.length || + !files.every((f, i) => f === expected[i]) + ) { + errors.push( + `${key}: expected [${expected.join(", ")}] but found [${files.join(", ")}]`, + ); + } + } + if (errors.length > 0) { + throw new Error("Map generator asset violations:\n" + errors.join("\n")); + } + }); + + test("Every GameMapType is listed in at least one mapCategories group", () => { + const categorized = getCategorizedMaps(); + const errors: string[] = []; + for (const key of allMapKeys) { + const value = GameMapType[key]; + if (!categorized.has(value)) { + errors.push( + `${key} ("${value}") is not listed in any mapCategories group`, + ); + } + } + if (errors.length > 0) { + throw new Error("Maps missing from mapCategories:\n" + errors.join("\n")); + } + }); + + test("Every GameMapType (except exemptions) has a frequency entry", () => { + const freqKeys = getFrequencyKeys(); + const errors: string[] = []; + for (const key of allMapKeys) { + if (FREQUENCY_EXEMPTIONS.has(key)) continue; + if (!freqKeys.has(key)) { + errors.push( + `${key} is missing from the frequency record in MapPlaylist.ts`, + ); + } + } + if (errors.length > 0) { + throw new Error( + "Maps missing from frequency (not exempted):\n" + errors.join("\n"), + ); + } + }); + + test("No unknown keys in frequency record", () => { + const freqKeys = getFrequencyKeys(); + const validKeys = new Set(allMapKeys); + const errors: string[] = []; + for (const key of freqKeys) { + if (!validKeys.has(key as GameMapName)) { + errors.push(`"${key}" in frequency is not a valid GameMapName`); + } + } + if (errors.length > 0) { + throw new Error( + "Unknown keys in frequency record:\n" + errors.join("\n"), + ); + } + }); + + test("Every GameMapType is registered in en.json map translations", () => { + const enKeys = getEnJsonMapKeys(); + const errors: string[] = []; + for (const key of allMapKeys) { + const folder = toFolderName(key); + if (!enKeys.has(folder)) { + errors.push( + `${key} (key "${folder}") is missing from en.json map translations`, + ); + } + } + if (errors.length > 0) { + throw new Error("Maps missing from en.json:\n" + errors.join("\n")); + } + }); + + test("Every GameMapType has resources/maps/ with thumbnail.webp, bin files, and manifest.json", () => { + const errors: string[] = []; + const requiredFiles = [ + "manifest.json", + "map.bin", + "map4x.bin", + "map16x.bin", + "thumbnail.webp", + ]; + + for (const key of allMapKeys) { + const folder = toFolderName(key); + const dir = path.join(RESOURCES_MAPS, folder); + + if (!fs.existsSync(dir)) { + errors.push(`${key}: directory "${folder}" missing in resources/maps/`); + continue; + } + + const files = fs.readdirSync(dir); + for (const req of requiredFiles) { + if (!files.includes(req)) { + errors.push(`${key}: missing "${req}" in resources/maps/${folder}/`); + } + } + } + if (errors.length > 0) { + throw new Error("Resource map file violations:\n" + errors.join("\n")); + } + }); + + test("No excess folders in resources/maps/ or map-generator/assets/maps/", () => { + const expectedFolders = new Set(allMapKeys.map((k) => toFolderName(k))); + const errors: string[] = []; + + const resourceDirs = fs + .readdirSync(RESOURCES_MAPS, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + for (const dir of resourceDirs) { + if (!expectedFolders.has(dir)) { + errors.push(`resources/maps/${dir}/ has no matching GameMapType entry`); + } + } + + const genDirs = fs + .readdirSync(MAP_GEN_MAPS, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + for (const dir of genDirs) { + if (!expectedFolders.has(dir)) { + errors.push( + `map-generator/assets/maps/${dir}/ has no matching GameMapType entry`, + ); + } + } + + if (errors.length > 0) { + throw new Error("Excess map folders:\n" + errors.join("\n")); + } + }); + + test("Nations in info.json and manifest.json should match", () => { + const errors: string[] = []; + + for (const key of allMapKeys) { + const folder = toFolderName(key); + const infoPath = path.join(MAP_GEN_MAPS, folder, "info.json"); + const manifestPath = path.join(RESOURCES_MAPS, folder, "manifest.json"); + + if (!fs.existsSync(infoPath) || !fs.existsSync(manifestPath)) { + continue; // Other tests catch missing files. + } + + try { + const info = JSON.parse(fs.readFileSync(infoPath, "utf8")); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + + type NationEntry = { name: string; coordinates: [number, number] }; + const infoNations: NationEntry[] = (info.nations ?? []).map( + (n: NationEntry) => ({ name: n.name, coordinates: n.coordinates }), + ); + const manifestNations: NationEntry[] = (manifest.nations ?? []).map( + (n: NationEntry) => ({ name: n.name, coordinates: n.coordinates }), + ); + + if (infoNations.length !== manifestNations.length) { + errors.push( + `${key}: nation count mismatch — info.json has ${infoNations.length}, manifest.json has ${manifestNations.length}`, + ); + continue; + } + + // Compare nations by index (order must match; names can be duplicated). + for (let i = 0; i < infoNations.length; i++) { + const inf = infoNations[i]; + const man = manifestNations[i]; + if (inf.name !== man.name) { + errors.push( + `${key}: nations[${i}] name mismatch — info.json "${inf.name}" vs manifest.json "${man.name}"`, + ); + continue; + } + const [ix, iy] = inf.coordinates; + const [mx, my] = man.coordinates; + if (ix !== mx || iy !== my) { + errors.push( + `${key}: nation "${inf.name}" (index ${i}) coordinates differ — info.json [${ix}, ${iy}] vs manifest.json [${mx}, ${my}]`, + ); + } + } + } catch (err) { + errors.push(`${key}: failed to parse JSON — ${(err as Error).message}`); + } + } + + if (errors.length > 0) { + throw new Error( + "Nation data mismatches between info.json and manifest.json:\n" + + errors.join("\n"), + ); + } + }); +});