mirror of
https://github.com/sbuffkin/hexmaker.git
synced 2026-07-22 06:14:01 +00:00
- Tokens placed per-hex link to vault notes; support circle/square shape, sm/md/lg size, fill color, optional border color, icon, and description - TokenInfoModal shows note content (formatted via MarkdownRenderer, images included) with jump-to-hex, open-note, edit, and two-step remove actions - Edit flow reopens TokenModal pre-filled; proxy notes handle multiple tokens referencing the same source note - Icon picker replaced with scrollable palette grid (2-row cap, drag-to-reorder, hide/show) in both HexEditorModal and TokenModal; order persisted in settings - Token layer toggle added to layers menu (default on) - Fix metadata-cache race on token delete (wasToken check prevents missed updates) - Token preview rendered inline in TokenModal title row; real-time color preview via native input event on color pickers - Bump version to 1.1.6 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
282 lines
10 KiB
TypeScript
282 lines
10 KiB
TypeScript
import { describe, it, mock } from "node:test";
|
|
import expect from "expect";
|
|
import { TFile } from "obsidian";
|
|
import {
|
|
getTokenDataFromCache,
|
|
removeTokenFrontmatter,
|
|
setTokenHex,
|
|
setTokenVisible,
|
|
applyTokenFrontmatter,
|
|
} from "../src/frontmatter";
|
|
|
|
/** Build a TFile-like mock with path and basename. */
|
|
function makeFile(path: string): TFile {
|
|
const file = Object.create(TFile.prototype) as TFile;
|
|
file.path = path;
|
|
file.basename = path.split("/").pop()!.replace(/\.md$/, "");
|
|
return file;
|
|
}
|
|
|
|
/** App that returns the given frontmatter from the metadata cache for one file. */
|
|
function makeAppWithCache(filePath: string, frontmatter: Record<string, unknown> | null) {
|
|
const file = makeFile(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, file };
|
|
}
|
|
|
|
/** App backed by an in-memory string that supports processFrontMatter. */
|
|
function makeApp(filePath: string, initialContent: string) {
|
|
let stored = initialContent;
|
|
const file = makeFile(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;
|
|
}),
|
|
},
|
|
} as unknown as import("obsidian").App;
|
|
|
|
return { app, getContent: () => stored };
|
|
}
|
|
|
|
// ── getTokenDataFromCache ─────────────────────────────────────────────────────
|
|
|
|
describe("getTokenDataFromCache", () => {
|
|
it("returns null when token field is absent", () => {
|
|
const { app, file } = makeAppWithCache("tokens/hero.md", { title: "Hero" });
|
|
expect(getTokenDataFromCache(app, file)).toBeNull();
|
|
});
|
|
|
|
it("returns null when token field is false", () => {
|
|
const { app, file } = makeAppWithCache("tokens/hero.md", { token: false });
|
|
expect(getTokenDataFromCache(app, file)).toBeNull();
|
|
});
|
|
|
|
it("returns null when there is no file cache", () => {
|
|
const { app, file } = makeAppWithCache("tokens/hero.md", null);
|
|
expect(getTokenDataFromCache(app, file)).toBeNull();
|
|
});
|
|
|
|
it("returns a TokenEntry with defaults when minimal frontmatter is present", () => {
|
|
const { app, file } = makeAppWithCache("tokens/hero.md", { token: true });
|
|
const entry = getTokenDataFromCache(app, file);
|
|
expect(entry).not.toBeNull();
|
|
expect(entry!.filePath).toBe("tokens/hero.md");
|
|
expect(entry!.title).toBe("hero");
|
|
expect(entry!.shape).toBe("circle");
|
|
expect(entry!.size).toBe("md");
|
|
expect(entry!.visible).toBe(true);
|
|
expect(entry!.hex).toBe("");
|
|
expect(entry!.map).toBe("");
|
|
expect(entry!.icon).toBeUndefined();
|
|
expect(entry!.color).toBeUndefined();
|
|
expect(entry!.border).toBeUndefined();
|
|
expect(entry!.tokenLink).toBeUndefined();
|
|
});
|
|
|
|
it("returns all fields from frontmatter", () => {
|
|
const { app, file } = makeAppWithCache("tokens/hero.md", {
|
|
token: true,
|
|
"token-icon": "sword.png",
|
|
"token-hex": "3_4",
|
|
"token-map": "overworld",
|
|
"token-visible": false,
|
|
"token-shape": "hexagon",
|
|
"token-size": "lg",
|
|
"token-color": "#ff0000",
|
|
"token-border": "#ffffff",
|
|
});
|
|
const entry = getTokenDataFromCache(app, file)!;
|
|
expect(entry.icon).toBe("sword.png");
|
|
expect(entry.hex).toBe("3_4");
|
|
expect(entry.map).toBe("overworld");
|
|
expect(entry.visible).toBe(false);
|
|
expect(entry.shape).toBe("hexagon");
|
|
expect(entry.size).toBe("lg");
|
|
expect(entry.color).toBe("#ff0000");
|
|
expect(entry.border).toBe("#ffffff");
|
|
});
|
|
|
|
it("falls back to md for an unknown size", () => {
|
|
const { app, file } = makeAppWithCache("tokens/hero.md", {
|
|
token: true,
|
|
"token-size": "enormous",
|
|
});
|
|
expect(getTokenDataFromCache(app, file)!.size).toBe("md");
|
|
});
|
|
|
|
it("resolves title from linked note when token-link is set", () => {
|
|
const linkedFile = makeFile("world/characters/Gandalf.md");
|
|
const file = makeFile("world/tokens/Gandalf-2.md");
|
|
const app = {
|
|
vault: {
|
|
getAbstractFileByPath: (p: string) => {
|
|
if (p === "world/characters/Gandalf.md") return linkedFile;
|
|
return null;
|
|
},
|
|
},
|
|
metadataCache: {
|
|
getFileCache: () => ({
|
|
frontmatter: {
|
|
token: true,
|
|
"token-link": "world/characters/Gandalf.md",
|
|
"token-hex": "1_1",
|
|
},
|
|
}),
|
|
},
|
|
} as unknown as import("obsidian").App;
|
|
const entry = getTokenDataFromCache(app, file)!;
|
|
expect(entry.title).toBe("Gandalf");
|
|
expect(entry.tokenLink).toBe("world/characters/Gandalf.md");
|
|
});
|
|
|
|
it("falls back to circle for an unknown shape", () => {
|
|
const { app, file } = makeAppWithCache("tokens/hero.md", {
|
|
token: true,
|
|
"token-shape": "triangle",
|
|
});
|
|
expect(getTokenDataFromCache(app, file)!.shape).toBe("circle");
|
|
});
|
|
|
|
it("ignores non-string token-icon", () => {
|
|
const { app, file } = makeAppWithCache("tokens/hero.md", {
|
|
token: true,
|
|
"token-icon": 42,
|
|
});
|
|
expect(getTokenDataFromCache(app, file)!.icon).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ── setTokenHex ───────────────────────────────────────────────────────────────
|
|
|
|
describe("setTokenHex", () => {
|
|
it("returns without writing when file does not exist", async () => {
|
|
const { app } = makeApp("tokens/hero.md", "---\ntoken: true\n---\n");
|
|
await setTokenHex(app, "tokens/MISSING.md", "2_3", "overworld");
|
|
expect((app.fileManager as unknown as { processFrontMatter: ReturnType<typeof mock.fn> }).processFrontMatter.mock.calls.length).toBe(0);
|
|
});
|
|
|
|
it("sets token-hex and token-map in frontmatter", async () => {
|
|
const { app, getContent } = makeApp("tokens/hero.md", "---\ntoken: true\n---\n");
|
|
await setTokenHex(app, "tokens/hero.md", "5_7", "overworld");
|
|
expect(getContent()).toContain("token-hex: 5_7");
|
|
expect(getContent()).toContain("token-map: overworld");
|
|
});
|
|
});
|
|
|
|
// ── setTokenVisible ───────────────────────────────────────────────────────────
|
|
|
|
describe("setTokenVisible", () => {
|
|
it("sets token-visible to false", async () => {
|
|
const { app, getContent } = makeApp("tokens/hero.md", "---\ntoken: true\n---\n");
|
|
await setTokenVisible(app, "tokens/hero.md", false);
|
|
expect(getContent()).toContain("token-visible: false");
|
|
});
|
|
|
|
it("sets token-visible to true", async () => {
|
|
const { app, getContent } = makeApp("tokens/hero.md", "---\ntoken: true\ntoken-visible: false\n---\n");
|
|
await setTokenVisible(app, "tokens/hero.md", true);
|
|
expect(getContent()).toContain("token-visible: true");
|
|
});
|
|
});
|
|
|
|
// ── removeTokenFrontmatter ────────────────────────────────────────────────────
|
|
|
|
describe("removeTokenFrontmatter", () => {
|
|
it("removes all token-* fields", async () => {
|
|
const initial = [
|
|
"---",
|
|
"title: hero",
|
|
"token: true",
|
|
"token-icon: sword.png",
|
|
"token-hex: 3_4",
|
|
"token-map: overworld",
|
|
"token-visible: true",
|
|
"token-shape: hexagon",
|
|
"token-size: lg",
|
|
"token-color: #ff0000",
|
|
"token-border: #ffffff",
|
|
"token-link: world/chars/Gandalf.md",
|
|
"---",
|
|
"",
|
|
].join("\n");
|
|
const { app, getContent } = makeApp("tokens/hero.md", initial);
|
|
await removeTokenFrontmatter(app, "tokens/hero.md");
|
|
const content = getContent();
|
|
expect(content).not.toContain("token:");
|
|
expect(content).not.toContain("token-icon:");
|
|
expect(content).not.toContain("token-hex:");
|
|
expect(content).not.toContain("token-map:");
|
|
expect(content).toContain("title: hero");
|
|
});
|
|
|
|
it("does nothing when file does not exist", async () => {
|
|
const { app } = makeApp("tokens/hero.md", "---\ntoken: true\n---\n");
|
|
await removeTokenFrontmatter(app, "tokens/MISSING.md");
|
|
expect((app.fileManager as unknown as { processFrontMatter: ReturnType<typeof mock.fn> }).processFrontMatter.mock.calls.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ── applyTokenFrontmatter ─────────────────────────────────────────────────────
|
|
|
|
describe("applyTokenFrontmatter", () => {
|
|
it("sets token: true and provided fields", async () => {
|
|
const { app, getContent } = makeApp("tokens/hero.md", "---\ntitle: hero\n---\n");
|
|
await applyTokenFrontmatter(app, "tokens/hero.md", {
|
|
icon: "sword.png",
|
|
hex: "2_3",
|
|
map: "overworld",
|
|
visible: true,
|
|
shape: "square",
|
|
size: "lg",
|
|
color: "#ff0000",
|
|
border: "#ffffff",
|
|
});
|
|
const content = getContent();
|
|
expect(content).toContain("token: true");
|
|
expect(content).toContain("token-icon: sword.png");
|
|
expect(content).toContain("token-hex: 2_3");
|
|
expect(content).toContain("token-map: overworld");
|
|
expect(content).toContain("token-visible: true");
|
|
expect(content).toContain("token-shape: square");
|
|
expect(content).toContain("token-size: lg");
|
|
expect(content).toContain("token-color: #ff0000");
|
|
expect(content).toContain("token-border: #ffffff");
|
|
});
|
|
|
|
it("clears icon when empty string is passed", async () => {
|
|
const { app, getContent } = makeApp("tokens/hero.md", "---\ntoken: true\ntoken-icon: sword.png\n---\n");
|
|
await applyTokenFrontmatter(app, "tokens/hero.md", { icon: "" });
|
|
expect(getContent()).not.toContain("token-icon:");
|
|
});
|
|
|
|
it("clears color when empty string is passed", async () => {
|
|
const { app, getContent } = makeApp("tokens/hero.md", "---\ntoken: true\ntoken-color: #ff0000\n---\n");
|
|
await applyTokenFrontmatter(app, "tokens/hero.md", { color: "" });
|
|
expect(getContent()).not.toContain("token-color:");
|
|
});
|
|
|
|
it("does not touch fields not included in data", async () => {
|
|
const { app, getContent } = makeApp("tokens/hero.md", "---\ntoken: true\ntoken-hex: 1_1\ntoken-map: overworld\n---\n");
|
|
await applyTokenFrontmatter(app, "tokens/hero.md", { shape: "hexagon" });
|
|
expect(getContent()).toContain("token-hex: 1_1");
|
|
expect(getContent()).toContain("token-map: overworld");
|
|
expect(getContent()).toContain("token-shape: hexagon");
|
|
});
|
|
});
|