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 }; }>(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); }); });