sbuffkin_hexmaker/tests/frontmatter.test.ts
isaprettycoolguy@protonmail.com b69131f16d perf: cut redundant per-item work on map + table load
Map grid: the per-hex render loop called getTerrainFromFile +
getIconOverrideFromFile + getGmIconsFromFile, each re-running
getAbstractFileByPath + getFileCache — 3 identical cache lookups per hex
(~11.5k redundant ops on a 3843-hex map). Fetch frontmatter once and feed
three new pure extractors (terrainFromFm/iconOverrideFromFm/gmIconsFromFm).
Reuse the bg-image layer across renders (bgLayerEl/bgLayerSrc) so
renderGrid()'s viewportEl.empty() no longer re-decodes the image each time.
Tag painted hexes with .duckmage-hex-painted (setHexColor) and switch the
bg-image empty-hex rule to a class selector instead of a slower
:not([style*="background-color"]) attribute-substring match.

Table view: memoise the name→entry palette Map per region instead of
rebuilding it per row, and re-filter only the rows just filled in each batch
(applyFilters(rows?)) instead of re-scanning all rows after every batch.

Adds dev/bg-hex-recalc-bench + unit tests for the new pure extractors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 07:03:23 -04:00

405 lines
17 KiB
TypeScript

import { describe, it, mock } from "node:test";
import expect from "expect";
import { TFile } from "obsidian";
import { getTerrainFromFile, setTerrainInFile, getIconOverrideFromFile, setIconOverrideInFile, getSubmapFromFile, setSubmapInFile, terrainFromFm, iconOverrideFromFm, gmIconsFromFm } from "../src/frontmatter";
/** Build a minimal mock App backed by an in-memory string. */
function makeApp(filePath: string, initialContent: string) {
let stored = initialContent;
const file = Object.create(TFile.prototype) as TFile;
file.path = filePath;
const app = {
vault: {
getAbstractFileByPath: (p: string) => (p === filePath ? file : null),
},
fileManager: {
processFrontMatter: mock.fn(async (_f: unknown, fn: (fm: Record<string, unknown>) => void) => {
const fmMatch = stored.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
const fm: Record<string, unknown> = {};
if (fmMatch) {
for (const line of fmMatch[1].split("\n")) {
const m = line.match(/^([\w-]+):\s*(.+)$/);
if (m) fm[m[1]] = m[2].trim();
}
}
const rest = fmMatch ? stored.slice(fmMatch[0].length) : stored;
fn(fm);
const fmLines = Object.entries(fm).map(([k, v]) => `${k}: ${v}`).join("\n");
stored = fmLines ? `---\n${fmLines}\n---\n${rest}` : rest;
}),
},
metadataCache: {
getFileCache: mock.fn(() => null),
},
} as unknown as import("obsidian").App;
return { app, getContent: () => stored };
}
// ── setTerrainInFile ──────────────────────────────────────────────────────────
describe("setTerrainInFile", () => {
it("returns false when the file does not exist", async () => {
const { app } = makeApp("world/hexes/1_1.md", "");
const result = await setTerrainInFile(app, "world/hexes/MISSING.md", "forest");
expect(result).toBe(false);
});
it("adds terrain to existing frontmatter that lacks it", async () => {
const { app, getContent } = makeApp("hex.md", "---\ntitle: test\n---\n\nContent");
await setTerrainInFile(app, "hex.md", "forest");
expect(getContent()).toContain("terrain: forest");
expect(getContent()).toContain("title: test");
});
it("updates an existing terrain field", async () => {
const { app, getContent } = makeApp("hex.md", "---\nterrain: plains\n---\n\nContent");
await setTerrainInFile(app, "hex.md", "forest");
expect(getContent()).toContain("terrain: forest");
expect(getContent()).not.toContain("terrain: plains");
});
it("removes the terrain field when passed null", async () => {
const { app, getContent } = makeApp("hex.md", "---\nterrain: forest\ntitle: x\n---\n\nContent");
await setTerrainInFile(app, "hex.md", null);
expect(getContent()).not.toContain("terrain:");
expect(getContent()).toContain("title: x");
});
it("creates frontmatter when none exists", async () => {
const { app, getContent } = makeApp("hex.md", "Just content.");
await setTerrainInFile(app, "hex.md", "desert");
expect(getContent()).toMatch(/^---\n/);
expect(getContent()).toContain("terrain: desert");
expect(getContent()).toContain("Just content.");
});
it("returns true and makes no change when removing terrain from a file with no frontmatter", async () => {
const { app, getContent } = makeApp("hex.md", "No frontmatter.");
const result = await setTerrainInFile(app, "hex.md", null);
expect(result).toBe(true);
expect(getContent()).toBe("No frontmatter.");
});
it("preserves content body after the frontmatter", async () => {
const body = "\n### Towns\n\n[[Town A]]\n";
const { app, getContent } = makeApp("hex.md", `---\nterrain: plains\n---\n${body}`);
await setTerrainInFile(app, "hex.md", "forest");
expect(getContent()).toContain("### Towns");
expect(getContent()).toContain("[[Town A]]");
});
});
// ── setIconOverrideInFile ─────────────────────────────────────────────────────
describe("setIconOverrideInFile", () => {
it("returns false when the file does not exist", async () => {
const { app } = makeApp("hex.md", "");
const result = await setIconOverrideInFile(app, "MISSING.md", "castle.png");
expect(result).toBe(false);
});
it("adds icon to existing frontmatter that lacks it", async () => {
const { app, getContent } = makeApp("hex.md", "---\nterrain: forest\n---\n\nContent");
await setIconOverrideInFile(app, "hex.md", "castle.png");
expect(getContent()).toContain("icon: castle.png");
expect(getContent()).toContain("terrain: forest");
});
it("updates an existing icon field", async () => {
const { app, getContent } = makeApp("hex.md", "---\nicon: tower.png\n---\n\nContent");
await setIconOverrideInFile(app, "hex.md", "castle.png");
expect(getContent()).toContain("icon: castle.png");
expect(getContent()).not.toContain("icon: tower.png");
});
it("removes the icon field when passed null", async () => {
const { app, getContent } = makeApp("hex.md", "---\nicon: castle.png\nterrain: forest\n---\n\nContent");
await setIconOverrideInFile(app, "hex.md", null);
expect(getContent()).not.toContain("icon:");
expect(getContent()).toContain("terrain: forest");
});
it("creates frontmatter when none exists", async () => {
const { app, getContent } = makeApp("hex.md", "Bare content.");
await setIconOverrideInFile(app, "hex.md", "ruin.png");
expect(getContent()).toMatch(/^---\n/);
expect(getContent()).toContain("icon: ruin.png");
expect(getContent()).toContain("Bare content.");
});
it("returns true and makes no change when removing icon from a file with no frontmatter", async () => {
const { app, getContent } = makeApp("hex.md", "No frontmatter.");
const result = await setIconOverrideInFile(app, "hex.md", null);
expect(result).toBe(true);
expect(getContent()).toBe("No frontmatter.");
});
});
// ── helpers for cache-based reads ────────────────────────────────────────────
/** Build a minimal mock App that returns a metadata cache with the given frontmatter. */
function makeAppWithCache(filePath: string, frontmatter: Record<string, unknown> | null) {
const file = Object.create(TFile.prototype) as TFile;
file.path = filePath;
const app = {
vault: {
getAbstractFileByPath: (p: string) => (p === filePath ? file : null),
},
metadataCache: {
getFileCache: mock.fn(() => (frontmatter !== null ? { frontmatter } : null)),
},
} as unknown as import("obsidian").App;
return { app };
}
// ── getTerrainFromFile ────────────────────────────────────────────────────────
describe("getTerrainFromFile", () => {
it("returns null when the file does not exist", () => {
const { app } = makeAppWithCache("hex.md", { terrain: "forest" });
expect(getTerrainFromFile(app, "MISSING.md")).toBeNull();
});
it("returns the terrain string from the metadata cache", () => {
const { app } = makeAppWithCache("hex.md", { terrain: "forest" });
expect(getTerrainFromFile(app, "hex.md")).toBe("forest");
});
it("returns null when terrain field is absent from frontmatter", () => {
const { app } = makeAppWithCache("hex.md", { title: "Test" });
expect(getTerrainFromFile(app, "hex.md")).toBeNull();
});
it("returns null when there is no file cache", () => {
const { app } = makeAppWithCache("hex.md", null);
expect(getTerrainFromFile(app, "hex.md")).toBeNull();
});
it("returns null when terrain field is not a string (e.g. array)", () => {
const { app } = makeAppWithCache("hex.md", { terrain: ["forest", "plains"] });
expect(getTerrainFromFile(app, "hex.md")).toBeNull();
});
it("returns null when terrain field is a number", () => {
const { app } = makeAppWithCache("hex.md", { terrain: 42 });
expect(getTerrainFromFile(app, "hex.md")).toBeNull();
});
});
// ── getIconOverrideFromFile ───────────────────────────────────────────────────
describe("getIconOverrideFromFile", () => {
it("returns null when the file does not exist", () => {
const { app } = makeAppWithCache("hex.md", { icon: "castle.png" });
expect(getIconOverrideFromFile(app, "MISSING.md")).toBeNull();
});
it("returns the icon string from the metadata cache", () => {
const { app } = makeAppWithCache("hex.md", { icon: "castle.png" });
expect(getIconOverrideFromFile(app, "hex.md")).toBe("castle.png");
});
it("returns null when icon field is absent from frontmatter", () => {
const { app } = makeAppWithCache("hex.md", { terrain: "forest" });
expect(getIconOverrideFromFile(app, "hex.md")).toBeNull();
});
it("returns null when there is no file cache", () => {
const { app } = makeAppWithCache("hex.md", null);
expect(getIconOverrideFromFile(app, "hex.md")).toBeNull();
});
it("returns null when icon field is not a string (e.g. boolean)", () => {
const { app } = makeAppWithCache("hex.md", { icon: true });
expect(getIconOverrideFromFile(app, "hex.md")).toBeNull();
});
});
// ── setSubmapInFile ───────────────────────────────────────────────────────────
describe("setSubmapInFile", () => {
it("returns false when the file does not exist", async () => {
const { app } = makeApp("hex.md", "");
const result = await setSubmapInFile(app, "missing.md", "the-coast");
expect(result).toBe(false);
});
it("writes duckmage-submap to a file with no existing frontmatter", async () => {
const { app, getContent } = makeApp("hex.md", "Body text.");
await setSubmapInFile(app, "hex.md", "the-coast");
expect(getContent()).toContain("duckmage-submap: the-coast");
});
it("updates an existing duckmage-submap value", async () => {
const { app, getContent } = makeApp("hex.md", "---\nduckmage-submap: old-map\n---\n");
await setSubmapInFile(app, "hex.md", "new-map");
expect(getContent()).toContain("duckmage-submap: new-map");
expect(getContent()).not.toContain("old-map");
});
it("removes duckmage-submap when passed null", async () => {
const { app, getContent } = makeApp("hex.md", "---\nduckmage-submap: the-coast\nterrain: plains\n---\n");
await setSubmapInFile(app, "hex.md", null);
expect(getContent()).not.toContain("duckmage-submap");
expect(getContent()).toContain("terrain: plains");
});
});
// ── getSubmapFromFile ─────────────────────────────────────────────────────────
describe("getSubmapFromFile", () => {
it("returns the submap name when present", () => {
const { app } = makeAppWithCache("hex.md", { "duckmage-submap": "the-coast" });
expect(getSubmapFromFile(app, "hex.md")).toBe("the-coast");
});
it("returns undefined when the key is absent", () => {
const { app } = makeAppWithCache("hex.md", { terrain: "plains" });
expect(getSubmapFromFile(app, "hex.md")).toBeUndefined();
});
it("returns undefined when the file does not exist", () => {
const { app } = makeAppWithCache("hex.md", null);
expect(getSubmapFromFile(app, "missing.md")).toBeUndefined();
});
it("returns undefined when the value is not a string", () => {
const { app } = makeAppWithCache("hex.md", { "duckmage-submap": 42 });
expect(getSubmapFromFile(app, "hex.md")).toBeUndefined();
});
});
// ── updateSubmapReferences ────────────────────────────────────────────────────
describe("updateSubmapReferences", () => {
function makeMultiFileApp(files: Record<string, { content: string; submap?: string }>) {
const stored: Record<string, string> = {};
const tfiles: Record<string, TFile> = {};
for (const [path, { content }] of Object.entries(files)) {
stored[path] = content;
const f = Object.create(TFile.prototype) as TFile;
f.path = path;
tfiles[path] = f;
}
const app = {
vault: {
getAbstractFileByPath: (p: string) => tfiles[p] ?? null,
getMarkdownFiles: () => Object.values(tfiles),
},
fileManager: {
processFrontMatter: mock.fn(async (file: TFile, fn: (fm: Record<string, unknown>) => void) => {
const content = stored[file.path] ?? "";
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
const fm: Record<string, unknown> = {};
if (fmMatch) {
for (const line of fmMatch[1].split("\n")) {
const m = line.match(/^([\w-]+):\s*(.+)$/);
if (m) fm[m[1]] = m[2].trim();
}
}
const rest = fmMatch ? content.slice(fmMatch[0].length) : content;
fn(fm);
const fmLines = Object.entries(fm).map(([k, v]) => `${k}: ${v}`).join("\n");
stored[file.path] = fmLines ? `---\n${fmLines}\n---\n${rest}` : rest;
}),
},
metadataCache: {
getFileCache: mock.fn((file: TFile) => {
const submap = files[file.path]?.submap;
return submap ? { frontmatter: { "duckmage-submap": submap } } : null;
}),
},
} as unknown as import("obsidian").App;
return { app, getContent: (p: string) => stored[p] ?? "" };
}
it("updates duckmage-submap in all matching hex notes", async () => {
const { app, getContent } = makeMultiFileApp({
"world/hexes/map-a/1_1.md": { content: "---\nduckmage-submap: old-map\n---\n", submap: "old-map" },
"world/hexes/map-a/2_2.md": { content: "---\nduckmage-submap: old-map\n---\n", submap: "old-map" },
"world/hexes/map-a/3_3.md": { content: "---\nterrain: plains\n---\n" },
});
// Simulate updateSubmapReferences logic directly
const hexFolder = "world/hexes";
const files = app.vault.getMarkdownFiles().filter(
(f: TFile) => f.path.startsWith(hexFolder + "/"),
);
for (const file of files) {
const submap = app.metadataCache.getFileCache(file)?.frontmatter?.["duckmage-submap"];
if (submap !== "old-map") continue;
await setSubmapInFile(app, file.path, "new-map");
}
expect(getContent("world/hexes/map-a/1_1.md")).toContain("duckmage-submap: new-map");
expect(getContent("world/hexes/map-a/2_2.md")).toContain("duckmage-submap: new-map");
expect(getContent("world/hexes/map-a/3_3.md")).not.toContain("duckmage-submap");
});
it("does not touch notes whose submap points to a different map", async () => {
const { app, getContent } = makeMultiFileApp({
"world/hexes/map-a/1_1.md": { content: "---\nduckmage-submap: other-map\n---\n", submap: "other-map" },
});
const files = app.vault.getMarkdownFiles();
for (const file of files) {
const submap = app.metadataCache.getFileCache(file)?.frontmatter?.["duckmage-submap"];
if (submap !== "old-map") continue;
await setSubmapInFile(app, file.path, "new-map");
}
expect(getContent("world/hexes/map-a/1_1.md")).toContain("duckmage-submap: other-map");
});
});
// ── Pure frontmatter extractors (used by the hex-grid render loop, which
// fetches frontmatter once per hex and feeds it to all three) ──────────────
describe("terrainFromFm", () => {
it("returns the terrain string", () => {
expect(terrainFromFm({ terrain: "forest" } as any)).toBe("forest");
});
it("returns null for missing/non-string/nullish frontmatter", () => {
expect(terrainFromFm({ icon: "x.png" } as any)).toBeNull();
expect(terrainFromFm({ terrain: 3 } as any)).toBeNull();
expect(terrainFromFm(null)).toBeNull();
expect(terrainFromFm(undefined)).toBeNull();
});
});
describe("iconOverrideFromFm", () => {
it("returns the icon string", () => {
expect(iconOverrideFromFm({ icon: "castle.png" } as any)).toBe("castle.png");
});
it("returns null for missing/non-string/nullish frontmatter", () => {
expect(iconOverrideFromFm({ terrain: "forest" } as any)).toBeNull();
expect(iconOverrideFromFm({ icon: true } as any)).toBeNull();
expect(iconOverrideFromFm(null)).toBeNull();
});
});
describe("gmIconsFromFm", () => {
it("returns the gm-icons array, filtered to strings", () => {
expect(gmIconsFromFm({ "gm-icons": ["a.png", "b.png"] } as any)).toEqual(["a.png", "b.png"]);
expect(gmIconsFromFm({ "gm-icons": ["a.png", 2, null, "b.png"] } as any)).toEqual(["a.png", "b.png"]);
});
it("falls back to legacy single gm-icon", () => {
expect(gmIconsFromFm({ "gm-icon": "legacy.png" } as any)).toEqual(["legacy.png"]);
});
it("prefers gm-icons array over legacy gm-icon", () => {
expect(gmIconsFromFm({ "gm-icons": ["a.png"], "gm-icon": "legacy.png" } as any)).toEqual(["a.png"]);
});
it("returns [] for missing/nullish frontmatter", () => {
expect(gmIconsFromFm({} as any)).toEqual([]);
expect(gmIconsFromFm(null)).toEqual([]);
expect(gmIconsFromFm(undefined)).toEqual([]);
});
});