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)); function readControlApp(): string { return fs.readFileSync(path.join(here, 'ControlApp.tsx'), 'utf8'); } function readControlAppCss(): string { return fs.readFileSync(path.join(here, 'ControlApp.module.css'), 'utf8'); } void test('ControlApp: звук молнии (public/molniya.mp3)', () => { const src = readControlApp(); assert.ok(src.includes('molniya.mp3')); assert.ok(src.includes('playLightningEffectSound')); assert.ok(src.includes('LIGHTNING_EFFECT_MS')); }); void test('ControlApp: звук заморозки (public/zamorozka.mp3)', () => { const src = readControlApp(); assert.ok(src.includes('zamorozka.mp3')); assert.ok(src.includes('playFreezeEffectSound')); }); void test('ControlApp: звук луча света (public/luch_sveta.mp3)', () => { const appSrc = readControlApp(); const sfxSrc = fs.readFileSync(path.join(here, 'sunbeamSfx.ts'), 'utf8'); assert.ok(appSrc.includes('playSunbeamEffectSound')); assert.ok(appSrc.includes('getSunbeamEffectLifeMs')); assert.ok(sfxSrc.includes('luch_sveta.mp3')); assert.ok(sfxSrc.includes('playbackRate')); assert.ok(sfxSrc.includes('SUNBEAM_PLAYBACK_RATE')); }); void test('ControlApp: звук облака яда (public/oblako-yada.mp3)', () => { const appSrc = readControlApp(); const sfxSrc = fs.readFileSync(path.join(here, 'poisonCloudSfx.ts'), 'utf8'); assert.ok(appSrc.includes('getPoisonCloudEffectLifeMs')); assert.ok(appSrc.includes('playPoisonCloudEffectSound')); assert.ok(sfxSrc.includes('oblako-yada.mp3')); assert.ok(sfxSrc.includes('playbackRate')); }); void test('ControlApp: активация ловушки «яд» спавнит poisonCloud как на пульте эффектов', () => { const src = readControlApp(); assert.ok(src.includes("trap?.type === 'poison'")); assert.ok(src.includes("type: 'poisonCloud'")); assert.ok(src.includes('trap_pc_')); assert.ok(src.includes('playPoisonCloudEffectSound(poisonLifeMs)')); }); void test('ControlApp: эффект «взрыв» + ловушка используют explosion webm/mp3', () => { const appSrc = readControlApp(); const sfxSrc = fs.readFileSync(path.join(here, 'explosionSfx.ts'), 'utf8'); assert.ok(appSrc.includes("title={t('control.explosion')}")); assert.ok(appSrc.includes("tool: 'explosion'")); assert.ok(appSrc.includes("type: 'explosion'")); assert.ok(appSrc.includes('getExplosionEffectLifeMs')); assert.ok(appSrc.includes('playExplosionEffectSound')); assert.ok(appSrc.includes("trap?.type === 'explosion'")); assert.ok(appSrc.includes('trap_ex_')); assert.ok(sfxSrc.includes('explosion.mp3')); assert.ok(sfxSrc.includes('aerial-debris-smoke.webm')); assert.ok(appSrc.includes('ExplosionVideoOverlay')); const videoOverlaySrc = fs.readFileSync( path.join(here, '..', 'shared', 'effects', 'ExplosionVideoOverlay.tsx'), 'utf8', ); assert.ok(videoOverlaySrc.includes('explosionEffectVideoUrl')); }); void test('ControlApp: эффекты в пульте, иконки с тултипами и подписью для a11y', () => { const src = readControlApp(); assert.ok(src.includes("t('control.instruments')")); assert.ok(src.includes("t('control.descriptionTool')")); assert.ok(src.includes("t('control.descriptionMissing')")); assert.ok(src.includes('openSceneDescription')); assert.ok(!src.includes('SceneDescriptionViewModal')); assert.ok(src.includes("t('control.effects')")); assert.ok(src.includes("t('control.tools')")); assert.ok(src.includes("t('control.fieldEffects')")); assert.ok(src.includes("t('control.actionEffects')")); assert.ok(src.includes("t('control.darknessControl')")); assert.ok(src.includes("t('control.explorerBrush')")); assert.ok(src.includes("t('control.closerBrush')")); assert.ok(src.includes("tool: 'closeBrush'") || src.includes("selectEffectTool('closeBrush')")); assert.ok(src.includes("mode: b.tool === 'closeBrush' ? 'cover' : 'reveal'") || src.includes("'cover'")); assert.ok(src.includes('SceneDarknessOverlay')); assert.ok(src.includes('useSceneDarknessState')); assert.ok(src.includes("t('control.sunbeam')")); assert.ok(src.includes("t('control.lightning')")); assert.ok(src.includes("title={t('control.water')}")); assert.ok(src.includes("title={t('control.fire')}")); assert.ok(src.includes("title={t('control.darkness')}")); assert.ok(src.includes("title={t('control.poisonCloud')}")); assert.ok(src.includes("title={t('control.fog')}")); assert.ok(src.includes("ariaLabel={t('control.fog')}")); assert.ok(src.includes('selectEffectTool'), 'повторный клик снимает инструмент'); assert.ok(src.includes("tool.tool === next ? 'none' : next")); assert.ok(src.includes('iconOnly')); assert.ok(src.includes("title={t('control.clearEffects')}")); assert.ok(src.includes("ariaLabel={t('control.clearEffects')}")); assert.ok(src.includes('#e5484d')); const instruments = src.indexOf("t('control.instruments')"); const fx = src.indexOf("t('control.effects')"); const story = src.indexOf("t('control.storyLine')"); assert.ok( instruments !== -1 && fx !== -1 && instruments < fx, 'Блок инструментов должен быть выше эффектов', ); assert.ok(fx !== -1 && story !== -1 && fx < story, 'Блок эффектов должен быть выше сюжетной линии'); }); void test('ControlApp: чекбокс привязки токенов к сетке', () => { const src = readControlApp(); const css = readControlAppCss(); assert.ok(src.includes('useTokenGridSnapSession')); assert.ok(src.includes('snapNormToGridCell')); assert.ok(src.includes('data-testid="token-grid-snap"')); assert.ok(src.includes("t('control.snapTokensToGrid')")); assert.ok(src.includes('snapAllTokensToGrid')); assert.ok(src.includes('snapNorm={snapNormActive}')); assert.ok(css.includes('.snapToGrid')); }); void test('ControlApp: сюжетная линия — колонка сверху вниз и фон как у карточек ветвления', () => { const src = readControlApp(); const css = readControlAppCss(); const story = src.indexOf("t('control.storyLine')"); assert.ok(story !== -1); assert.ok(src.includes('className={styles.storyScroll}')); assert.match(css, /\.storyScroll[\s\S]*?justify-content:\s*flex-start/); assert.match(css, /\.storyScroll[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/); assert.match(css, /\.branchCard[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/); }); void test('ControlApp: клик по истории добавляет новый шаг, не подавляет запись', () => { const src = readControlApp(); assert.ok(!src.includes('suppressNextHistoryPushRef')); assert.ok(!src.includes('arr.includes(cur)')); assert.ok(src.includes('historyRef.current = [...arr, cur]')); }); void test('ControlApp: текущая сцена в истории — последнее вхождение', () => { const src = readControlApp(); assert.ok(src.includes('history.lastIndexOf(currentGraphNodeId)')); assert.ok(src.includes('idx === currentHistoryIdx')); }); void test('ControlApp: сюжетная линия не меняет граф и сцены проекта', () => { const src = readControlApp(); const story = src.indexOf("t('control.storyLine')"); assert.ok(story !== -1); const tail = src.slice(story); assert.ok(!tail.includes('addSceneGraphNode')); assert.ok(!tail.includes('removeSceneGraphNode')); assert.ok(!tail.includes('addSceneGraphEdge')); assert.ok(!tail.includes('removeSceneGraphEdge')); assert.ok(!tail.includes('updateProject')); assert.ok(tail.includes('ipcChannels.project.setCurrentGraphNode')); }); void test('ControlApp: слой кисти не использует курсор not-allowed (ластик тоже crosshair)', () => { const src = readControlApp(); const css = readControlAppCss(); assert.ok(!src.includes("tool.tool === 'eraser' ? 'not-allowed'")); assert.ok(src.includes('className={styles.brushLayer}')); assert.match(css, /\.brushLayer[\s\S]*?cursor:\s*crosshair/); }); void test('ControlApp: радиус кисти не в блоке предпросмотра', () => { const src = readControlApp(); const previewLabel = src.indexOf("t('control.screenPreview')"); const radius = src.indexOf("t('control.brushRadius')"); assert.ok(previewLabel !== -1 && radius !== -1); assert.ok( radius < previewLabel, 'Слайдер радиуса должен быть в пульте (файл: выше заголовка предпросмотра)', ); }); void test('ControlApp: музыка разделена на сцену и кампанию', () => { const src = readControlApp(); assert.ok(src.includes("t('control.sceneMusic')")); assert.ok(src.includes("t('control.gameMusic')")); // при музыке сцены — кампанию ставим на паузу assert.ok(src.includes('allowCampaignAudio')); assert.ok( src.includes('campaignAudioSpecKey'), 'кампания: перезагрузка аудио привязана к списку треков, не к смене сцены', ); assert.match(src, /pause campaign\./i); }); void test('ControlApp: у каждой аудиозаписи есть регулятор громкости под транспортом', () => { const src = readControlApp(); const card = fs.readFileSync(path.join(here, 'ControlAudioCard.tsx'), 'utf8'); const css = readControlAppCss(); assert.ok(src.includes('ControlAudioCard')); assert.ok(src.includes("t('control.volume')")); assert.ok(src.includes('sceneAudioGainRef')); assert.ok(src.includes('campaignAudioGainRef')); assert.ok(src.includes('applyAudioGain')); assert.ok(card.includes('VolumeSpeakerIcon')); assert.ok(card.includes('styles.audioVolume')); assert.ok(card.includes('styles.audioVolumeRow')); assert.match(css, /\.audioControls[\s\S]*?flex-direction:\s*column/); assert.match(css, /\.audioVolumeRow\b/); assert.match(css, /\.audioVolumeIcon\b/); assert.match(css, /\.audioVolume\b/); }); void test('ControlApp: весь контент скроллится в окне, отступы сверху и снизу равны', () => { const css = readControlAppCss(); assert.match(css, /\.page\s*\{[^}]*padding:\s*16px/s); assert.match(css, /\.page\s*\{[^}]*overflow:\s*auto/s); assert.doesNotMatch(css, /\.rightStack\s*\{[^}]*overflow-y:\s*auto/s); }); void test('ControlApp: вода/дождь гасят огонь; ambient огня/дождя + слайдер «Звук эффектов»', () => { const src = readControlApp(); const fireSfx = fs.readFileSync(path.join(here, 'fireAmbientSfx.ts'), 'utf8'); const rainSfx = fs.readFileSync(path.join(here, 'rainAmbientSfx.ts'), 'utf8'); const i18n = fs.readFileSync(path.join(here, '../editor/i18n/editorMessages.ts'), 'utf8'); assert.ok(src.includes('extinguishFireAlong')); assert.ok(src.includes("types: ['fire']")); assert.ok(src.includes("hitMode: 'brush'")); assert.ok(src.includes('effectsSoundRow')); assert.ok(src.includes('setFireAmbientActive')); assert.ok(src.includes('setRainAmbientActive')); assert.ok(src.includes('hasFireOnScene')); assert.ok(src.includes('hasRainOnScene')); assert.ok(src.includes("t('control.effectsSound')")); assert.ok(fireSfx.includes('fire-ambient.mp3')); assert.ok(rainSfx.includes('rain-ambient.mp3')); assert.ok(i18n.includes("'control.effectsSound': 'Звук эффектов'")); const radius = src.indexOf("t('control.brushRadius')"); const effectsSound = src.indexOf("t('control.effectsSound')"); assert.ok(radius !== -1 && effectsSound !== -1 && radius < effectsSound); }); void test('ControlApp: загрузка камп. аудио — useEffect зависит только от api и campaignAudioSpecKey', () => { const src = readControlApp(); const re = /\/\/ Campaign elements:[\s\S]*?useEffect\(\(\) => \{[\s\S]*?\}\s*,\s*\[([^\]]*)\]\s*\)\s*;/; const m = re.exec(src); assert.ok(m, 'ожидается useEffect загрузки кампании после комментария Campaign elements'); const depList = m[1]; assert.ok(depList !== undefined); const deps = depList .split(',') .map((s) => s.trim()) .filter(Boolean); assert.deepEqual( deps, ['api', 'campaignAudioSpecKey'], 'смена сцены / allowCampaignAudio / campaignAudioRefs не должны перезапускать загрузку кампании', ); assert.ok(!/\ballowCampaignAudio\b/.test(depList)); assert.ok(!/\bcurrentScene\b/.test(depList)); assert.ok(!/\bproject\b/.test(depList)); assert.ok(!/\bcampaignAudioRefs\b/.test(depList)); });