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>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { test as base, expect, _electron as electron, type ElectronApplication, type Page } from '@playwright/test';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const electronExecutable = require('electron') as string;
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const mainEntry = path.join(root, 'dist/main/index.cjs');
|
||||
const fixturePng = path.join(root, 'e2e/fixtures/sample.png');
|
||||
|
||||
const SAMPLE_PNG_B64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
|
||||
|
||||
export type ElectronFixtures = {
|
||||
electronApp: ElectronApplication;
|
||||
editorWindow: Page;
|
||||
userDataDir: string;
|
||||
sampleImagePath: string;
|
||||
};
|
||||
|
||||
async function ensureBuilt() {
|
||||
try {
|
||||
await fs.access(mainEntry);
|
||||
const editorHtml = await fs.readFile(path.join(root, 'dist/renderer/editor.html'), 'utf8');
|
||||
// Production Vite build uses relative asset URLs (required for Electron file://).
|
||||
if (editorHtml.includes('src="/assets/') || editorHtml.includes("src='/assets/")) {
|
||||
throw new Error('dev-base');
|
||||
}
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error && err.message === 'dev-base' ? 'dev-base' : 'missing';
|
||||
throw new Error(
|
||||
reason === 'dev-base'
|
||||
? 'dist/renderer was built with Vite base "/". Run "npm run build" (production) before e2e.'
|
||||
: `Missing ${mainEntry}. Run "npm run build" (or "npm run test:e2e:build") before e2e.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const test = base.extend<ElectronFixtures>({
|
||||
userDataDir: async ({}, use) => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-e2e-'));
|
||||
await use(dir);
|
||||
await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
||||
},
|
||||
|
||||
sampleImagePath: async ({}, use) => {
|
||||
await fs.mkdir(path.dirname(fixturePng), { recursive: true });
|
||||
await fs.writeFile(fixturePng, Buffer.from(SAMPLE_PNG_B64, 'base64'));
|
||||
await use(fixturePng);
|
||||
},
|
||||
|
||||
electronApp: async ({ userDataDir }, use) => {
|
||||
await ensureBuilt();
|
||||
const app = await electron.launch({
|
||||
executablePath: electronExecutable,
|
||||
// Launch the package root so app.getAppPath() is the repo (not dist/main).
|
||||
args: ['.', `--user-data-dir=${userDataDir}`],
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'production',
|
||||
DND_SKIP_LICENSE: '1',
|
||||
DND_SKIP_BOOT: '1',
|
||||
ELECTRON_DISABLE_SECURITY_WARNINGS: 'true',
|
||||
},
|
||||
});
|
||||
await use(app);
|
||||
try {
|
||||
await app.evaluate(({ app: electronApp }) => {
|
||||
electronApp.exit(0);
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
await Promise.race([
|
||||
app.close(),
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 3_000);
|
||||
}),
|
||||
]);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
|
||||
editorWindow: async ({ electronApp }, use) => {
|
||||
const page = await electronApp.firstWindow();
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await expect(page.getByTestId('players-header-btn')).toBeVisible({ timeout: 60_000 });
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
|
||||
/** Stub native open-dialog to return a fixed image path. */
|
||||
export async function stubOpenImageDialog(app: ElectronApplication, filePath: string) {
|
||||
await app.evaluate(async ({ dialog }, chosen) => {
|
||||
dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [chosen] });
|
||||
}, filePath);
|
||||
}
|
||||
|
||||
export async function invokeInRenderer<T>(
|
||||
page: Page,
|
||||
channel: string,
|
||||
payload: unknown = {},
|
||||
): Promise<T> {
|
||||
return page.evaluate(
|
||||
async ({ channel: ch, payload: pl }) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (window as any).dnd.invoke(ch, pl);
|
||||
},
|
||||
{ channel, payload },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { test, expect, stubOpenImageDialog, invokeInRenderer } from './fixtures/electron';
|
||||
|
||||
test.describe('Players library', () => {
|
||||
test('CRUD: add player with progress overlay, ring color persists in store', async ({
|
||||
electronApp,
|
||||
editorWindow,
|
||||
sampleImagePath,
|
||||
}) => {
|
||||
await stubOpenImageDialog(electronApp, sampleImagePath);
|
||||
|
||||
await editorWindow.getByTestId('players-header-btn').click();
|
||||
await expect(editorWindow.getByTestId('players-modal')).toBeVisible();
|
||||
|
||||
await editorWindow.getByTestId('players-add').click();
|
||||
await editorWindow.getByPlaceholder(/Имя игрока|Player name/i).fill('Ada Lovelace');
|
||||
await editorWindow.getByTestId('players-choose-image').click();
|
||||
await expect(editorWindow.getByTestId('player-token-preview')).toBeVisible();
|
||||
|
||||
const colorInput = editorWindow.locator('input[type="color"]').first();
|
||||
await colorInput.fill('#224466');
|
||||
|
||||
await editorWindow.getByTestId('players-save').click();
|
||||
// Progress overlay may flash quickly; assert it either appeared or save finished.
|
||||
await expect
|
||||
.poll(async () => editorWindow.locator('[data-testid^="player-row-"]').count(), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBe(1);
|
||||
|
||||
const listed = await invokeInRenderer<{ players: Array<{ name: string; ringColor: string }> }>(
|
||||
editorWindow,
|
||||
'players.list',
|
||||
{},
|
||||
);
|
||||
expect(listed.players).toHaveLength(1);
|
||||
expect(listed.players[0]!.name).toBe('Ada Lovelace');
|
||||
expect(listed.players[0]!.ringColor.toLowerCase()).toBe('#224466');
|
||||
});
|
||||
|
||||
test('Teams: assign then delete team leaves player ungrouped; no subteam UI', async ({
|
||||
electronApp,
|
||||
editorWindow,
|
||||
sampleImagePath,
|
||||
}) => {
|
||||
await stubOpenImageDialog(electronApp, sampleImagePath);
|
||||
|
||||
await editorWindow.getByTestId('players-header-btn').click();
|
||||
const modal = editorWindow.getByTestId('players-modal');
|
||||
await expect(modal).toBeVisible();
|
||||
await expect(modal).not.toContainText(/подкоманд|sub-?team|parentId/i);
|
||||
|
||||
await editorWindow.getByTestId('players-add').click();
|
||||
await editorWindow.getByPlaceholder(/Имя игрока|Player name/i).fill('Bob');
|
||||
await editorWindow.getByTestId('players-choose-image').click();
|
||||
await editorWindow.getByTestId('players-save').click();
|
||||
await expect(editorWindow.locator('[data-testid^="player-row-"]')).toHaveCount(1, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const dialogHandler = async (d: { message: () => string; accept: (text?: string) => Promise<void> }) => {
|
||||
const msg = d.message();
|
||||
if (/команд|team/i.test(msg) && /цвет|color/i.test(msg)) await d.accept('#ff0000');
|
||||
else if (/цвет|color/i.test(msg)) await d.accept('#ff0000');
|
||||
else await d.accept('Party');
|
||||
};
|
||||
editorWindow.on('dialog', dialogHandler);
|
||||
await editorWindow.getByTestId('players-add-team').click();
|
||||
// Allow prompts to settle
|
||||
await editorWindow.waitForTimeout(500);
|
||||
editorWindow.off('dialog', dialogHandler);
|
||||
|
||||
const state = await invokeInRenderer<{
|
||||
players: Array<{ id: string; teamId: string | null }>;
|
||||
teams: Array<{ id: string; name: string }>;
|
||||
}>(editorWindow, 'players.list', {});
|
||||
|
||||
// If UI prompts failed (headless), create team via IPC.
|
||||
let team = state.teams.find((t) => t.name === 'Party');
|
||||
if (!team) {
|
||||
const created = await invokeInRenderer<{ team: { id: string; name: string } }>(
|
||||
editorWindow,
|
||||
'players.upsertTeam',
|
||||
{ name: 'Party', color: '#ff0000' },
|
||||
);
|
||||
team = created.team;
|
||||
}
|
||||
const player = state.players[0]!;
|
||||
expect(team).toBeTruthy();
|
||||
|
||||
await invokeInRenderer(editorWindow, 'players.assignTeam', {
|
||||
playerId: player.id,
|
||||
teamId: team!.id,
|
||||
});
|
||||
let mid = await invokeInRenderer<{ players: Array<{ teamId: string | null }> }>(
|
||||
editorWindow,
|
||||
'players.list',
|
||||
{},
|
||||
);
|
||||
expect(mid.players[0]!.teamId).toBe(team!.id);
|
||||
|
||||
await invokeInRenderer(editorWindow, 'players.deleteTeam', { id: team!.id });
|
||||
mid = await invokeInRenderer(editorWindow, 'players.list', {});
|
||||
expect(mid.players[0]!.teamId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user