import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; const here = path.dirname(fileURLToPath(import.meta.url)); const rendererRoot = path.resolve(here, '../..'); const SECONDARY_WINDOW_MAINS = [ 'editor/main.tsx', 'npcs/npcsEditorMain.tsx', 'npcs/npcsMain.tsx', 'materials/main.tsx', 'sceneEditor/main.tsx', 'sceneDescription/main.tsx', 'control/main.tsx', 'presentation/main.tsx', ] as const; void test('secondary window mains: WindowErrorBoundary wraps app root', () => { for (const rel of SECONDARY_WINDOW_MAINS) { const src = fs.readFileSync(path.join(rendererRoot, rel), 'utf8'); assert.ok( src.includes('WindowErrorBoundary'), `${rel}: must wrap with WindowErrorBoundary to avoid black screen on React crash`, ); assert.match( src, /[\s\S]*<\/WindowErrorBoundary>/, `${rel}: WindowErrorBoundary must wrap children`, ); } }); void test('NpcDescriptionField: TipTap StrictMode-safe (no black screen on NPC open)', () => { const src = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcDescriptionField.tsx'), 'utf8'); assert.match(src, /immediatelyRender:\s*false/); assert.ok(src.includes('readTipTapHtmlSafe')); assert.ok(src.includes('isDestroyed')); assert.doesNotMatch(src, /immediatelyRender:\s*true/); }); void test('NpcsEditorApp: no undefined controlStyles (inspector crash)', () => { const src = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcsEditorApp.tsx'), 'utf8'); assert.doesNotMatch(src, /controlStyles/); }); void test('NPC binding removed from editor UI', () => { const editor = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcsEditorApp.tsx'), 'utf8'); const modal = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcEditModal.tsx'), 'utf8'); assert.doesNotMatch(editor, /NpcBindingFields|binding/); assert.doesNotMatch(modal, /NpcBindingFields|binding|noneBinding/); assert.ok(!fs.existsSync(path.join(rendererRoot, 'npcs/NpcBindingFields.tsx'))); assert.ok(!fs.existsSync(path.join(rendererRoot, '../shared/npcs/npcBinding.ts'))); }); void test('storyline export modal: NPC selection step', () => { const src = fs.readFileSync(path.join(rendererRoot, 'editor/StorylineTransferModals.tsx'), 'utf8'); assert.match(src, /step === 'npcs'/); assert.match(src, /export\.selectAllNpcs/); assert.match(src, /npcIds/); assert.match(src, /Array\.isArray\(res\?\.npcs\)/); assert.doesNotMatch(src, /filterNpcsForStorylineExport/); }); void test('WindowErrorBoundary component exists and catches errors', () => { const src = fs.readFileSync(path.join(here, 'WindowErrorBoundary.tsx'), 'utf8'); assert.ok(src.includes('getDerivedStateFromError')); assert.ok(src.includes('componentDidCatch')); assert.ok(src.includes('role="alert"')); }); void test('EditorApp: Players header after File, opens modal', () => { const src = fs.readFileSync(path.join(rendererRoot, 'editor/EditorApp.tsx'), 'utf8'); const fileIdx = src.indexOf("t('top.file')"); const playersIdx = src.indexOf('data-testid="players-header-btn"'); assert.ok(fileIdx > 0, 'File menu present'); assert.ok(playersIdx > fileIdx, 'Players button must follow File in source order'); assert.ok(src.includes('PlayersManagerModal')); assert.ok(src.includes('setPlayersManagerOpen(true)')); }); void test('Players modal: flat teams only, progress overlay, PlayerTokenView', () => { const src = fs.readFileSync(path.join(rendererRoot, 'editor/PlayersModals.tsx'), 'utf8'); assert.ok(src.includes('PlayerTokenView')); assert.ok(src.includes('data-testid="players-modal"')); assert.ok(src.includes('data-testid="players-save-progress"')); assert.doesNotMatch(src, /parentId|подкоманд|subteam|sub-team/i); assert.ok(src.includes("application/x-dnd-player-id")); }); void test('Scene NPC accordion: separate mime and PlayerTokenView markers', () => { const scene = fs.readFileSync(path.join(rendererRoot, 'sceneEditor/SceneEditorApp.tsx'), 'utf8'); const tokenTile = fs.readFileSync(path.join(rendererRoot, 'sceneEditor/TokenTile.tsx'), 'utf8'); assert.ok(scene.includes('data-testid="scene-npc-accordion"')); assert.ok(scene.includes("application/x-dnd-scene-npc-id")); assert.ok(tokenTile.includes("application/x-dnd-app-token-id")); assert.notEqual( 'application/x-dnd-scene-npc-id', 'application/x-dnd-app-token-id', 'NPC scene mime must differ from app token mime', ); assert.ok(scene.includes('PlayerTokenView')); assert.ok(scene.includes('persistNpcTokens([])')); assert.doesNotMatch(scene, /SceneTokenMarker[\s\S]{0,80}npcTokens/); }); void test('Control and Presentation use SceneNpcTokensOverlay', () => { const control = fs.readFileSync(path.join(rendererRoot, 'control/ControlApp.tsx'), 'utf8'); const presentation = fs.readFileSync(path.join(rendererRoot, 'shared/PresentationView.tsx'), 'utf8'); assert.ok(control.includes('SceneNpcTokensOverlay')); assert.ok(presentation.includes('SceneNpcTokensOverlay')); assert.ok(control.includes('sceneNpcTokensSession')); }); void test('NpcsEditorApp: token appearance via PlayerTokenView', () => { const src = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcsEditorApp.tsx'), 'utf8'); assert.ok(src.includes('PlayerTokenView')); assert.ok(src.includes('ringColor') || src.includes('updateNpcFields')); });