test(bgm): cover emitted shape and multi-package collection
Add a SerializedPackage type for the JSON the plugin emits, and tests that assert the maps serialize to plain objects, that the packages module returns every package, and that buildStart watches def files. Add a second fixture package to exercise multi-package collection.
This commit is contained in:
@@ -0,0 +1,52 @@
|
|||||||
|
# Azul
|
||||||
|
|
||||||
|
A second tiny example game, used to exercise the loader's collection of
|
||||||
|
multiple packages.
|
||||||
|
|
||||||
|
```yaml file=azul.yaml
|
||||||
|
role: package
|
||||||
|
id: azul
|
||||||
|
title: Azul
|
||||||
|
designer: Michael Kiesling
|
||||||
|
players: 4
|
||||||
|
language: en
|
||||||
|
include: ['**/azul/**/*.yaml']
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml file=parts/tiles.yaml
|
||||||
|
role: part
|
||||||
|
type: tile
|
||||||
|
id: blue
|
||||||
|
face: ./assets/tiles.png
|
||||||
|
faceCrop: [0, 0, 5, 5]
|
||||||
|
size: [20, 20, 3]
|
||||||
|
fillet: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml file=parts/board.yaml
|
||||||
|
type: board
|
||||||
|
id: azul
|
||||||
|
role: surface
|
||||||
|
size: [400, 300]
|
||||||
|
layout:
|
||||||
|
- route: /factory/:n
|
||||||
|
candidates:
|
||||||
|
$variants: ./factories.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
```csv file=parts/factories.csv
|
||||||
|
n,x,y,rotation
|
||||||
|
string,number,number,number
|
||||||
|
0,-150,0,0
|
||||||
|
1,-50,0,0
|
||||||
|
2,50,0,0
|
||||||
|
3,150,0,0
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml file=setup/main.yaml
|
||||||
|
role: setup
|
||||||
|
type: game
|
||||||
|
id: main
|
||||||
|
setup:
|
||||||
|
/factory/0: azul:tile#blue
|
||||||
|
```
|
||||||
@@ -10,6 +10,7 @@ title: Harbor
|
|||||||
designer: Jane Doe
|
designer: Jane Doe
|
||||||
players: 2
|
players: 2
|
||||||
language: en
|
language: en
|
||||||
|
include: ['**/harbor/**/*.yaml']
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml file=parts/tokens.yaml
|
```yaml file=parts/tokens.yaml
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import harbor from 'virtual:bgm/package/harbor';
|
import harbor from 'virtual:bgm/package/harbor';
|
||||||
|
import azul from 'virtual:bgm/package/azul';
|
||||||
import packages from 'virtual:bgm/packages';
|
import packages from 'virtual:bgm/packages';
|
||||||
|
|
||||||
// Re-export the package data so the test can assert the bundled output.
|
// Re-export the package data so the test can assert the bundled output.
|
||||||
export const parts = [...harbor.parts.keys()];
|
export const parts = Object.keys(harbor.parts);
|
||||||
export const surfaces = [...harbor.surfaces.keys()];
|
export const surfaces = Object.keys(harbor.surfaces);
|
||||||
export const setups = [...harbor.setups.keys()];
|
export const setups = Object.keys(harbor.setups);
|
||||||
export const title = harbor.meta.title;
|
export const title = harbor.meta.title;
|
||||||
|
export const azulTitle = azul.meta.title;
|
||||||
export const allIds = packages.map((p) => p.meta.id);
|
export const allIds = packages.map((p) => p.meta.id);
|
||||||
|
|
||||||
console.log(title, parts, surfaces, setups, allIds);
|
console.log(title, parts, surfaces, setups, allIds, azulTitle);
|
||||||
|
|||||||
@@ -191,6 +191,21 @@ export interface Package {
|
|||||||
setups: Map<string, Setup>;
|
setups: Map<string, Setup>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A package as emitted by the vite plugin: the `Map`s are serialized to
|
||||||
|
* plain objects keyed by `type#id`, since `JSON.stringify` can't encode a
|
||||||
|
* `Map`. This is the shape consumers receive from `virtual:bgm/*` modules.
|
||||||
|
*/
|
||||||
|
export interface SerializedPackage {
|
||||||
|
meta: PackageMeta;
|
||||||
|
/** All parts by `type#id`. */
|
||||||
|
parts: Record<string, Part>;
|
||||||
|
/** All surfaces by `type#id`. */
|
||||||
|
surfaces: Record<string, Surface>;
|
||||||
|
/** All setups by `type#id`. */
|
||||||
|
setups: Record<string, Setup>;
|
||||||
|
}
|
||||||
|
|
||||||
/** Errors during loading, carrying the source location when available. */
|
/** Errors during loading, carrying the source location when available. */
|
||||||
export class BgmError extends Error {
|
export class BgmError extends Error {
|
||||||
constructor(message: string, readonly location?: string) {
|
constructor(message: string, readonly location?: string) {
|
||||||
|
|||||||
@@ -41,9 +41,14 @@ describe('bgm vite plugin (integration)', () => {
|
|||||||
expect(code).toContain('game#main');
|
expect(code).toContain('game#main');
|
||||||
expect(code).toContain('Harbor');
|
expect(code).toContain('Harbor');
|
||||||
|
|
||||||
|
// A second package resolves through the same plugin.
|
||||||
|
expect(code).toContain('azul');
|
||||||
|
expect(code).toContain('tile#blue');
|
||||||
|
|
||||||
// The `bgm` module lists every discovered package.
|
// The `bgm` module lists every discovered package.
|
||||||
expect(code).toContain('allIds');
|
expect(code).toContain('allIds');
|
||||||
expect(code).toContain('"harbor"');
|
expect(code).toContain('"harbor"');
|
||||||
|
expect(code).toContain('"azul"');
|
||||||
} finally {
|
} finally {
|
||||||
await fs.promises.rm(outDir, { recursive: true, force: true });
|
await fs.promises.rm(outDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import type { Plugin } from 'vite';
|
import type { Plugin } from 'vite';
|
||||||
import { collectPackages, loadDefs } from './collect.js';
|
import { collectPackages, loadDefs } from './collect.js';
|
||||||
import type { Package } from './types.js';
|
import type { Package, SerializedPackage } from './types.js';
|
||||||
|
|
||||||
const VIRTUAL_PREFIX = '\0bgm:';
|
const VIRTUAL_PREFIX = '\0bgm:';
|
||||||
/** Public specifier for the module that lists every package. */
|
/** Public specifier for the module that lists every package. */
|
||||||
@@ -73,7 +73,7 @@ export function bgm(options: BgmOptions): Plugin {
|
|||||||
* Serialize a `Package` for JSON emission. The parts/surfaces/setups are
|
* Serialize a `Package` for JSON emission. The parts/surfaces/setups are
|
||||||
* `Map`s, which `JSON.stringify` would otherwise turn into `{}`.
|
* `Map`s, which `JSON.stringify` would otherwise turn into `{}`.
|
||||||
*/
|
*/
|
||||||
function toJson(pkg: Package): Record<string, unknown> {
|
function toJson(pkg: Package): SerializedPackage {
|
||||||
return {
|
return {
|
||||||
meta: pkg.meta,
|
meta: pkg.meta,
|
||||||
parts: Object.fromEntries(pkg.parts),
|
parts: Object.fromEntries(pkg.parts),
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ function load(plugin: ReturnType<typeof bgm>, id: string): unknown {
|
|||||||
return (plugin.load as Callable<typeof plugin.load>)(id, { ssr: false });
|
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', () => {
|
describe('bgm vite plugin', () => {
|
||||||
it('resolves bgm imports to the virtual module', () => {
|
it('resolves bgm imports to the virtual module', () => {
|
||||||
const plugin = bgm({ root: gamesRoot });
|
const plugin = bgm({ root: gamesRoot });
|
||||||
@@ -34,10 +40,7 @@ describe('bgm vite plugin', () => {
|
|||||||
|
|
||||||
it('loads every package as a JSON module', () => {
|
it('loads every package as a JSON module', () => {
|
||||||
const plugin = bgm({ root: gamesRoot });
|
const plugin = bgm({ root: gamesRoot });
|
||||||
const code = load(plugin, '\0bgm:virtual:bgm/packages');
|
const packages = parseModule(load(plugin, '\0bgm:virtual:bgm/packages')) as Array<Record<string, any>>;
|
||||||
expect(code).toBeDefined();
|
|
||||||
expect(String(code).startsWith('export default ')).toBe(true);
|
|
||||||
const packages = JSON.parse(String(code).slice('export default '.length));
|
|
||||||
expect(packages).toHaveLength(1);
|
expect(packages).toHaveLength(1);
|
||||||
expect(packages[0].meta.id).toBe('harbor');
|
expect(packages[0].meta.id).toBe('harbor');
|
||||||
expect(packages[0].parts).toHaveProperty('token#wood');
|
expect(packages[0].parts).toHaveProperty('token#wood');
|
||||||
@@ -45,17 +48,45 @@ describe('bgm vite plugin', () => {
|
|||||||
|
|
||||||
it('loads a package as a JSON module', () => {
|
it('loads a package as a JSON module', () => {
|
||||||
const plugin = bgm({ root: gamesRoot });
|
const plugin = bgm({ root: gamesRoot });
|
||||||
const code = load(plugin, '\0bgm:virtual:bgm/package/harbor');
|
const pkg = parseModule(load(plugin, '\0bgm:virtual:bgm/package/harbor')) as Record<string, any>;
|
||||||
expect(code).toBeDefined();
|
|
||||||
expect(String(code).startsWith('export default ')).toBe(true);
|
|
||||||
const pkg = JSON.parse(String(code).slice('export default '.length));
|
|
||||||
expect(pkg.meta.id).toBe('harbor');
|
expect(pkg.meta.id).toBe('harbor');
|
||||||
expect(pkg.parts).toHaveProperty('token#wood');
|
expect(pkg.parts).toHaveProperty('token#wood');
|
||||||
expect(pkg.surfaces).toHaveProperty('board#harbor');
|
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'] 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']);
|
||||||
|
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 });
|
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', () => {
|
||||||
|
const plugin = bgm({ root: gamesRoot });
|
||||||
|
const watched: string[] = [];
|
||||||
|
const context = { addWatchFile: (file: string) => watched.push(file) };
|
||||||
|
(plugin.buildStart as Callable<typeof plugin.buildStart>).call(context);
|
||||||
|
|
||||||
|
// Every def file (real + virtual code blocks) is watched so edits
|
||||||
|
// trigger a re-collect.
|
||||||
|
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.every((f) => path.isAbsolute(f))).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user