d9fbecf5a7
Move audio scrub/volume and brush drafts off root React ticks, coalesce overlay layout IPC, cache machine fingerprint and asset URLs, share one scene overlay host, and stop idle Pixi ticker. Also harden console against EPIPE on window load failures. Co-authored-by: Cursor <cursoragent@cursor.com>
201 lines
9.3 KiB
TypeScript
201 lines
9.3 KiB
TypeScript
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: эффекты в пульте, иконки с тултипами и подписью для 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('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('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();
|
|
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: загрузка камп. аудио — 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));
|
|
});
|