Files
leggenda-del-regolo/tests/browser.mjs
T

208 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Prova nel browser vero (Chromium headless con swiftshader): titolo, partita giocata con clic veri
// fino alla fine, salvataggio e impaginazione su telefono. Schermate in $OUT (predefinito: /tmp/regolo-shots).
// npm run browser
import { chromium } from 'playwright';
import { spawn } from 'node:child_process';
import { mkdirSync } from 'node:fs';
import assert from 'node:assert/strict';
import { solution } from '../src/story/solution.js';
const OUT = process.env.OUT ?? '/tmp/regolo-shots', PORT = 8000 + Math.floor(Math.random() * 900);
mkdirSync(OUT, { recursive: true });
const server = spawn('python3', ['-m', 'http.server', String(PORT), '--bind', '127.0.0.1'], { stdio: 'ignore' });
const browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
const errors = [];
async function open(viewport) {
const page = await browser.newPage({ viewport });
page.on('pageerror', e => errors.push(e.message));
page.on('console', m => m.type() === 'error' && errors.push(m.text()));
for (let i = 0; i < 50; i++) {
try { await page.goto(`http://127.0.0.1:${PORT}/`); break; } catch { await page.waitForTimeout(100); }
}
return page;
}
// Clic su un punto della scena (coordinate 640×360).
async function clickScene(page, [x, y]) {
const r = await page.locator('#scene').boundingBox();
await page.mouse.click(r.x + x / 640 * r.width, r.y + y / 360 * r.height);
}
// Aspetta che il gioco sia fermo, saltando le battute con la barra spaziatrice.
async function settle(page) {
for (let i = 0; i < 400; i++) {
const s = await page.evaluate(() => ({ speech: !!game.speech, idle: !game.busy && !game.walking && !game.queue.length && !game.task }));
if (s.speech) await page.keyboard.press('Space');
else if (s.idle) return;
await page.waitForTimeout(50);
}
throw new Error('il gioco non si ferma');
}
const hotspotCenter = (page, id) => page.evaluate(id => { const h = game.hotspot(id); return [h.box[0] + h.box[2] / 2, h.box[1] + h.box[3] / 2]; }, id);
try {
const page = await open({ width: 1280, height: 860 });
// Il cartello d'apertura, poi da solo il titolo.
assert.match(await page.textContent('#splash'), /Manzolo\s*\(Poca\)\s*Art\s*Presents/);
assert.ok(await page.isHidden('#title'), 'il titolo aspetta la fine del cartello');
assert.match(await page.evaluate(() => jingle.src), /assets\/audio\/cartello\.mp3$/, 'la musichetta del cartello');
assert.equal(await page.evaluate(() => music.want), null, 'la musica del titolo aspetta il titolo');
await page.waitForTimeout(2800);
await page.screenshot({ path: `${OUT}/00-cartello.png` });
// Il titolo arriva sotto il cartello mentre sfuma: in nessun momento si vede la scena nuda.
await page.waitForSelector('#title:not([hidden])', { timeout: 8000 });
assert.ok(await page.isVisible('#splash'), 'il titolo compare quando il cartello è ancora sopra');
await page.waitForTimeout(350);
await page.screenshot({ path: `${OUT}/00b-dissolvenza.png` });
await page.waitForSelector('#splash', { state: 'hidden', timeout: 3000 });
await page.screenshot({ path: `${OUT}/01-titolo.png` });
// I crediti dal titolo: si aprono, mostrano i nomi, Esc li chiude.
await page.click('#title .open-credits');
assert.match(await page.textContent('#credits'), /Crediti\s*Al Lele/, 'la dedica al Lele viene per prima');
assert.match(await page.textContent('#credits'), /Yuri Baldi/);
assert.match(await page.textContent('#credits'), /Francesco Bacci/);
await page.screenshot({ path: `${OUT}/01c-crediti.png` });
// L'elenco può scorrere; la dedica e il pulsante Chiudi devono restare dentro lo schermo.
const fitsCredits = await page.evaluate(() => { const c = document.getElementById('credits').getBoundingClientRect(), b = document.getElementById('close-credits').getBoundingClientRect(); return b.bottom <= c.bottom && b.top >= c.top; });
assert.ok(fitsCredits, 'il pulsante Chiudi dei crediti è in vista');
await page.keyboard.press('Escape');
assert.ok(await page.isHidden('#credits'), 'Esc chiude i crediti');
await page.click('#new-game');
// L'introduzione: una riga per clic, poi comincia la partita.
await page.waitForSelector('#intro:not([hidden])');
await page.screenshot({ path: `${OUT}/01b-intro.png` });
for (let i = 0; i < 10 && await page.isVisible('#intro'); i++) await page.click('#intro', { position: { x: 40, y: 40 } });
assert.ok(await page.isHidden('#intro'), 'l\'introduzione non finisce');
await page.waitForFunction(() => game.speech);
// La prima battuta ha la sua voce, e sotto la voce la musica si abbassa.
await page.waitForFunction(() => voices.playing, null, { timeout: 5000 });
assert.match(await page.evaluate(() => voices.audio.src), /assets\/voices\/andrea-[0-9a-f]+\.mp3$/);
await page.waitForFunction(() => music.duck === 0.45, null, { timeout: 2000 });
await page.screenshot({ path: `${OUT}/02-inizio.png` });
const fits = async p => p.evaluate(() => document.querySelector('#panel').getBoundingClientRect().bottom <= innerHeight);
assert.ok(await fits(page), 'verbi e inventario fuori dallo schermo');
await settle(page);
// Doppio clic su un punto della scena: Andrea ci va di corsa.
const r = await page.locator('#scene').boundingBox();
await page.mouse.dblclick(r.x + 300 / 640 * r.width, r.y + 330 / 360 * r.height);
assert.ok(await page.evaluate(() => game.hurry), 'il doppio clic fa correre');
await settle(page);
// «?» mostra per qualche secondo cosa si può toccare.
await page.click('#spots-button');
await page.screenshot({ path: `${OUT}/02c-punti.png` });
// Pausa: Esc o il pulsante fermano il gioco; Riprendi torna dov'eri; il titolo offre «Continua».
await page.keyboard.press('Escape');
await page.waitForSelector('#pause:not([hidden])');
await page.screenshot({ path: `${OUT}/02b-pausa.png` });
await page.click('#resume');
assert.ok(await page.isHidden('#pause'));
await page.click('#pause-button');
await page.click('#to-title');
await page.waitForSelector('#continue:not([hidden])');
await page.click('#continue');
await settle(page);
// Musica: dopo il primo clic suona il brano del paese; muto e volume si ricordano.
// Il brano cambia al fotogramma dopo «Continua»: si aspetta quello, senza indovinare i tempi.
const playing = await page.waitForFunction(() => music.current?.src.endsWith('paese.mp3'), null, { timeout: 3000 }).then(() => true, () => false);
assert.ok(playing, 'musica del paese: ' + await page.evaluate(() => music.current?.src));
await page.click('#mute');
await page.locator('#volume').fill('30');
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-audio'))), { volume: 0.3, muted: false });
for (const [verb, target, item] of solution) {
if (verb === 'choose') {
await page.locator('#choices li', { hasText: target }).click();
} else {
const smart = await page.evaluate(([v, t]) => game.defaultVerb(t) === v, [verb, target]);
if (item) {
await page.click(`#verbs [data-verb="${verb}"]`);
await page.click(`#inventory [data-item="${item}"]`);
} else if (!smart) await page.click(`#verbs [data-verb="${verb}"]`);
await clickScene(page, await hotspotCenter(page, target));
}
await page.waitForTimeout(80);
if (verb === 'talk') await page.screenshot({ path: `${OUT}/03-dialogo.png` });
await settle(page);
if (target === 'cosa_luccicante') await page.screenshot({ path: `${OUT}/04-squama.png` });
if (target === 'su_fortezza') await page.screenshot({ path: `${OUT}/05-fortezza.png` });
}
await page.waitForSelector('#ending:not([hidden])');
await page.screenshot({ path: `${OUT}/06-fine.png` });
await page.click('#ending .open-credits');
assert.ok(await page.isVisible('#credits'), 'crediti anche dalla fine');
await page.click('#close-credits');
assert.ok(await page.isHidden('#credits'));
const saved = await page.evaluate(() => JSON.parse(localStorage.getItem('leggenda-regolo-save')));
assert.ok(saved.ended && saved.inventory.includes('squama'), 'salvataggio finale');
// Una partita a metà riprende da dove era rimasta.
await page.click('#restart');
await settle(page);
for (const exit of ['verso_villa_adami', 'su_villa_adami', 'salita', 'su_fortezza', 'sentiero_trebbio', 'al_trebbio']) {
await clickScene(page, await hotspotCenter(page, exit));
await settle(page);
}
await page.click('#verbs [data-verb="talk"]');
await clickScene(page, await hotspotCenter(page, 'torre'));
await page.locator('#choices li', { hasText: 'bloccato' }).click();
await page.waitForFunction(() => game.speech?.who === 'castello');
await page.waitForTimeout(100);
await page.screenshot({ path: `${OUT}/07-trebbio.png` });
await settle(page);
await page.locator('#choices li', { hasText: 'pace' }).click();
await settle(page);
await page.reload();
await page.waitForSelector('#continue:not([hidden])');
await page.click('#continue');
assert.equal(await page.evaluate(() => game.state.scene), 'trebbio');
// La mappa: dal Trebbio alla Piazza con due clic; i posti mai visti (la Pieve) non ci sono.
await settle(page);
await page.click('#map-button');
assert.equal(await page.locator('#map-board button[data-scene="pieve"]').count(), 0, 'la Pieve non è ancora vista');
assert.ok(await page.isVisible('#map-board button.here[data-scene="trebbio"]'));
await page.screenshot({ path: `${OUT}/07b-mappa.png` });
await page.click('#map-board button[data-scene="piazza"]');
assert.ok(await page.isHidden('#map'));
assert.equal(await page.evaluate(() => game.state.scene), 'piazza');
const phone = await open({ width: 390, height: 844 });
await phone.click('#splash'); // un clic salta il cartello
assert.ok(await phone.isVisible('#title'), 'il clic sul cartello porta al titolo');
await phone.click('#new-game');
await phone.click('#skip-intro');
await settle(phone);
const overflow = await phone.evaluate(() => document.documentElement.scrollWidth > innerWidth);
// Ogni verbo si vede tutto: niente pulsanti tagliati dall'inventario.
assert.ok(await phone.evaluate(() => [...document.querySelectorAll('#verbs button')].every(b => b.scrollWidth <= b.clientWidth + 1 && b.getBoundingClientRect().right <= innerWidth)), 'verbi tagliati sul telefono');
assert.equal(overflow, false, 'scorrimento orizzontale su telefono');
await phone.screenshot({ path: `${OUT}/08-telefono.png` });
// Telefono stretto in orizzontale: niente deve allargare la pagina oltre lo schermo (il browser la rimpicciolirebbe
// e taglierebbe verbi e inventario a destra), e la pausa sta tutta dentro la scena.
const small = await browser.newPage({ viewport: { width: 740, height: 330 }, deviceScaleFactor: 2.4, hasTouch: true, isMobile: true });
small.on('pageerror', e => errors.push(e.message));
await small.goto(`http://127.0.0.1:${PORT}/`);
await small.waitForSelector('#splash', { state: 'hidden', timeout: 15000 });
await small.click('#new-game');
await small.click('#skip-intro');
await settle(small);
assert.equal(await small.evaluate(() => innerWidth), 740, 'la pagina è più larga dello schermo del telefono');
await small.click('#pause-button');
assert.ok(await small.evaluate(() => document.getElementById('to-title').getBoundingClientRect().bottom <= document.getElementById('stage').getBoundingClientRect().bottom), 'la pausa esce dalla scena');
await small.screenshot({ path: `${OUT}/08b-telefono-stretto-pausa.png` });
const landscape = await open({ width: 844, height: 390 });
await landscape.click('#new-game');
await landscape.click('#skip-intro');
await settle(landscape);
assert.equal(await landscape.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
await landscape.screenshot({ path: `${OUT}/09-orizzontale.png`, fullPage: true });
assert.deepEqual(errors, [], 'errori nella console');
console.log(`Browser OK: soluzione completa, ripresa del salvataggio, telefono. Schermate in ${OUT}`);
} finally {
await browser.close();
server.kill();
}