Files
DndGamePlayer/e2e/scene-npc.spec.ts
T
Ivan Fontosh 101f595bac feat(players): app Players library and circular NPC tokens on scenes
Add userData players/teams, scene npcTokens with hex-inscribed sizing, session scale synced to presentation, and Playwright e2e coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 13:38:35 +08:00

205 lines
6.6 KiB
TypeScript

import { test, expect, invokeInRenderer, stubOpenImageDialog } from './fixtures/electron';
type Project = {
id: string;
currentSceneId: string | null;
scenes: Record<
string,
{
tokens: unknown[];
npcTokens: Array<{ id: string; npcId: string; nx: number; ny: number; sizeN: number }>;
}
>;
npcs: Array<{ id: string; name: string }>;
};
async function seedProjectWithNpcAndPlacement(
page: import('@playwright/test').Page,
sampleImagePath: string,
) {
await invokeInRenderer<{ project: Project }>(page, 'project.create', {
name: `E2E Scene ${Date.now()}`,
});
const sceneId = `scene_e2e_${Date.now()}`;
await invokeInRenderer(page, 'project.updateScene', {
sceneId,
patch: {
title: 'Map',
description: '',
media: { videos: [], audios: [] },
settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false },
layout: { x: 0, y: 0 },
previewAssetId: null,
previewAssetType: null,
previewVideoAutostart: false,
npcTokens: [],
tokens: [],
traps: [],
},
});
await invokeInRenderer(page, 'project.setCurrentScene', { sceneId });
await invokeInRenderer(page, 'project.importScenePreview', {
sceneId,
filePath: sampleImagePath,
});
const npcUpsert = await invokeInRenderer<{ project: Project }>(page, 'project.upsertNpc', {
name: `Guard ${Date.now()}`,
filePath: sampleImagePath,
ringColor: '#c9a227',
imageOffset: { x: 0, y: 0 },
});
const npc = npcUpsert.project.npcs[npcUpsert.project.npcs.length - 1]!;
const placementId = `snt_e2e_${Date.now()}`;
await invokeInRenderer(page, 'project.updateScene', {
sceneId,
patch: {
npcTokens: [
{
id: placementId,
npcId: npc.id,
nx: 0.35,
ny: 0.4,
sizeN: 0.12,
},
],
},
});
const refreshed = await invokeInRenderer<{ project: Project }>(page, 'project.get', {});
return {
sceneId,
npcId: npc.id,
placementId,
project: refreshed.project,
};
}
test.describe('Scene NPC tokens', () => {
test('scene editor shows circular NPC token; clear removes both kinds', async ({
electronApp,
editorWindow,
sampleImagePath,
}) => {
await stubOpenImageDialog(electronApp, sampleImagePath);
const seeded = await seedProjectWithNpcAndPlacement(editorWindow, sampleImagePath);
const token = await invokeInRenderer<{ token: { id: string } }>(editorWindow, 'tokens.upsert', {
name: `Prop ${Date.now()}`,
filePath: sampleImagePath,
});
await invokeInRenderer(editorWindow, 'project.updateScene', {
sceneId: seeded.sceneId,
patch: {
tokens: [
{
id: `st_${Date.now()}`,
tokenId: token.token.id,
nx: 0.7,
ny: 0.7,
sizeN: 0.1,
rotationDeg: 0,
},
],
npcTokens: [
{
id: seeded.placementId,
npcId: seeded.npcId,
nx: 0.35,
ny: 0.4,
sizeN: 0.12,
},
],
},
});
const winPromise = electronApp.waitForEvent('window');
await invokeInRenderer(editorWindow, 'windows.openSceneEditor', {});
const sceneWin = await winPromise;
await sceneWin.waitForLoadState('domcontentloaded');
const accordion = sceneWin.getByTestId('scene-npc-accordion');
await expect(accordion).toBeVisible({ timeout: 30_000 });
// Expand if collapsed
await accordion.locator('button').first().click().catch(() => undefined);
await expect(sceneWin.getByTestId(`scene-npc-tile-${seeded.npcId}`)).toBeVisible({
timeout: 15_000,
});
await expect(sceneWin.getByTestId(`scene-npc-token-${seeded.placementId}`)).toBeVisible();
await sceneWin.getByTestId('scene-clear-btn').click();
await expect(sceneWin.getByTestId(`scene-npc-token-${seeded.placementId}`)).toHaveCount(0);
await expect
.poll(
async () => {
const after = await invokeInRenderer<{ project: Project }>(editorWindow, 'project.get', {});
const scene = after.project.scenes[seeded.sceneId]!;
return { tokens: scene.tokens.length, npcTokens: scene.npcTokens.length };
},
{ timeout: 10_000 },
)
.toEqual({ tokens: 0, npcTokens: 0 });
await invokeInRenderer(editorWindow, 'windows.closeSceneEditor', {}).catch(() => undefined);
});
test('session move resets after re-open multiwindow', async ({
editorWindow,
sampleImagePath,
electronApp,
}) => {
await stubOpenImageDialog(electronApp, sampleImagePath);
const seeded = await seedProjectWithNpcAndPlacement(editorWindow, sampleImagePath);
// Need a graph start node for session; open multiwindow may still work with currentSceneId.
await invokeInRenderer(editorWindow, 'project.addSceneGraphNode', {
sceneId: seeded.sceneId,
x: 0,
y: 0,
});
const withNode = await invokeInRenderer<{ project: { sceneGraphNodes: Array<{ id: string }> } }>(
editorWindow,
'project.get',
{},
);
const nodeId = withNode.project.sceneGraphNodes[0]?.id;
if (nodeId) {
await invokeInRenderer(editorWindow, 'project.setSceneGraphNodeStart', {
graphNodeId: nodeId,
});
}
await invokeInRenderer(editorWindow, 'windows.openMultiWindow', {});
await editorWindow.waitForTimeout(1000);
await invokeInRenderer(editorWindow, 'sceneNpcTokensSession.dispatch', {
event: { kind: 'move', placementId: seeded.placementId, nx: 0.8, ny: 0.8 },
});
let session = await invokeInRenderer<{
state: { byPlacementId: Record<string, { nx: number; ny: number }> };
}>(editorWindow, 'sceneNpcTokensSession.getState', {});
expect(session.state.byPlacementId[seeded.placementId]?.nx).toBeCloseTo(0.8, 5);
await invokeInRenderer(editorWindow, 'windows.closeMultiWindow', {});
await editorWindow.waitForTimeout(500);
await invokeInRenderer(editorWindow, 'windows.openMultiWindow', {});
await editorWindow.waitForTimeout(500);
session = await invokeInRenderer(editorWindow, 'sceneNpcTokensSession.getState', {});
expect(session.state.byPlacementId[seeded.placementId]).toBeUndefined();
const proj = await invokeInRenderer<{ project: Project }>(editorWindow, 'project.get', {});
const placement = proj.project.scenes[seeded.sceneId]!.npcTokens.find(
(t) => t.id === seeded.placementId,
);
expect(placement?.nx).toBeCloseTo(0.35, 5);
expect(placement?.ny).toBeCloseTo(0.4, 5);
await invokeInRenderer(editorWindow, 'windows.closeMultiWindow', {}).catch(() => undefined);
});
});