From deb7df20d04fda8ba98a5b0731487704b02c4f57 Mon Sep 17 00:00:00 2001 From: rizkyaz0 Date: Sun, 4 Oct 2026 00:45:09 +0700 Subject: [PATCH] Tambah uji regresi layar hasil dan VS: menu tidak menutupi arena setelah MAIN LAGI, Spasi/Enter tidak memulai ulang, toast tidak menutupi bilah VS, Statistik tidak meluap di layar pendek dan potret; catat di ENGINE_NOTES --- docs/ENGINE_NOTES.md | 4 +-- tests/t2-vsresult.mjs | 73 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/docs/ENGINE_NOTES.md b/docs/ENGINE_NOTES.md index 9415328..d18bd28 100644 --- a/docs/ENGINE_NOTES.md +++ b/docs/ENGINE_NOTES.md @@ -18,7 +18,7 @@ Game fighting 2D canvas, tanpa dependensi dan tanpa langkah build. Semua teks un - `touch.js`: kontrol sentuh; tombol memanggil `__game.input(key, down)`. - `brand.js` (`window.Brand`) + `content/loading.js` (`window.LOADING_QUOTES`) + `content/changelog.js` (`window.CHANGELOG`): dimuat di `index.html` tepat setelah kartu `#boot`, sebelum `precache.js`. Kutipan acak (tidak pernah sama dengan yang terakhir, kunci `konoha.loadquote`; `?quote=N` memaksa) tampil di `#boot .boot-quote` dan `#load-state .load-quote`. "Yang baru": `Brand.decorate(root, screen)` dipanggil `menu.js` di akhir `render()`; layar `about` mendapat panel `.whatsnew-panel` dan tautan kredit, layar `home` memicu toast `.brand-toast` satu kali bila versi teratas CHANGELOG berbeda dari `localStorage['konoha.changelog.seen']` (pemain tanpa simpanan `konoha.save.v1` tidak diberi toast). Menambah pembaruan: sisipkan objek baru di AWAL `content/changelog.js` dengan version lebih besar. Berkas `content/*.js` ikut `tools/build_precache.py`. - Latar menu utama dan About: `menu.js` `collageHtml()` menyusun tujuh potret `assets/menu/-select.webp` (lima di layar sentuh) menjadi kolase CSS (`.hc-*` di bagian "Merek" paling bawah `menu.css`). Gerak berhenti lewat kelas `.bg-still` pada `#front-menu` (`FrontEnd.syncBackground()`: Pengaturan terbuka, tab tersembunyi, saklar "Animasi latar menu", `prefers-reduced-motion`). Video dan gambar bergaya game asal (`home-factions.webp`, `home-factions-loop.mp4`, `home-art.webp`) tidak dipakai lagi tetapi berkasnya masih ada (dan ikut daftar precache). -- `vs.js` (`window.VS`, `window.ResultView`) + `vs.css` + `content/quotes.js` (`window.QUOTES`: intro/win/lose per karakter): layar VS dan layar hasil. `startMatch({..., vs: true | {label, p2}})` menampilkan layar VS 2,2 dtk (hanya mode versus; latihan tidak); selama itu `vsHold` di game.js menahan `advanceMatch` dan cue 'Round 1' keluar tepat sekali saat VS selesai atau dilewati (klik/Spasi/Enter/Esc setelah 0,38 dtk). `startMatch` tanpa `vs` berperilaku seperti dulu. `menu.js` screen `result` memakai `ResultView.view/mount` (layar lama jadi cadangan); `Meta.award` menyimpan rincian di `Meta.lastAward`; `Meta.nextFight`/`finishRun` memakai VS dan layar hasil yang sama. Pemilih kutipan `VS.quote(id, 'intro'|'win'|'lose')` memakai pengacak kosmetik terpisah. API uji: `__game.vsActive()`, `__game.vsSkip()`. +- `vs.js` (`window.VS`, `window.ResultView`) + `vs.css` + `content/quotes.js` (`window.QUOTES`: intro/win/lose per karakter): layar VS dan layar hasil. `startMatch({..., vs: true | {label, p2}})` menampilkan layar VS 2,2 dtk (hanya mode versus; latihan tidak); selama itu `vsHold` di game.js menahan `advanceMatch` dan cue 'Round 1' keluar tepat sekali saat VS selesai atau dilewati (klik/Spasi/Enter/Esc setelah 0,38 dtk). `startMatch` tanpa `vs` berperilaku seperti dulu. `menu.js` screen `result` memakai `ResultView.view/mount` (layar lama jadi cadangan); `Meta.award` menyimpan rincian di `Meta.lastAward`; `Meta.nextFight`/`finishRun` memakai VS dan layar hasil yang sama. Pemilih kutipan `VS.quote(id, 'intro'|'win'|'lose')` memakai pengacak kosmetik terpisah. API uji: `__game.vsActive()`, `__game.vsSkip()`. Menu depan yang tersembunyi dijamin hilang oleh `#front-menu[hidden]{display:none!important}` (menu.css); aturan `display` per `data-screen` tidak boleh menimpanya. Layar hasil memfokuskan wadah `.rs` (`tabindex=-1`), bukan tombol, agar Spasi/Enter dari pertarungan tidak langsung memicu MAIN LAGI (Tab tetap menjangkau tombol). `Meta.toast` menahan toast selama `VS.active` dan memunculkannya sesudah VS selesai. - HUD (game.js): meter ultimate `#ult-player`/`#ult-enemy` (`updateUltMeters`; mengisi/SIAP/waktu mode dari `modeOf`, `bwx.rage`, bentuk FENR; DOM hanya ditulis saat tampilan berubah). Menu jeda `#pause-panel` dua panel; `buildMoveList()` membangun ulang Daftar gerakan tiap `setPaused(true)`, pilihan lipat diingat di `localStorage['konoha.moveList']`. - `assets//` atlas sprite (15 state), `manifest.js` (data + metrik), `ui/`; `work/` (di repo induk, luar folder ini) berisi pipeline pembuat karakter. - `precache.js`/`files.json`: **dibuat otomatis** oleh `tools/build_precache.py` — jangan diedit atau di-commit oleh cabang fitur; perintahkan integrator membangun ulang. @@ -29,7 +29,7 @@ Game fighting 2D canvas, tanpa dependensi dan tanpa langkah build. Semua teks un `__game.hitDummy`, `__game.feel` dan `__game.feelState()` membuka tabel rasa kena, status K.O./angka untuk `tests/t1-feel.mjs`. `__game.fx.snapshot()` melaporkan keadaan efek (bayangan, genangan api, DoT, aba-aba, proyektil beserta gaya/umur) untuk skenario uji. Untuk uji deterministik set `__game.manual = true` dulu; kalau tidak, loop waktu nyata ikut memajukan simulasi saat screenshot. ## Alat uji headless -`node tests/harness.mjs smoke` — 7 karakter × easy/hard melawan CPU, gagal bila ada galat halaman. `node tests/harness.mjs tests/foo.mjs` — menjalankan skenario: `export default async ({ page, game, errors, characters }) => hasil` (`game(fn, arg)` = `page.evaluate`). Screenshot: `await page.screenshot({path})` lalu `Read` gambar. `tests/t1-fx.mjs` melepas semua jurus enam karakter berbasis data dari kedua sisi (env `FX_SHOTS` = folder screenshot, `FX_ONLY` = regex nama jurus). Node dan Playwright sudah terpasang; tidak perlu `npm install`. `tests/t2-hud.mjs` (env `HUD_ONLY=meter|enemy|cpu|pause|layout`) menguji meter ultimate dan menu jeda; `tests/t2-vsresult.mjs` (env `VSR_SHOTS`, `VSR_ONLY=quotes|vs|skip|abort|result|run|ui|phone`) menguji layar VS, layar hasil, dan kutipan. `tests/t2-brand.mjs` (env `BRAND_SHOTS`, `BRAND_ONLY=text|quote|log|menu|flow|docs`) menguji sisa nama game asal di seluruh layar, kutipan loading, Yang baru dan toast, serta latar kolase. +`node tests/harness.mjs smoke` — 7 karakter × easy/hard melawan CPU, gagal bila ada galat halaman. `node tests/harness.mjs tests/foo.mjs` — menjalankan skenario: `export default async ({ page, game, errors, characters }) => hasil` (`game(fn, arg)` = `page.evaluate`). Screenshot: `await page.screenshot({path})` lalu `Read` gambar. `tests/t1-fx.mjs` melepas semua jurus enam karakter berbasis data dari kedua sisi (env `FX_SHOTS` = folder screenshot, `FX_ONLY` = regex nama jurus). Node dan Playwright sudah terpasang; tidak perlu `npm install`. `tests/t2-hud.mjs` (env `HUD_ONLY=meter|enemy|cpu|pause|layout`) menguji meter ultimate dan menu jeda; `tests/t2-vsresult.mjs` (env `VSR_SHOTS`, `VSR_ONLY=quotes|vs|skip|abort|result|run|ui|focus|phone`) menguji layar VS, layar hasil, dan kutipan. `tests/t2-brand.mjs` (env `BRAND_SHOTS`, `BRAND_ONLY=text|quote|log|menu|flow|docs`) menguji sisa nama game asal di seluruh layar, kutipan loading, Yang baru dan toast, serta latar kolase. ## Aturan kerja - Jangan memakai `Math.random`/`Date.now` untuk logika yang memengaruhi hasil pertandingan bila ada pengacak `random()` bawaan (benih tetap). diff --git a/tests/t2-vsresult.mjs b/tests/t2-vsresult.mjs index cab5096..a794bad 100644 --- a/tests/t2-vsresult.mjs +++ b/tests/t2-vsresult.mjs @@ -1,6 +1,6 @@ // Tahap 2.2, 2.3, 2.6: layar VS, layar hasil, kutipan. // node tests/harness.mjs tests/t2-vsresult.mjs -// VSR_SHOTS=folder simpan screenshot (default: folder sementara sistem); VSR_ONLY=regex hanya bagian yang namanya cocok (quotes|vs|skip|abort|result|run|ui|phone) +// VSR_SHOTS=folder simpan screenshot (default: folder sementara sistem); VSR_ONLY=regex hanya bagian yang namanya cocok (quotes|vs|skip|abort|result|run|ui|focus|phone) // Jendela 1280x520 dipakai sengaja: tampilan harus muat di tinggi 520 px. import fs from 'node:fs'; import os from 'node:os'; @@ -24,6 +24,10 @@ export default async ({ page, game, errors, characters }) => { }, { side, rounds }); const startVS = (p, e, extra = {}) => game(({ p, e, extra }) => { const g = window.__game; g.manual = false; return g.startMatch({ player: p, enemy: e, stage: extra.stage || 'bellora', level: extra.level || 'medium', mode: extra.mode || 'versus', vs: extra.vs ?? true }); }, { p, e, extra }); const vsState = () => game(() => { const g = window.__game, el = document.querySelector('#vs-screen'); const s = g.snapshot(); return { el: !!el, out: el?.classList.contains('out') || false, active: g.vsActive(), phase: s.match?.phase, phaseTime: +(s.match?.phaseTime || 0).toFixed(3), cues: s.roundCues.map(c => c.cue + c.round), paused: s.paused, menuOpen: s.menuOpen, names: [...document.querySelectorAll('#vs-screen .vs-name')].map(n => n.textContent), quotes: [...document.querySelectorAll('#vs-screen .vs-quote')].map(n => n.textContent), roundCard: !document.querySelector('#round-call').hidden }; }); + // Menu depan benar-benar hilang (bukan hanya atribut hidden) dan tidak menutupi arena; toast yang tampil dan perpotongannya dengan bilah atas layar VS. + const menuGone = () => game(() => { const fm = document.querySelector('#front-menu'), hit = document.elementFromPoint(innerWidth / 2, innerHeight / 2); return { hidden: fm.hidden, display: getComputedStyle(fm).display, screen: fm.dataset.screen, coversArena: !!hit?.closest('#front-menu'), hit: hit?.id || hit?.className || hit?.tagName, vs: !!document.querySelector('#vs-screen') }; }); + const arenaClear = (name, m) => ok(name, m.hidden && m.display === 'none' && !m.coversArena && !m.vs, m); + const toasts = () => game(() => { const top = document.querySelector('#vs-screen .vs-top')?.getBoundingClientRect(); return [...document.querySelectorAll('.meta-toast')].map(t => { const r = t.getBoundingClientRect(); return { text: t.textContent, opacity: getComputedStyle(t).opacity, coversVsTop: !!top && r.bottom > top.top && r.top < top.bottom && r.right > top.left && r.left < top.right }; }); }); // ---------- 2.6 data kutipan ---------- if (want('quotes')) { @@ -185,6 +189,8 @@ export default async ({ page, game, errors, characters }) => { await wait(150); let s = await vsState(); const label = await game(() => document.querySelector('#vs-screen .vs-top')?.textContent || ''); ok('arcade: lawan 1 memakai layar VS berlabel', s.el && s.active && /ARCADE · LAWAN 1 \/ 6/.test(label), { s, label }); + const tArc = await toasts(); + ok('arcade: tidak ada toast di atas bilah layar VS (info lawan sudah ada di bilah)', tArc.length === 0, tArc); await wait(450); await shot('vs-arcade'); await game(() => window.__game.vsSkip()); await finish('player', 2).then(() => null).catch(() => null); await wait(100); @@ -202,7 +208,9 @@ export default async ({ page, game, errors, characters }) => { await page.click('[data-cmd=rematch]'); await wait(200); s = await vsState(); const lab2 = await game(() => document.querySelector('#vs-screen .vs-top')?.textContent || ''); ok('arcade: Main lagi memulai rangkaian baru (lawan 1)', s.el && /LAWAN 1 \/ 6/.test(lab2), { s, lab2 }); - await game(() => window.__game.vsSkip()); await finish('enemy'); await wait(200); + await game(() => window.__game.vsSkip()); await wait(350); + arenaClear('arcade: Main lagi, setelah VS layar hasil tidak lagi menutupi arena', await menuGone()); + await finish('enemy'); await wait(200); await page.click('[data-cmd=reselect]'); await wait(100); const sel = await game(() => ({ screen: document.querySelector('#front-menu').dataset.screen, step: document.querySelector('#front-menu').dataset.step, eyebrow: document.querySelector('.select-header .eyebrow')?.textContent })); ok('arcade: Pilih ulang kembali ke pilih karakter (mode ARCADE)', sel.screen === 'select' && sel.step === '0' && /ARCADE/.test(sel.eyebrow), sel); @@ -225,6 +233,7 @@ export default async ({ page, game, errors, characters }) => { // ---------- jalur menu: dua pemain, tombol hasil ---------- if (want('ui')) { await game(() => { window.Meta.save.ach = { first_win: 1, perfect: 1, hard_win: 1, combo5: 1, combo10: 1, wins10: 1, rich: 1 }; window.Meta.cancelRun(); window.__game.setMenuOpen(true); window.FrontEnd.home(); }); + await game(() => { delete window.Meta.save.ach.twoplayer; }); await page.click('[data-cmd="2p"]'); await wait(80); await page.click('[data-fighter=browo]'); await wait(1100); await page.click('[data-fighter=megachan]'); await wait(1100); @@ -232,7 +241,11 @@ export default async ({ page, game, errors, characters }) => { let s = await vsState(); const meta = await game(() => ({ who: [...document.querySelectorAll('#vs-screen .vs-who')].map(n => n.textContent), top: document.querySelector('#vs-screen .vs-top')?.textContent })); ok('dua pemain lewat menu: layar VS berlabel PEMAIN 1 / PEMAIN 2', s.active && meta.who.join() === 'PEMAIN 1,PEMAIN 2' && /DUA PEMAIN/.test(meta.top), { s, meta }); + const tDuring = await toasts(); + ok('dua pemain: toast petunjuk P2 dan pencapaian ditunda, tidak menutupi bilah VS', tDuring.length === 0, tDuring); await wait(2200); s = await vsState(); + const tAfter = await toasts(); + ok('dua pemain: petunjuk P2 dan pencapaian TEMAN BERMAIN muncul tepat setelah VS selesai', tAfter.some(t => /^P2:/.test(t.text)) && tAfter.some(t => /TEMAN BERMAIN/.test(t.text)) && tAfter.every(t => !t.coversVsTop), tAfter); ok('dua pemain: ronde mulai normal setelah VS', !s.active && s.cues.join() === 'round1', s); await finish('enemy'); await wait(150); const two = await game(() => ({ screen: document.querySelector('#front-menu').dataset.screen, kicker: document.querySelector('.rs-kicker')?.textContent, tone: document.querySelector('.rs')?.dataset.tone })); @@ -247,7 +260,9 @@ export default async ({ page, game, errors, characters }) => { await page.click('[data-cmd=rematch]'); await wait(200); s = await vsState(); const again = await game(() => ({ who: [...document.querySelectorAll('#vs-screen .vs-who')].map(n => n.textContent) })); ok('tombol MAIN LAGI memulai ulang (VS tampil, tetap dua pemain)', s.active && again.who.join() === 'PEMAIN 1,PEMAIN 2', { s, again }); - await game(() => window.__game.vsSkip()); await finish('player'); await wait(150); + await game(() => window.__game.vsSkip()); await wait(350); + arenaClear('MAIN LAGI (dua pemain): setelah VS layar hasil tidak lagi menutupi arena', await menuGone()); + await finish('player'); await wait(150); await page.click('[data-cmd=home]'); await wait(100); m = await game(() => ({ screen: document.querySelector('#front-menu').dataset.screen })); ok('tombol MENU UTAMA kembali ke menu utama', m.screen === 'home', m); @@ -259,6 +274,36 @@ export default async ({ page, game, errors, characters }) => { const cpu = await game(() => ({ who: [...document.querySelectorAll('#vs-screen .vs-who')].map(n => n.textContent), top: document.querySelector('#vs-screen .vs-top')?.textContent })); ok('versus CPU lewat menu: label KOMPUTER, arena, tingkat SULIT', cpu.who.join() === 'PEMAIN 1,KOMPUTER' && /VERSUS KOMPUTER/.test(cpu.top) && /SULIT/.test(cpu.top), cpu); await shot('vs-versus-menu'); await game(() => window.__game.vsSkip()); + await finish('player'); await wait(150); + await page.click('[data-cmd=rematch]'); await wait(200); await game(() => window.__game.vsSkip()); await wait(350); + arenaClear('MAIN LAGI (versus CPU): setelah VS layar hasil tidak lagi menutupi arena', await menuGone()); + } + + // ---------- fokus awal layar hasil: Spasi/Enter (tombol serang) tidak boleh langsung memulai ulang ---------- + if (want('focus')) { + await game(() => { window.Meta.cancelRun(); window.Meta.save.ach = { first_win: 1, perfect: 1, hard_win: 1, combo5: 1, combo10: 1, wins10: 1, rich: 1, arcade_clear: 1, survival5: 1 }; window.__game.manual = false; }); + const state = () => game(() => { const g = window.__game, a = document.activeElement; return { menuOpen: g.snapshot().menuOpen, screen: document.querySelector('#front-menu').dataset.screen, rs: !!document.querySelector('.rs'), vs: !!document.querySelector('#vs-screen'), activeCmd: a?.dataset?.cmd || null, activeTag: a?.tagName }; }); + const stays = (s) => s.menuOpen && s.screen === 'result' && s.rs && !s.vs; + await startVS('browo', 'megachan', { vs: false, level: 'easy' }); + await finish('player'); await wait(150); + let s = await state(); + ok('hasil: fokus awal bukan tombol aksi (bukan MAIN LAGI)', stays(s) && s.activeCmd === null, s); + await page.keyboard.press(' '); await page.keyboard.press('Enter'); await wait(450); s = await state(); + ok('hasil: Spasi lalu Enter tepat setelah layar muncul tidak memulai ulang', stays(s), s); + // tombol serang ditahan menembus K.O.: auto-repeat dan keyup tidak boleh mengaktifkan tombol apa pun + await page.keyboard.down(' '); await page.keyboard.down(' '); await page.keyboard.down(' '); await wait(60); await page.keyboard.up(' '); await wait(450); s = await state(); + ok('hasil: Spasi ditahan (auto-repeat) lalu dilepas tidak memulai ulang', stays(s), s); + await page.keyboard.press('Tab'); s = await state(); + ok('hasil: Tab menjangkau MAIN LAGI (papan ketik tetap bisa dipakai)', s.activeCmd === 'rematch', s); + await page.keyboard.press('Enter'); await wait(250); s = await state(); + ok('hasil: Tab lalu Enter (sengaja) memulai ulang dengan layar VS', !s.menuOpen && s.vs, s); + await game(() => window.__game.vsSkip()); + // akhir Arcade: Spasi tidak boleh memulai rangkaian baru + await game(() => { window.Meta.startRun('arcade', 'browo'); }); await wait(100); await game(() => window.__game.vsSkip()); + await finish('enemy'); await wait(200); + await page.keyboard.press(' '); await page.keyboard.press('Enter'); await wait(450); s = await state(); + ok('akhir Arcade: Spasi/Enter tidak memulai rangkaian baru', stays(s) && await game(() => document.querySelector('.rs-kicker')?.textContent.includes('KALAH DI LAWAN')), s); + await game(() => { window.Meta.cancelRun(); window.__game.setMenuOpen(true); window.FrontEnd.home(); }); } // ---------- ponsel lanskap ---------- @@ -270,6 +315,28 @@ export default async ({ page, game, errors, characters }) => { await finish('player'); await wait(2300); await shot('hasil-667x375'); const fit = await game(() => { const b = [...document.querySelectorAll('.rs-actions button')].map(x => x.getBoundingClientRect()); return { bottom: Math.max(...b.map(x => x.bottom)), h: innerHeight, scroll: document.querySelector('#front-menu').scrollHeight - document.querySelector('#front-menu').clientHeight }; }); ok('hasil di ponsel lanskap 667x375: tombol muat tanpa gulir', fit.bottom <= fit.h && fit.scroll <= 0, fit); + // Kolom Statistik tidak boleh meluap atau terpotong di layar pendek maupun potret (gerak dikurangi: tata letak langsung jadi, tanpa menunggu animasi). + await page.emulateMedia({ reducedMotion: 'reduce' }); + const stats = () => game(() => { const p = document.querySelector('.rs-stats'), pr = p.getBoundingClientRect(), bad = []; for (const n of p.querySelectorAll('dt,dd')) { const r = n.getBoundingClientRect(); if (r.right > pr.right + .5 || r.right > innerWidth + .5 || n.scrollWidth > n.clientWidth + 1) bad.push(`${n.textContent} r=${Math.round(r.right)} sw=${n.scrollWidth} cw=${n.clientWidth}`); } const ab = Math.max(...[...document.querySelectorAll('.rs-actions button')].map(x => x.getBoundingClientRect().bottom)); return { bad, panelRight: Math.round(pr.right), panelOverflow: p.scrollWidth - p.clientWidth, vw: innerWidth, actionsBottom: Math.round(ab), vh: innerHeight }; }); + const base = { winner: 'player', playerWins: 2, enemyWins: 0, player: 'sonofsolo', enemy: 'ethanolman', level: 'excellent', mode: 'versus', bestCombo: 14, matchBest: 14, award: { won: true, gain: 120, lines: [{ key: 'win', label: 'Menang', amount: 120 }] } }; + const variants = [ + ['versus elderwood', { stage: 'elderwood' }], ['versus sunspire', { stage: 'sunspire' }], + ['arcade kalah', { winner: 'enemy', playerWins: 0, enemyWins: 2, stage: 'elderwood', run: { kind: 'arcade', wins: 1, total: 6, cleared: false, best: 3 } }], + ['survival kalah', { winner: 'enemy', playerWins: 0, enemyWins: 2, stage: 'sunspire', run: { kind: 'survival', wins: 12, total: 0, cleared: false, best: 14 } }] + ]; + const bads = [], lows = []; + for (const [w, h] of [[568, 320], [667, 375], [740, 360], [812, 375], [844, 390], [896, 414], [1024, 520], [768, 1024]]) { + await page.setViewportSize({ width: w, height: h }); + for (const [name, extra] of variants) { + await game(d => { window.__game.setMenuOpen(true); window.FrontEnd.showResult(d); }, { ...base, ...extra }); + await wait(120); + const st = await stats(); if (st.bad.length || st.panelOverflow > 0) bads.push({ viewport: `${w}x${h}`, variant: name, ...st }); if (st.actionsBottom > st.vh) lows.push(`${w}x${h} ${name} bottom=${st.actionsBottom}`); + if (w === 667 && name === 'arcade kalah') await shot('hasil-arcade-667x375'); + } + } + ok('hasil: kolom Statistik tidak meluap atau terpotong di 568x320 sampai 1024x520 dan potret 768x1024 (versus, Arcade, Survival)', bads.length === 0, bads.slice(0, 4)); + ok('hasil: tombol aksi tetap muat di semua ukuran uji (Statistik tidak menambah tinggi berlebih)', lows.length === 0, lows); + await page.emulateMedia({ reducedMotion: 'no-preference' }); await page.setViewportSize({ width: 1280, height: 520 }); }