From d7c7b5dde60eaa5df256f74463cda626f573b58e Mon Sep 17 00:00:00 2001 From: barrulus Date: Sat, 5 Sep 2026 08:17:34 +0100 Subject: [PATCH] fix: validate the stored label and burg group registries A value persisted by an older build can be structurally valid and still leave the renderer with nothing to draw - `{groups: []}` has every key, the right types, and produces a map with no labels at all. Both keys are read unvalidated on boot and again in randomizeOptions, so the bad value survives every new map until the user clears site data. Parse them through Labels.parseStoredOptions and Burgs.parseStoredGroups instead: drop entries the renderer cannot use, restore any label type left without a group, and keep a default burg group for assignment to fall back on. --- public/main.js | 4 +-- public/modules/ui/options.js | 4 +-- src/generators/burgs-generator.test.ts | 31 ++++++++++++++++++ src/generators/burgs-generator.ts | 14 ++++++++ src/generators/labels-generator.test.ts | 43 +++++++++++++++++++++++++ src/generators/labels-generator.ts | 24 ++++++++++++++ src/index.html | 4 +-- tests/e2e/stored-options.spec.ts | 41 +++++++++++++++++++++++ 8 files changed, 159 insertions(+), 6 deletions(-) create mode 100644 tests/e2e/stored-options.spec.ts diff --git a/public/main.js b/public/main.js index 65a059d3a1..1a92a274fb 100644 --- a/public/main.js +++ b/public/main.js @@ -71,9 +71,9 @@ let options = { prec: 100, // precipitation modifier in % showBurgPreview: true, burgs: { - groups: JSON.safeParse(localStorage.getItem("burg-groups")) || Burgs.getDefaultGroups() + groups: Burgs.parseStoredGroups(localStorage.getItem("burg-groups")) }, - labels: JSON.safeParse(localStorage.getItem("options-labels")) || Labels.getDefaultOptions(), + labels: Labels.parseStoredOptions(localStorage.getItem("options-labels")), emblems: { showAll: false }, trade: { animation: JSON.safeParse(localStorage.getItem("trade-animation")) || TradeAnimation.getDefaultOptions() diff --git a/public/modules/ui/options.js b/public/modules/ui/options.js index 91203b4f1f..4325ac9e42 100644 --- a/public/modules/ui/options.js +++ b/public/modules/ui/options.js @@ -615,8 +615,8 @@ function randomizeOptions() { // a loaded map's migrated group registries are map data, not session preferences: re-seed // from the same source boot uses, so new maps get the user's saved groups or the defaults - options.burgs.groups = JSON.safeParse(localStorage.getItem("burg-groups")) || Burgs.getDefaultGroups(); - options.labels = JSON.safeParse(localStorage.getItem("options-labels")) || Labels.getDefaultOptions(); + options.burgs.groups = Burgs.parseStoredGroups(localStorage.getItem("burg-groups")); + options.labels = Labels.parseStoredOptions(localStorage.getItem("options-labels")); // 'Options' settings if (randomize || !stored("points")) changeCellsDensity(4); // reset to default, no need to randomize diff --git a/src/generators/burgs-generator.test.ts b/src/generators/burgs-generator.test.ts index aa872483e9..a938731a30 100644 --- a/src/generators/burgs-generator.test.ts +++ b/src/generators/burgs-generator.test.ts @@ -704,3 +704,34 @@ describe("ensureBurgGroupStyles", () => { expect(anchors.groups.fortresses).toEqual(townAnchor); }); }); + +describe("BurgsModule.parseStoredGroups", () => { + let Burgs: any; + + beforeEach(async () => { + globalThis.TIME = false; + globalThis.window = globalThis.window || ({} as any); + await import("./burgs-generator"); + Burgs = (globalThis as any).Burgs; + }); + + it("falls back to the defaults when the stored value holds no usable group", () => { + const defaults = Burgs.getDefaultGroups(); + expect(Burgs.parseStoredGroups(null)).toEqual(defaults); + expect(Burgs.parseStoredGroups("[]")).toEqual(defaults); + expect(Burgs.parseStoredGroups("not json")).toEqual(defaults); + expect(Burgs.parseStoredGroups(JSON.stringify([{ order: 1 }]))).toEqual(defaults); + }); + + it("keeps usable stored groups and drops the rest", () => { + const usable = { name: "outpost", order: 3 }; + const stored = JSON.stringify([usable, { order: 4 }, { name: "noOrder" }]); + const groups = Burgs.parseStoredGroups(stored); + expect(groups.map((group: any) => group.name)).toEqual(["outpost"]); + }); + + it("always leaves a default group for burg assignment to fall back on", () => { + const groups = Burgs.parseStoredGroups(JSON.stringify([{ name: "outpost", order: 3 }])); + expect(groups.filter((group: any) => group.isDefault).length).toBe(1); + }); +}); diff --git a/src/generators/burgs-generator.ts b/src/generators/burgs-generator.ts index 7d2e3416b7..53328977c8 100644 --- a/src/generators/burgs-generator.ts +++ b/src/generators/burgs-generator.ts @@ -3,6 +3,7 @@ import { Emblems } from "@/generators/emblems-generator"; import { buildSettlemakerUrl } from "@/services/previews/settlemaker"; import type { BurgGroup } from "@/types/burg-groups"; import type { Emblem } from "@/types/emblems"; +import { safeParseJSON } from "@/utils/stringUtils"; import { each, ensureEl, gauss, minmax, normalize, P, rn } from "../utils"; import { buildBurgContext } from "./burg-context"; import { type CultureType, DEFAULT_CULTURE_TYPE } from "./cultures-generator"; @@ -1024,6 +1025,19 @@ class BurgModule { } } + /** burg assignment needs a named, ordered group and a default to fall back on: a value persisted + * by an older build can satisfy neither and still parse */ + parseStoredGroups(stored: string | null): BurgGroup[] { + const parsed = stored ? safeParseJSON(stored) : null; + const groups: BurgGroup[] = Array.isArray(parsed) + ? parsed.filter(group => typeof group?.name === "string" && typeof group?.order === "number") + : []; + if (!groups.length) return this.getDefaultGroups(); + + if (!groups.some(group => group.isDefault)) groups[0].isDefault = true; + return groups; + } + getDefaultGroups(): BurgGroup[] { return [ { diff --git a/src/generators/labels-generator.test.ts b/src/generators/labels-generator.test.ts index 5e35cf26cf..2ad6a5dd30 100644 --- a/src/generators/labels-generator.test.ts +++ b/src/generators/labels-generator.test.ts @@ -1,5 +1,7 @@ import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { LABEL_TYPES } from "./labels-generator"; + let LabelsModule: typeof import("./labels-generator").LabelsModule; let labels: import("./labels-generator").LabelsModule; @@ -80,3 +82,44 @@ describe("ensureBurgLabelGroups", () => { expect(options.labels.groups.length).toBe(count); }); }); + +describe("parseStoredOptions", () => { + it("restores label types whose groups are missing from the stored value", () => { + const stored = JSON.stringify({ resizeOnZoom: true, showAll: false, groups: [] }); + const parsed = labels.parseStoredOptions(stored); + for (const type of LABEL_TYPES) { + expect(parsed.groups.some(group => group.type === type)).toBe(true); + } + }); + + it("keeps stored groups the renderer can use", () => { + const custom = { name: "outpost", type: "burg", zoom: { min: 3, max: 40 } }; + const parsed = labels.parseStoredOptions(JSON.stringify({ groups: [custom] })); + expect(parsed.groups).toContainEqual(custom); + }); + + it("drops groups that are missing a name, a known type or zoom bounds", () => { + const groups = [ + { type: "burg", zoom: { min: 1, max: 2 } }, + { name: "ghost", type: "spaceship", zoom: { min: 1, max: 2 } }, + { name: "noZoom", type: "burg" } + ]; + const parsed = labels.parseStoredOptions(JSON.stringify({ groups })); + const names = parsed.groups.map(group => group.name); + expect(names.includes("ghost")).toBe(false); + expect(names.includes("noZoom")).toBe(false); + }); + + it("falls back to the defaults when the stored value is absent or unusable", () => { + const defaults = labels.getDefaultOptions(); + expect(labels.parseStoredOptions(null)).toEqual(defaults); + expect(labels.parseStoredOptions("not json")).toEqual(defaults); + expect(labels.parseStoredOptions("[1,2,3]")).toEqual(defaults); + }); + + it("keeps stored flags but repairs ones of the wrong type", () => { + const parsed = labels.parseStoredOptions(JSON.stringify({ resizeOnZoom: false, showAll: "yes" })); + expect(parsed.resizeOnZoom).toBe(false); + expect(parsed.showAll).toBe(labels.getDefaultOptions().showAll); + }); +}); diff --git a/src/generators/labels-generator.ts b/src/generators/labels-generator.ts index 96f426ed35..85d0ffdfa3 100644 --- a/src/generators/labels-generator.ts +++ b/src/generators/labels-generator.ts @@ -1,5 +1,6 @@ import type { LayerId } from "@/components/layers"; import type { Point } from "@/types/global"; +import { safeParseJSON } from "@/utils/stringUtils"; export const LABEL_TYPES = ["state", "province", "burg", "river", "route", "added"] as const; @@ -137,6 +138,29 @@ export class LabelsModule { }; } + /** a value persisted by an older build can be structurally valid and still leave the renderer with + * nothing to draw, so drop groups it cannot use and restore any type left without one */ + parseStoredOptions(stored: string | null) { + const defaults = this.getDefaultOptions(); + const parsed = stored ? safeParseJSON(stored) : null; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return defaults; + + const isUsable = (group: LabelGroup) => + Boolean(group?.name) && (LABEL_TYPES as readonly string[]).includes(group?.type) && Boolean(group?.zoom); + const groups: LabelGroup[] = Array.isArray(parsed.groups) ? parsed.groups.filter(isUsable) : []; + for (const type of LABEL_TYPES) { + if (groups.some(group => group.type === type)) continue; + groups.push(...defaults.groups.filter(group => group.type === type)); + } + + const flag = (value: unknown, fallback: boolean) => (typeof value === "boolean" ? value : fallback); + return { + resizeOnZoom: flag(parsed.resizeOnZoom, defaults.resizeOnZoom), + showAll: flag(parsed.showAll, defaults.showAll), + groups + }; + } + /** burgs can be assigned to groups the label registry has never seen (old maps, the Burg * Groups editor) - without an entry the renderer draws no label at all */ ensureBurgLabelGroups(): void { diff --git a/src/index.html b/src/index.html index 8b288c8fdd..5781ca2ec1 100644 --- a/src/index.html +++ b/src/index.html @@ -5196,8 +5196,8 @@ - - + + diff --git a/tests/e2e/stored-options.spec.ts b/tests/e2e/stored-options.spec.ts new file mode 100644 index 0000000000..c746f01ed2 --- /dev/null +++ b/tests/e2e/stored-options.spec.ts @@ -0,0 +1,41 @@ +import { expect, test } from "@playwright/test"; + +// a value written by an older build can be structurally valid and still leave the renderer with +// nothing to draw; it is read on boot and again in randomizeOptions, so both sites have to validate +const STALE = { + "options-labels": '{"resizeOnZoom":true,"showAll":false,"groups":[]}', + "burg-groups": "[]" +}; + +async function generateMap(page: import("@playwright/test").Page) { + await page.goto("/"); + await page.waitForSelector("#mapToLoad", { state: "attached", timeout: 60000 }); + await page.waitForFunction(() => (window as any).mapId !== undefined, { timeout: 120000 }); + await page.waitForTimeout(1000); + + return page.evaluate(() => { + const labels = document.getElementById("labels"); + return { + groups: labels?.children.length ?? 0, + labelTexts: labels?.querySelectorAll("text").length ?? 0, + // `options` is a global binding from main.js, not a window property + labelGroups: (0, eval)("options.labels.groups.length"), + burgGroups: (0, eval)("options.burgs.groups.length") + }; + }); +} + +test("a stale stored group registry does not leave a new map without labels", async ({ page }) => { + await page.goto("/"); + await page.evaluate(stale => { + localStorage.clear(); + for (const [key, value] of Object.entries(stale)) localStorage.setItem(key, value); + }, STALE); + + const { groups, labelTexts, labelGroups, burgGroups } = await generateMap(page); + + expect(labelGroups).toBeGreaterThan(0); + expect(burgGroups).toBeGreaterThan(0); + expect(groups).toBeGreaterThan(0); + expect(labelTexts).toBeGreaterThan(0); +});