sbuffkin_hexmaker/tests/helpers/simVault.ts
isaprettycoolguy@protonmail.com 6aa885b5b2 feat: export module — PDF/Markdown for notes, hexes, maps, tables, workflows (1.2.0)
Adds a unified export pipeline under src/export/ with exporters for single notes,
structured hexes, maps (with reference table), random tables, and workflows
(with rolled samples). PDF rendering reuses Obsidian's MarkdownRenderer + theme
CSS; map PNG/PDF goes through a dedicated canvas renderer.

UI surface:
- "Export" tab in MapModal (PNG/PDF with overlay + size options)
- "Export" link in HexEditorModal + HexExportModal
- "Export PDF/Markdown" links in RandomTableView header
- "Export…" button in WorkflowEditorModal + WorkflowExportModal
- Commands: export current note (PDF/MD), current hex, current workflow
- File-menu items on any .md file: Export to PDF / Markdown

Other:
- New `exportFolder` setting (defaults to {worldFolder}/exports)
- hexGeometry: add hexSize / hexCenter / hexPolygonPoints / gridBoundingBox
- HexTableView + HexEditorModal: first-wins on duplicate palette names
  (matches HexMapView's .find() behaviour)
- Tests: unit + integration suites for all exporters; sim-vault fixture

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 20:46:41 -04:00

423 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Mock `App` / `Vault` / `MetadataCache` + `HexmakerPlugin` that read from
* the sim-vault fixture on real disk. Lets integration tests drive the
* actual exporter orchestrators end-to-end without spinning up Obsidian.
*
* The mocks implement only the surface area our orchestrators touch:
* vault.read / getAbstractFileByPath / getMarkdownFiles / createBinary /
* modifyBinary / create / modify
* metadataCache.getFileCache / getFirstLinkpathDest
* workspace.getLeaf().openFile() ← no-op
* plugin.app / plugin.settings / plugin.hexPath
*
* The metadata cache is built lazily per-file by an inline parser that
* handles YAML frontmatter (simple key:value), `### Headings` (level 3),
* and `[[wikilinks]]` — enough for everything our exporters depend on.
*/
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
export const SIM_VAULT_ROOT = path.resolve(
__dirname,
"../fixtures/sim-vault",
);
// ─── Minimal TFile/TFolder polyfills ───────────────────────────────────────
export class MockTFile {
path: string;
basename: string;
extension: string;
constructor(p: string) {
this.path = p;
const slash = p.lastIndexOf("/");
const name = slash >= 0 ? p.slice(slash + 1) : p;
const dot = name.lastIndexOf(".");
this.basename = dot >= 0 ? name.slice(0, dot) : name;
this.extension = dot >= 0 ? name.slice(dot + 1) : "";
}
}
export class MockTFolder {
path: string;
constructor(p: string) {
this.path = p;
}
}
// ─── Filesystem-backed Vault ───────────────────────────────────────────────
export class MockVault {
/** Output dir for created/modified files. Defaults to a sub-tmp. */
outDir: string;
/** Cache of absolute paths → TFile, populated lazily. */
private fileCache = new Map<string, MockTFile>();
constructor(outDir: string) {
this.outDir = outDir;
}
/**
* Resolve a vault-relative path to an absolute filesystem path.
* Read-only files come from SIM_VAULT_ROOT; written files go to outDir.
* On read, prefer outDir if the file was written there (overrides).
*/
resolvePath(vaultPath: string, write: boolean): string {
if (write) return path.join(this.outDir, vaultPath);
// For reads: try outDir (in case we created/modified it), then sim-vault.
const outPath = path.join(this.outDir, vaultPath);
if (fs.existsSync(outPath)) return outPath;
return path.join(SIM_VAULT_ROOT, vaultPath);
}
// ── Lookup ─────────────────────────────────────────────────────────────
getAbstractFileByPath(p: string): MockTFile | MockTFolder | null {
const cached = this.fileCache.get(p);
if (cached) return cached;
const inSim = path.join(SIM_VAULT_ROOT, p);
const inOut = path.join(this.outDir, p);
let real = "";
if (fs.existsSync(inOut)) real = inOut;
else if (fs.existsSync(inSim)) real = inSim;
if (!real) return null;
const stat = fs.statSync(real);
if (stat.isDirectory()) return new MockTFolder(p);
const file = new MockTFile(p);
this.fileCache.set(p, file);
return file;
}
/** Return all .md files under both sim-vault and outDir, deduplicated. */
getMarkdownFiles(): MockTFile[] {
const found = new Map<string, MockTFile>();
const walk = (root: string, prefix: string) => {
if (!fs.existsSync(root)) return;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
const childAbs = path.join(root, entry.name);
const childRel = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
walk(childAbs, childRel);
} else if (entry.isFile() && entry.name.endsWith(".md")) {
if (!found.has(childRel)) found.set(childRel, new MockTFile(childRel));
}
}
};
walk(SIM_VAULT_ROOT, "");
walk(this.outDir, "");
return Array.from(found.values());
}
// ── Read ───────────────────────────────────────────────────────────────
async read(file: MockTFile): Promise<string> {
const abs = this.resolvePath(file.path, false);
return await fsp.readFile(abs, "utf8");
}
async cachedRead(file: MockTFile): Promise<string> {
return this.read(file);
}
// ── Write ──────────────────────────────────────────────────────────────
async create(p: string, data: string): Promise<MockTFile> {
const abs = path.join(this.outDir, p);
await fsp.mkdir(path.dirname(abs), { recursive: true });
await fsp.writeFile(abs, data, "utf8");
const f = new MockTFile(p);
this.fileCache.set(p, f);
return f;
}
async modify(file: MockTFile, data: string): Promise<void> {
const abs = path.join(this.outDir, file.path);
await fsp.mkdir(path.dirname(abs), { recursive: true });
await fsp.writeFile(abs, data, "utf8");
}
async createBinary(p: string, data: ArrayBuffer): Promise<MockTFile> {
const abs = path.join(this.outDir, p);
await fsp.mkdir(path.dirname(abs), { recursive: true });
await fsp.writeFile(abs, Buffer.from(data));
const f = new MockTFile(p);
this.fileCache.set(p, f);
return f;
}
async modifyBinary(file: MockTFile, data: ArrayBuffer): Promise<void> {
const abs = path.join(this.outDir, file.path);
await fsp.mkdir(path.dirname(abs), { recursive: true });
await fsp.writeFile(abs, Buffer.from(data));
}
async createFolder(p: string): Promise<void> {
const abs = path.join(this.outDir, p);
await fsp.mkdir(abs, { recursive: true });
}
// Adapter API (Obsidian-style)
adapter = {
exists: async (p: string): Promise<boolean> => {
return (
fs.existsSync(path.join(this.outDir, p)) ||
fs.existsSync(path.join(SIM_VAULT_ROOT, p))
);
},
getResourcePath: (p: string): string => {
// Real Obsidian returns app:// URLs. Tests don't load these via the
// browser; we just need a string. Use a file:// URL so failures are
// obvious if anything actually fetches it.
return `file://${this.resolvePath(p, false)}`;
},
};
}
// ─── MetadataCache ─────────────────────────────────────────────────────────
interface ParsedCache {
frontmatter: Record<string, unknown>;
headings: Array<{
heading: string;
level: number;
position: {
start: { line: number; col: number; offset: number };
end: { line: number; col: number; offset: number };
};
}>;
links: Array<{
link: string;
position: {
start: { line: number; col: number; offset: number };
end: { line: number; col: number; offset: number };
};
}>;
blocks: Record<string, unknown>;
}
export class MockMetadataCache {
constructor(private vault: MockVault) {}
getFileCache(file: MockTFile): ParsedCache | null {
try {
const abs = this.vault.resolvePath(file.path, false);
if (!fs.existsSync(abs)) return null;
const content = fs.readFileSync(abs, "utf8");
return parseFileCache(content);
} catch {
return null;
}
}
getFirstLinkpathDest(
linkpath: string,
_sourcePath: string,
): MockTFile | null {
// Try linkpath as-is, with .md, as a folder/basename match.
const candidates = [linkpath, `${linkpath}.md`];
for (const c of candidates) {
const f = this.vault.getAbstractFileByPath(c);
if (f instanceof MockTFile) return f;
}
// Fall back to a basename search across the vault.
const target = (linkpath.split("/").pop() ?? linkpath).replace(/\.md$/i, "");
for (const f of this.vault.getMarkdownFiles()) {
if (f.basename === target) return f;
}
return null;
}
fileToLinktext(file: MockTFile, _sourcePath: string): string {
return file.basename;
}
}
// ─── Workspace (no-op) ─────────────────────────────────────────────────────
class MockLeaf {
async openFile(_f: MockTFile): Promise<void> {
/* no-op */
}
async setViewState(_s: unknown): Promise<void> {
/* no-op */
}
}
export class MockWorkspace {
getLeaf(_arg?: unknown): MockLeaf {
return new MockLeaf();
}
}
// ─── App ────────────────────────────────────────────────────────────────────
export class MockApp {
vault: MockVault;
metadataCache: MockMetadataCache;
workspace: MockWorkspace;
constructor(outDir: string) {
this.vault = new MockVault(outDir);
this.metadataCache = new MockMetadataCache(this.vault);
this.workspace = new MockWorkspace();
}
}
// ─── Plugin ────────────────────────────────────────────────────────────────
import { DEFAULT_SETTINGS } from "../../src/constants";
import type { HexmakerPluginSettings } from "../../src/types";
export class MockHexmakerPlugin {
app: MockApp;
settings: HexmakerPluginSettings;
manifest = { dir: ".obsidian/plugins/duckmage-plugin", id: "duckmage" };
// Field that real plugin uses for icon URL resolution.
vaultIconsSet = new Set<string>();
constructor(outDir: string) {
this.app = new MockApp(outDir);
this.settings = makeSimSettings();
}
hexPath(x: number, y: number, mapName: string): string {
const folder = this.settings.hexFolder;
return folder
? `${folder}/${mapName}/${x}_${y}.md`
: `${mapName}/${x}_${y}.md`;
}
getMap(mapName: string) {
return this.settings.maps.find((m) => m.name === mapName);
}
getMapPalette(mapName: string) {
const map = this.getMap(mapName);
return (
this.settings.terrainPalettes.find((p) => p.name === map?.paletteName)
?.terrains ?? this.settings.terrainPalettes[0]?.terrains ?? []
);
}
}
function makeSimSettings(): HexmakerPluginSettings {
// Start from real defaults but point folders + maps at the sim-vault layout.
const settings: HexmakerPluginSettings = JSON.parse(
JSON.stringify(DEFAULT_SETTINGS),
);
settings.worldFolder = "";
settings.hexFolder = "hexes";
settings.tablesFolder = "tables";
settings.workflowsFolder = "workflows";
settings.townsFolder = "towns";
settings.dungeonsFolder = "dungeons";
settings.featuresFolder = "features";
settings.questsFolder = "quests";
settings.factionsFolder = "factions";
settings.regionsFolder = "regions";
settings.iconsFolder = "";
settings.exportFolder = "exports";
// Single 3×2 map.
settings.maps = [
{
name: "the-coast",
paletteName: settings.terrainPalettes[1]?.name ?? settings.terrainPalettes[0].name,
gridSize: { cols: 3, rows: 2 },
gridOffset: { x: 0, y: 0 },
pathChains: [],
},
];
// Inject a duplicate "ocean" entry into the palette to exercise the
// first-wins regression fix.
const pal = settings.terrainPalettes.find(
(p) => p.name === settings.maps[0].paletteName,
);
if (pal) {
const oceanIdx = pal.terrains.findIndex((t) => t.name === "ocean");
if (oceanIdx >= 0) {
pal.terrains.push({ name: "ocean", color: "#888888" });
}
}
return settings;
}
// ─── Inline parser for the metadata cache ──────────────────────────────────
function parseFileCache(content: string): ParsedCache {
const out: ParsedCache = {
frontmatter: {},
headings: [],
links: [],
blocks: {},
};
// Frontmatter
let bodyStart = 0;
const fmMatch = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(content);
if (fmMatch) {
bodyStart = fmMatch[0].length;
const fmBody = fmMatch[1];
for (const line of fmBody.split(/\r?\n/)) {
const m = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
if (m) {
const key = m[1];
let val: unknown = m[2].trim();
// Strip optional surrounding quotes
if (typeof val === "string") {
const unq = /^"(.*)"$/.exec(val)?.[1];
if (unq !== undefined) val = unq;
}
out.frontmatter[key] = val;
}
}
}
// Headings — track byte offsets in the original content
const headingRe = /^(#{1,6})\s+(.+?)\s*$/gm;
let hm: RegExpExecArray | null;
while ((hm = headingRe.exec(content)) !== null) {
if (hm.index < bodyStart) continue;
out.headings.push({
heading: hm[2],
level: hm[1].length,
position: {
start: { line: 0, col: 0, offset: hm.index },
end: { line: 0, col: 0, offset: hm.index + hm[0].length },
},
});
}
// Wikilinks
const linkRe = /\[\[([^\]]+)\]\]/g;
let lm: RegExpExecArray | null;
while ((lm = linkRe.exec(content)) !== null) {
out.links.push({
link: lm[1].split("|")[0].split("#")[0].trim(),
position: {
start: { line: 0, col: 0, offset: lm.index },
end: { line: 0, col: 0, offset: lm.index + lm[0].length },
},
});
}
return out;
}
// Export a TFile alias as `TFile` for code that does `instanceof TFile`.
// Use a Proxy/typing trick: the orchestrators import `TFile` from "obsidian"
// (the mock obsidian.ts), so we don't substitute here. Test files using these
// mocks should pass MockTFile instances which extend nothing — orchestrators'
// `instanceof TFile` checks will fail.
//
// To make `instanceof TFile` work in orchestrators run through tsx, the test
// setup needs to alias MockTFile under the same name as the obsidian module's
// TFile. The simplest pattern: monkey-patch the imported TFile via a setup
// helper.
import * as obsidian from "obsidian";
const ObsidianTFile = (obsidian as unknown as { TFile: typeof MockTFile }).TFile;
// Force MockTFile to be reported as instanceof obsidian's TFile.
Object.setPrototypeOf(MockTFile.prototype, ObsidianTFile.prototype);