From 9e677cfc61e6b00f157aed5ab30b11a165ff8247 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Mon, 11 May 2026 17:20:13 +0300 Subject: [PATCH] =?UTF-8?q?test(web-test):=20M4.F=20=E2=80=94=20recording?= =?UTF-8?q?=20smoke=20(video=20+=20captions=20+=20TTS=20+=20overlays)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новый 15-recording.test.mjs (5 шагов, 20.7s) — покрытие полного публичного API recording.md. - record + captions: startRecording → 2× showCaption → stopRecording. Проверки isRecording, duration/size, mp4 на диске, .captions.json, getCaptions с правильными text и time. - narration: addNarration через Edge TTS (ru-RU-DmitryNeural), narrated mp4 больше исходного (добавлен аудио-трек). - title-slide: showTitleSlide/hideTitleSlide — overlay fullscreen (w==innerWidth, h==innerHeight). - image-overlay: showImage/hideImage с тестовой картинкой из screenshot. - highlight: setHighlight toggles isHighlightMode, manual highlight на кнопке «Создать» создаёт overlay позиционированный на элементе. Артефакты в test-tmp/recording-smoke/ (.gitignore), идемпотентный. Полный регресс 15/15 зелёный (7m 27s). Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/web-test/15-recording.test.mjs | 133 +++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 tests/web-test/15-recording.test.mjs diff --git a/tests/web-test/15-recording.test.mjs b/tests/web-test/15-recording.test.mjs new file mode 100644 index 00000000..b346a956 --- /dev/null +++ b/tests/web-test/15-recording.test.mjs @@ -0,0 +1,133 @@ +export const name = 'recording: video, captions, TTS narration, overlays (title/image/highlight)'; +export const tags = ['recording']; +export const timeout = 120000; + +export default async function({ + navigateSection, openCommand, closeForm, + startRecording, stopRecording, showCaption, hideCaption, getCaptions, addNarration, + isRecording, + showTitleSlide, hideTitleSlide, showImage, hideImage, + setHighlight, isHighlightMode, highlight, unhighlight, + screenshot, getPage, + wait, assert, step, log +}) { + const fs = await import('fs'); + const path = await import('path'); + + const overlayIds = async () => { + const p = await getPage(); + return p.evaluate(() => [...document.body.children] + .filter(c => c.id && c.id.startsWith('__web_test')).map(c => c.id)); + }; + + const dir = 'test-tmp/recording-smoke'; + const videoPath = path.join(dir, 'smoke.mp4'); + const captionsJson = path.join(dir, 'smoke.captions.json'); + const narratedPath = path.join(dir, 'smoke-narrated.mp4'); + + // Idempotent: убрать артефакты прошлого прогона + for (const f of [videoPath, captionsJson, narratedPath]) { + try { fs.unlinkSync(f); } catch {} + } + + await step('record + captions: startRecording → showCaption ×2 → stopRecording', async () => { + await startRecording(videoPath, { fps: 15 }); + assert.equal(isRecording(), true, 'isRecording=true пока идёт запись'); + + await showCaption('Открываем Контрагентов'); + await navigateSection('Склад'); + await openCommand('Контрагенты'); + await wait(1); + await hideCaption(); + + await showCaption('Закрываем форму'); + await closeForm(); + await wait(1); + await hideCaption(); + + const result = await stopRecording(); + log(`stop result: file=${path.basename(result.file)} duration=${result.duration}s size=${result.size}B captions=${result.captions}`); + assert.equal(isRecording(), false, 'isRecording=false после stopRecording'); + assert.equal(result.captions, 2, 'два collected caption'); + assert.ok(result.duration >= 3, `duration >= 3s (got ${result.duration})`); + assert.ok(result.size > 10000, `mp4 размер > 10KB (got ${result.size})`); + assert.ok(fs.existsSync(result.file), 'mp4 файл создан на диске'); + assert.ok(fs.existsSync(captionsJson), '.captions.json создан рядом с mp4'); + + const captions = getCaptions(); + assert.equal(captions.length, 2, 'getCaptions() возвращает 2 записи'); + assert.equal(captions[0].text, 'Открываем Контрагентов', 'текст первой подписи'); + assert.equal(captions[1].text, 'Закрываем форму', 'текст второй подписи'); + assert.ok(captions[1].time > captions[0].time, 'time второй подписи > первой'); + }); + + await step('narration: addNarration генерирует mp4 со звуковой дорожкой через edge TTS', async () => { + assert.ok(fs.existsSync(videoPath), 'исходный mp4 должен существовать'); + const result = await addNarration(videoPath, { provider: 'edge', voice: 'ru-RU-DmitryNeural' }); + log(`narration: file=${path.basename(result.file)} duration=${result.duration}s size=${result.size}B captions=${result.captions}`); + assert.equal(result.captions, 2, 'narration использовал 2 подписи'); + assert.ok(result.size > 10000, `narrated mp4 > 10KB (got ${result.size})`); + assert.ok(fs.existsSync(result.file), 'narrated mp4 создан'); + // narrated.mp4 должен быть больше исходного (добавлен аудио-трек) + const origSize = fs.statSync(videoPath).size; + assert.ok(result.size > origSize, `narrated (${result.size}) > original (${origSize}) — добавлен аудио-трек`); + }); + + await step('title-slide: showTitleSlide создаёт fullscreen overlay, hideTitleSlide убирает', async () => { + await showTitleSlide('Заголовок', { subtitle: 'подзаголовок' }); + const p = await getPage(); + const view = await p.evaluate(() => ({ w: window.innerWidth, h: window.innerHeight })); + const overlays = await p.evaluate(() => [...document.body.children] + .filter(c => c.id && c.id.startsWith('__web_test_title')) + .map(c => ({ id: c.id, w: c.offsetWidth, h: c.offsetHeight }))); + log(`title overlays: ${JSON.stringify(overlays)}`); + assert.equal(overlays.length, 1, 'один title overlay'); + assert.equal(overlays[0].w, view.w, 'overlay перекрывает всю ширину viewport'); + assert.equal(overlays[0].h, view.h, 'overlay перекрывает всю высоту viewport'); + await hideTitleSlide(); + const after = await overlayIds(); + assert.ok(!after.includes('__web_test_title'), 'title overlay удалён'); + }); + + await step('image-overlay: showImage создаёт overlay, hideImage убирает', async () => { + // используем свежий screenshot как тестовую картинку + const imgPath = path.join(dir, 'sample.png'); + const png = await screenshot(); + fs.writeFileSync(imgPath, png); + await showImage(imgPath, { style: 'dark' }); + const p = await getPage(); + const overlays = await p.evaluate(() => [...document.body.children] + .filter(c => c.id && c.id.startsWith('__web_test_image')) + .map(c => ({ id: c.id, w: c.offsetWidth, h: c.offsetHeight }))); + log(`image overlays: ${JSON.stringify(overlays)}`); + assert.equal(overlays.length, 1, 'один image overlay'); + assert.ok(overlays[0].w > 0 && overlays[0].h > 0, 'overlay имеет размер'); + await hideImage(); + const after = await overlayIds(); + assert.ok(!after.includes('__web_test_image'), 'image overlay удалён'); + }); + + await step('highlight: setHighlight toggles isHighlightMode; manual highlight/unhighlight создают и убирают overlay', async () => { + assert.equal(isHighlightMode(), false, 'highlight mode выключен по умолчанию'); + setHighlight(true); + assert.equal(isHighlightMode(), true, 'после setHighlight(true) — включён'); + setHighlight(false); + assert.equal(isHighlightMode(), false, 'после setHighlight(false) — выключен'); + + // Manual highlight требует элемент на форме — откроем список + await navigateSection('Склад'); + await openCommand('Контрагенты'); + await highlight('Создать'); + const p = await getPage(); + const overlays = await p.evaluate(() => [...document.body.children] + .filter(c => c.id && c.id.startsWith('__web_test_highlight')) + .map(c => ({ id: c.id, w: c.offsetWidth, h: c.offsetHeight }))); + log(`highlight overlays: ${JSON.stringify(overlays)}`); + assert.equal(overlays.length, 1, 'один highlight overlay'); + assert.ok(overlays[0].w > 0 && overlays[0].h > 0, 'overlay позиционирован на элементе'); + await unhighlight(); + const after = await overlayIds(); + assert.ok(!after.includes('__web_test_highlight'), 'highlight overlay удалён'); + await closeForm(); + }); +}