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.
This commit is contained in:
@@ -1,22 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { bgm } from './vite.js';
|
||||
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');
|
||||
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;
|
||||
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,
|
||||
});
|
||||
return (plugin.resolveId as Callable<typeof plugin.resolveId>)(
|
||||
id,
|
||||
undefined,
|
||||
{
|
||||
isEntry: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function load(plugin: ReturnType<typeof bgm>, id: string): unknown {
|
||||
@@ -25,73 +34,94 @@ function load(plugin: ReturnType<typeof bgm>, id: string): unknown {
|
||||
|
||||
/** 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));
|
||||
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', () => {
|
||||
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();
|
||||
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', () => {
|
||||
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>>;
|
||||
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');
|
||||
expect(packages[0].meta.id).toBe("harbor");
|
||||
expect(packages[0].parts).toHaveProperty("token#wood");
|
||||
});
|
||||
|
||||
it('loads a package as a JSON module', () => {
|
||||
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');
|
||||
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', () => {
|
||||
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>;
|
||||
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'] as const) {
|
||||
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']);
|
||||
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', () => {
|
||||
it("errors on an unknown package", () => {
|
||||
const plugin = bgm({ root: gamesRoot });
|
||||
expect(() => load(plugin, '\0bgm:virtual:bgm/package/unknown')).toThrow(/not found/);
|
||||
expect(() => load(plugin, "\0bgm:virtual:bgm/package/unknown")).toThrow(
|
||||
/not found/,
|
||||
);
|
||||
});
|
||||
|
||||
it('watches every source file for reloads', () => {
|
||||
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);
|
||||
(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.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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user