Files
hyper 999b7a0771 feat(bgm): add dialog role and interaction affordances
Add the dialog role and the interaction affordances the free-interaction
layer depends on: Setup.interactions, Package.dialogs, and the Interaction,
DialogAction, and Dialog types. Wire the dialog role through the zod
schemas, package collection (with duplicate checking), and vite
serialization, and update the tabletop serializedToPackage and test
fixtures to carry dialogs.
2026-08-18 10:24:21 +08:00

128 lines
4.6 KiB
TypeScript

import { describe, expect, it } from "vitest";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { bgm } from "./vite.js";
const fixtureRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"__fixtures__",
);
const gamesRoot = path.join(fixtureRoot, "harbor");
/**
* Vite 8 types plugin hooks as `ObjectHook`, which may be a plain function or
* a `{ handler, order }` object. Our plugin uses plain functions, so cast the
* hook to a callable for direct invocation in tests.
*/
type Callable<T> = T extends (...args: infer A) => infer R
? (...args: A) => R
: never;
function resolveId(plugin: ReturnType<typeof bgm>, id: string): unknown {
return (plugin.resolveId as Callable<typeof plugin.resolveId>)(
id,
undefined,
{
isEntry: false,
},
);
}
function load(plugin: ReturnType<typeof bgm>, id: string): unknown {
return (plugin.load as Callable<typeof plugin.load>)(id, { ssr: false });
}
/** Parse the JSON payload out of an emitted `export default <json>` module. */
function parseModule(code: unknown): unknown {
expect(String(code).startsWith("export default ")).toBe(true);
return JSON.parse(String(code).slice("export default ".length));
}
describe("bgm vite plugin", () => {
it("resolves bgm imports to the virtual module", () => {
const plugin = bgm({ root: gamesRoot });
expect(resolveId(plugin, "virtual:bgm/packages")).toBe(
"\0bgm:virtual:bgm/packages",
);
expect(resolveId(plugin, "virtual:bgm/package/harbor")).toBe(
"\0bgm:virtual:bgm/package/harbor",
);
expect(resolveId(plugin, "virtual:bgm/package/nope")).toBe(
"\0bgm:virtual:bgm/package/nope",
);
expect(resolveId(plugin, "other")).toBeUndefined();
});
it("loads every package as a JSON module", () => {
const plugin = bgm({ root: gamesRoot });
const packages = parseModule(
load(plugin, "\0bgm:virtual:bgm/packages"),
) as Array<Record<string, any>>;
expect(packages).toHaveLength(1);
expect(packages[0].meta.id).toBe("harbor");
expect(packages[0].parts).toHaveProperty("token#wood");
});
it("loads a package as a JSON module", () => {
const plugin = bgm({ root: gamesRoot });
const pkg = parseModule(
load(plugin, "\0bgm:virtual:bgm/package/harbor"),
) as Record<string, any>;
expect(pkg.meta.id).toBe("harbor");
expect(pkg.parts).toHaveProperty("token#wood");
expect(pkg.surfaces).toHaveProperty("board#harbor");
});
it("serializes maps as plain objects, not Map instances", () => {
const plugin = bgm({ root: gamesRoot });
const pkg = parseModule(
load(plugin, "\0bgm:virtual:bgm/package/harbor"),
) as Record<string, any>;
// The emitted shape must be JSON-serializable: plain objects keyed by
// `type#id`, not `Map`s (which `JSON.stringify` turns into `{}`).
for (const key of ["parts", "surfaces", "setups", "dialogs"] as const) {
expect(pkg[key]).not.toBeInstanceOf(Map);
expect(pkg[key]).toEqual(expect.any(Object));
}
// Consumers read the collections with Object.values / Object.keys.
expect(
Object.values(pkg.parts)
.map((p: any) => p.id)
.sort(),
).toEqual(["grain", "wood"]);
expect(Object.keys(pkg.surfaces)).toEqual(["board#harbor", "board#player"]);
expect(Object.keys(pkg.setups)).toEqual(["game#main"]);
});
it("errors on an unknown package", () => {
const plugin = bgm({ root: gamesRoot });
expect(() => load(plugin, "\0bgm:virtual:bgm/package/unknown")).toThrow(
/not found/,
);
});
it("watches every source file for reloads", () => {
const plugin = bgm({ root: gamesRoot });
const watched: string[] = [];
const context = { addWatchFile: (file: string) => watched.push(file) };
// Vite 8 (rolldown) `buildStart` takes `(this, options)`; the plugin ignores
// the options, so pass a placeholder.
(plugin.buildStart as Callable<typeof plugin.buildStart>).call(
context,
{} as never,
);
// Every def file (real + virtual code blocks) is watched so edits
// trigger a re-collect. The real markdown source must be watched too:
// `defMap.files` only lists virtual code-block files, so without watching
// the on-disk `.md` file the dev server would never notice an edit.
expect(watched.length).toBeGreaterThan(0);
expect(watched.some((f) => f.endsWith(".yaml"))).toBe(true);
expect(watched.some((f) => f.endsWith(".csv"))).toBe(true);
expect(watched.some((f) => f.endsWith(".md"))).toBe(true);
expect(watched.every((f) => path.isAbsolute(f))).toBe(true);
});
});