Tambah uji regresi tests/t1-review.mjs untuk perbaikan temuan review Tahap 1

This commit is contained in:
rizkyaz0 2026-10-03 07:02:29 +07:00
parent 80dfd90ee5
commit 0b2ccbb090
1 changed files with 134 additions and 0 deletions

134
tests/t1-review.mjs Normal file
View File

@ -0,0 +1,134 @@
// Tahap 1, perbaikan temuan review: regresi untuk lima perbaikan di game.js.
// node tests/harness.mjs tests/t1-review.mjs
// REVIEW_ONLY=regex hanya jalankan bagian yang namanya cocok (split|dot|cutin|puddle|feel)
// Jendela 1280x520 dipakai sengaja: tampilan harus muat di tinggi 520 px.
export const options = { viewport: { width: 1280, height: 520 } };
export default async ({ page, game, url, errors }) => {
const checks = [], ok = (name, pass, info) => checks.push({ name, pass: !!pass, info });
const only = process.env.REVIEW_ONLY ? new RegExp(process.env.REVIEW_ONLY) : null, want = n => !only || only.test(n);
// ---------- 1. gelombang membelah tidak memukul dua kali ----------
if (want('split')) {
const side = (kind, D) => game(({ kind, D }) => {
const g = window.__game; g.startMatch({ player: kind === 'player' ? 'anice' : 'browo', enemy: kind === 'player' ? 'browo' : 'anice', stage: 'bellora', level: 'easy', mode: 'training' });
g.setAI(false); if (kind === 'enemy') g.setP2(true); g.manual = true; g.advance(1.5);
const mode = kind === 'player' ? 'p' : '.', fire = kind === 'player' ? 'o' : ',';
g.press(mode); g.advance(1 / 60); g.release(mode); g.advance(7.6);
if (kind === 'player') { g.hero.x = 350; g.dummy.x = 350 + D; } else { g.dummy.x = 930; g.hero.x = 930 - D; }
const before = kind === 'player' ? g.dummy.damage : g.hero.hp;
g.press(fire); g.advance(1 / 60); g.release(fire);
let hits = 0, last = before;
for (let i = 0; i < 120; i++) { g.advance(1 / 60); const now = kind === 'player' ? g.dummy.damage : g.hero.hp, d = Math.abs(now - last); if (d > 0) hits++; last = now; }
return { total: Math.abs(last - before), hits };
}, { kind, D });
for (const kind of ['player', 'enemy']) {
const base = await side(kind, 160);
ok(`split ${kind}: jarak 160 memukul sekali`, base.total > 0 && base.hits === 1, base);
for (const D of kind === 'player' ? [230, 260, 290, 400] : [280, 400]) {
const r = await side(kind, D);
ok(`split ${kind}: jarak ${D} tidak memukul dua kali`, r.total === base.total && r.hits === 1, { r, base });
}
}
}
// ---------- 2. DoT tidak menaikkan hitungan kombo ----------
if (want('dot')) {
// maks = jumlah pukulan nyata/ledakan terjadwal yang sah pada jurus itu (lob Son of Solo: dampak + ledakan = 2)
for (const [ch, key, name, maks] of [['megachan', 'o', 'jejak api', 1], ['ethanolman', 'i', 'jejak mode', 1], ['sonofsolo', 'o', 'genangan lob', 2], ['sonofsolo', 'i', 'korosi', 1]]) {
const r = await game(({ ch, key }) => {
const g = window.__game; g.startMatch({ player: ch, enemy: ch === 'megachan' ? 'anice' : 'browo', stage: 'bellora', level: 'easy', mode: 'training' });
g.setAI(false); g.manual = true; g.advance(1.5); g.press('p'); g.advance(1 / 60); g.release('p'); g.advance(7.6);
g.hero.x = 350; g.dummy.x = 550; g.press(key); g.advance(1 / 60); g.release(key);
let maxHits = 0, rank = '', dmgFromDot = 0;
for (let i = 0; i < 4; i++) { g.advance(.75); maxHits = Math.max(maxHits, g.snapshot().comboHits); g.draw(); const el = document.getElementById('combo-rank'); if (el && el.classList.contains('visible')) rank = el.dataset.rank; }
dmgFromDot = Math.round(g.dummy.damage);
g.dummy.state = 'idle'; g.dummy.action = null; const b = g.dummy.damage; g.hitDummy(20, 100, '#fff', g.hero.x, g.hero.y - 90, false, true, 1);
const s = g.snapshot();
return { maxHits, rank, dmgFromDot, real: Math.round(g.dummy.damage - b), hitsAfter: s.comboHits, best: s.bestCombo };
}, { ch, key });
ok(`dot ${ch} '${key}' (${name}): tick tidak menaikkan kombo`, r.maxHits <= maks && !r.rank, r);
ok(`dot ${ch} '${key}' (${name}): DoT tetap memberi damage`, r.dmgFromDot > 5, r);
ok(`dot ${ch} '${key}' (${name}): pukulan nyata berikutnya penuh (20)`, r.real === 20 && r.hitsAfter <= maks + 1, r);
}
// pukulan nyata tetap menaikkan kombo
const real = await game(() => { const g = window.__game; g.startMatch({ player: 'megachan', enemy: 'anice', stage: 'bellora', level: 'easy', mode: 'training' }); g.setAI(false); g.manual = true; g.advance(1.5); for (let i = 0; i < 3; i++) { g.dummy.state = 'idle'; g.dummy.action = null; g.hitDummy(6, 60, '#fff', g.hero.x, g.hero.y - 90, false, true, 1); } return g.snapshot().comboHits; });
ok('dot: tiga pukulan nyata = 3 kombo', real === 3, real);
}
// ---------- 3. banner cut-in ultimate ----------
if (want('cutin')) {
const read = async (player, enemy, p2) => {
await game(({ player, enemy, p2 }) => {
const g = window.__game; g.startMatch({ player, enemy, stage: 'bellora', level: 'easy', mode: 'training' }); g.setAI(false); if (p2) g.setP2(true); g.manual = true; g.advance(1.5);
const key = p2 ? '.' : 'p'; if (!p2) g.hero.cooldowns.ultimate = 0; g.press(key); g.advance(1 / 60); g.release(key); g.advance(.04); g.draw();
}, { player, enemy, p2 });
return game(() => { const c = document.getElementById('ultimate-cutin'); return { visible: c.classList.contains('visible'), title: c.querySelector('strong').innerText, kicker: c.querySelector('.cutin-kicker').textContent, detail: c.querySelector('.cutin-detail').textContent.trim(), aria: c.getAttribute('aria-label') }; });
};
for (const ch of ['megachan', 'sonofsolo', 'ethanolman', 'anice', 'soloman', 'gufron']) {
const r = await read(ch, 'browo', false);
ok(`cutin ${ch}: judul/kicker/detail terisi dari manifest`, r.visible && r.title && !/undefined/.test(r.title + r.kicker + r.detail + r.aria) && r.kicker.includes('PLAYER') && r.detail, r);
}
const bw = await read('browo', 'megachan', false);
ok('cutin browo: tetap HUTAN SAWIT', /HUTAN/.test(bw.title) && bw.kicker === 'BRO WO / PLAYER' && bw.detail === 'TEN-PALM RUN + GIANT SLAM', bw);
const rv = await read('browo', 'megachan', true);
ok('cutin sisi lawan: Mega Chan / RIVAL', rv.visible && /RIVAL/.test(rv.kicker) && !/undefined/.test(rv.title + rv.aria) && rv.title, rv);
}
// ---------- 4. genangan asam berada di lantai ----------
if (want('puddle')) {
const draws = await game(() => {
const g = window.__game, proto = CanvasRenderingContext2D.prototype, orig = proto.drawImage, log = [];
proto.drawImage = function (im, ...a) { const s = im && im.src || ''; if (/fx-(big)?puddle/.test(s) && a.length === 4) log.push({ src: s.split('/').pop(), y: a[1], h: a[3] }); return orig.call(this, im, ...a); };
const out = {};
try {
for (const [name, mode, key] of [['lob skill1', false, 'i'], ['mode skill2 genangan luas', true, 'o']]) {
g.startMatch({ player: 'sonofsolo', enemy: 'browo', stage: 'bellora', level: 'easy', mode: 'training' }); g.setAI(false); g.manual = true; g.advance(1.5);
if (mode) { g.press('p'); g.advance(1 / 60); g.release('p'); g.advance(7.6); }
g.hero.x = 350; g.dummy.x = 800; g.press(key); g.advance(1 / 60); g.release(key); g.advance(1.4); log.length = 0; g.draw();
out[name] = { ground: g.dummy.y, draws: log.slice() };
}
} finally { proto.drawImage = orig; }
return out;
});
for (const [name, d] of Object.entries(draws)) {
const c = d.draws.at(-1);
ok(`puddle ${name}: tergambar`, !!c, d);
if (c) ok(`puddle ${name}: pusat tampak genangan dalam 20 px dari lantai`, Math.abs(c.y + c.h / 2 - d.ground) <= 20, { center: c.y + c.h / 2, ground: d.ground });
}
}
// ---------- 5. boot tanpa feel.js (cold start offline) ----------
if (want('feel')) {
const p2 = await page.context().newPage(), errs = [];
p2.on('pageerror', e => errs.push(e.message));
await p2.route('**/feel.js', r => r.abort());
await p2.goto(url + '?touch=0');
let ready = true;
try { await p2.waitForFunction(() => window.__game && window.__game.snapshot().ready, null, { timeout: 20000 }); } catch (_) { ready = false; }
ok('feel hilang: game tetap siap tanpa galat', ready && !errs.length, { ready, errs });
if (ready) {
const r = await p2.evaluate(() => {
const g = window.__game; g.startMatch({ player: 'megachan', enemy: 'sonofsolo', stage: 'bellora', level: 'easy', mode: 'versus' }); g.setAI(true);
for (let i = 0; i < 80; i++) { g.advance(.25); if (i % 5 === 0) { g.press('d'); g.press(' '); g.release(' '); g.release('d'); } if (i % 9 === 0) { g.press('i'); g.release('i'); } if (i % 13 === 0) { g.press('p'); g.release('p'); } }
const s = g.snapshot(); return { stepCount: s.stepCount, phase: s.match?.phase, feel: typeof window.Feel, hp: Math.round(g.hero.hp), dmg: Math.round(g.dummy.damage) };
});
ok('feel hilang: pertandingan berjalan tanpa galat', r.stepCount > 1000 && !errs.length, { r, errs });
// K.O. tanpa Feel: pukulan penentu ke lawan, lalu ke pemain (versus berakhir/ronde berganti tanpa galat)
const ko = await p2.evaluate(() => {
const g = window.__game; g.startMatch({ player: 'megachan', enemy: 'sonofsolo', stage: 'bellora', level: 'easy', mode: 'versus' }); g.setAI(false); g.advance(6);
g.dummy.state = 'idle'; g.dummy.damage = g.dummy.damage + 190; g.hitDummy(30, 120, '#fff', g.hero.x, g.hero.y - 90, false, true, 1); g.advance(5);
const r1 = { round: g.snapshot().match?.round, phase: g.snapshot().match?.phase };
g.advance(6); g.hero.hp = 1; g.receiveHit(40); g.advance(6);
return { r1, hero: Math.round(g.hero.hp), phase: g.snapshot().match?.phase, round: g.snapshot().match?.round };
});
ok('feel hilang: K.O. dan pergantian ronde tanpa galat', !errs.length && ko.r1.round >= 1, { ko, errs });
}
await p2.close();
}
const failed = checks.filter(c => !c.pass);
const out = { total: checks.length, failed: failed.length, failedChecks: failed, passed: checks.filter(c => c.pass).length + ' lulus' };
if (failed.length) throw new Error('GAGAL ' + JSON.stringify(out, null, 1));
return out;
};