From 24e9b8c0559cacbdd223283afb7c602aba9cb4c1 Mon Sep 17 00:00:00 2001 From: Andrea Manzi Date: Sun, 1 Nov 2026 12:35:40 +0100 Subject: [PATCH] La prova nel browser in tre processi paralleli (principale, minigiochi, telefoni), ognuno col suo server e il suo Chromium: da 525 a circa 275 secondi, tre giri su tre verdi; --parte= per una sola. Le attese della torcia hanno una scadenza di 30 s invece di 200 giri, che con la macchina carica non bastavano (Andrea) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T --- Makefile | 2 +- README.md | 4 +- tests/browser.mjs | 525 ++++++++++++++++++----------------- tests/multimedia.browser.mjs | 2 +- 4 files changed, 279 insertions(+), 254 deletions(-) diff --git a/Makefile b/Makefile index 9186f59..a5d107a 100644 --- a/Makefile +++ b/Makefile @@ -47,7 +47,7 @@ test: ## Test della logica, della storia, della soluzione completa e del generat $(NODE) --test tests/*.test.mjs $(PYTHON) -m unittest discover -s tests -p "test_*.py" -q -browser: ## Partita completa nel browser con Playwright +browser: ## Partita completa nel browser con Playwright (tre parti in processi paralleli, ~4,5 min) $(NODE) tests/browser.mjs verify: test browser check-public ## Tutti i controlli diff --git a/README.md b/README.md index 1772da6..6745a37 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,8 @@ Oppure `make up` (Docker, su http://localhost:8080). Non aprire `index.html` con - `assets/`: sfondi, sprite, musiche, voci, carattere, video. - `tools/`: gli strumenti (sprite dai fogli, voci con XTTS, musiche sintetizzate, favicon, taratura dei minigiochi). - `tests/`: `npm test` (unità: regole, storia coerente, voci, salvataggi) e `npm run browser` (Playwright: percorre - tutta la soluzione dell'avventura, su desktop e telefono); `npm run browser:briscola`, `:tressette`, `:scopa`, - `:biliardo`, `:effects` per le parti singole. + tutta la soluzione dell'avventura, su desktop e telefono, in tre processi paralleli; `--parte=` per una sola); + `npm run browser:briscola`, `:tressette`, `:scopa`, `:biliardo`, `:effects` per le parti singole. - `video/`: i copioni dei video «Come si gioca» e della demo, girati in una macchina virtuale. - `docs/`: `PROGETTO.md` (l'idea, i personaggi, la mappa) e `STATO.md` (dove siamo, cosa manca). diff --git a/tests/browser.mjs b/tests/browser.mjs index 1694187..2b33d3b 100644 --- a/tests/browser.mjs +++ b/tests/browser.mjs @@ -1,6 +1,8 @@ // 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 +// npm run browser: le tre parti (--parte=principale, minigiochi, telefoni) girano insieme come processi separati, ognuna +// col suo server e il suo Chromium; la prova passa quasi tutto il tempo ad aspettare animazioni in tempo reale, e così le +// attese si sovrappongono. Una parte sola: node tests/browser.mjs --parte=minigiochi (--media-only: solo multimedia). import { chromium } from 'playwright'; import { staticServer } from './helpers.mjs'; import { mkdirSync, readFileSync } from 'node:fs'; @@ -12,6 +14,22 @@ import { checkMultimedia } from './multimedia.browser.mjs'; const OUT = process.env.OUT ?? '/tmp/regolo-shots'; mkdirSync(OUT, { recursive: true }); +const PARTI = ['principale', 'minigiochi', 'telefoni']; +const PARTE = process.argv.find(a => a.startsWith('--parte='))?.slice('--parte='.length); +if (PARTE && !PARTI.includes(PARTE)) { console.error(`parte sconosciuta: ${PARTE} (${PARTI.join(', ')})`); process.exit(2); } +if (!PARTE && !process.argv.includes('--media-only')) { + const { spawn } = await import('node:child_process'); + const t0 = Date.now(); + const esiti = await Promise.all(PARTI.map(parte => new Promise(resolve => { + const p = spawn(process.execPath, [process.argv[1], `--parte=${parte}`], { stdio: ['ignore', 'pipe', 'pipe'] }); + let resto = ''; + const riga = chunk => { const righe = (resto + chunk).split('\n'); resto = righe.pop(); for (const r of righe) console.log(`[${parte}] ${r}`); }; + p.stdout.on('data', riga); p.stderr.on('data', riga); + p.on('close', code => { if (resto) console.log(`[${parte}] ${resto}`); resolve({ parte, code, s: Math.round((Date.now() - t0) / 1000) }); }); + }))); + for (const e of esiti) console.log(`${e.code === 0 ? 'OK ' : 'FAIL'} ${e.parte} (${e.s} s)`); + process.exit(esiti.every(e => e.code === 0) ? 0 : 1); +} const server = await staticServer(), PORT = server.port; const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); const errors = []; @@ -49,9 +67,261 @@ const hotspotCenter = async (page, id) => { return c; }; +// Le parti che aprono una pagina propria (col suo salvataggio), ognuna nel suo processo (vedi in cima). +async function minigiochi() { + // Il libro dell'oratorio: preso, il primo piano; si sfoglia coi clic veri, «FINE» senza che nessuno lo dica, + // Esc chiude; dall'inventario «Apri» lo riapre e il pulsante Chiudi lo chiude. Intanto la scena dietro non risponde. + { + const book = await open({ width: 1280, height: 860 }); + await book.click('#splash'); + await book.click('#new-game'); + await book.click('#rione [data-rione="campomigliaio"]'); + await book.click('#skip-intro'); + await settle(book); + await book.evaluate(() => game.go('oratorio', [540, 300])); + await settle(book); + await book.click('#verbs [data-verb="take"]'); + await clickScene(book, await hotspotCenter(book, 'libro_dimenticato')); + await book.waitForFunction(() => game.closeup?.step === 'cover', null, { timeout: 15000 }); + await book.waitForTimeout(600); + await book.screenshot({ path: `${OUT}/03a-libro-copertina.png` }); + const where = await book.evaluate(() => [game.state.x, game.state.y]); + await clickScene(book, [320, 200]); + assert.equal(await book.evaluate(() => game.closeup.step), 'title', 'un clic apre il libro'); + await clickScene(book, [320, 200]); + assert.equal(await book.evaluate(() => game.closeup.step), 'turn', 'un altro gira la pagina'); + await book.waitForFunction(() => game.closeup?.step === 'end'); + assert.equal(await book.evaluate(() => game.speech), null, '«FINE» non lo dice nessuno'); + // Prima la battuta, poi la schermata: con swiftshader una schermata può durare più della battuta. + await book.waitForFunction(() => game.speech?.text === 'Mi ricordavo un finale diverso.', null, { timeout: 5000 }); + await book.screenshot({ path: `${OUT}/03b-libro-fine.png` }); + assert.deepEqual(await book.evaluate(() => [game.state.x, game.state.y]), where, 'i clic sul libro non muovono Andrea'); + await book.keyboard.press('Space'); + await book.keyboard.press('Escape'); + assert.equal(await book.evaluate(() => game.closeup), null, 'Esc chiude il libro'); + await book.waitForSelector('#closeup-close', { state: 'hidden' }); + await settle(book); + await book.click('#verbs [data-verb="open"]'); + await book.click('#inventory button'); + await book.waitForFunction(() => game.closeup?.step === 'cover'); + assert.ok(await book.isVisible('#closeup-close'), 'si vede il pulsante Chiudi'); + await book.click('#closeup-close'); + assert.equal(await book.evaluate(() => game.closeup), null, 'Chiudi chiude il libro'); + await book.close(); + } + + // Le bocce al pallaio: col mouse tenuto premuto la forza sale, lasciando la boccia parte; la scena dietro è ferma. + { + const bocce = await open({ width: 1280, height: 860 }); + await bocce.click('#splash'); + await bocce.click('#new-game'); + await bocce.click('#rione [data-rione="campomigliaio"]'); + await bocce.click('#skip-intro'); + await settle(bocce); + await bocce.evaluate(() => game.go('tennis_chiesa', [430, 310])); + await settle(bocce); + await clickScene(bocce, await hotspotCenter(bocce, 'mariano')); + await bocce.waitForFunction(() => game.dialogue && !game.busy, null, { timeout: 20000 }); + await bocce.locator('#choices li', { hasText: 'Facciamo una partita' }).click(); + await bocce.waitForFunction(() => game.options().some(o => o.text === 'Seria.'), null, { timeout: 30000 }); + await bocce.locator('#choices li', { hasText: 'Seria.' }).click(); + await bocce.waitForFunction(() => game.closeup?.id === 'bocce' && game.closeup.step === 'aim' && !game.speech, null, { timeout: 30000 }); + const r = await bocce.locator('#scene').boundingBox(); + await bocce.mouse.move(r.x + r.width / 2, r.y + r.height / 2); + await bocce.mouse.down(); + await bocce.waitForTimeout(500); + assert.ok(await bocce.evaluate(() => game.closeup.power > 0.2), 'tenendo premuto la forza sale'); + await bocce.screenshot({ path: `${OUT}/03c-bocce-forza.png` }); + await bocce.mouse.up(); + assert.equal(await bocce.evaluate(() => game.closeup.balls.length), 1, 'lasciando, la boccia parte'); + await bocce.waitForFunction(() => game.closeup.balls.length === 2, null, { timeout: 15000 }); + // Il clic fissa la mira: andando verso la forza o «Lancia» il puntatore attraversa il campo e non la sposta. + await bocce.waitForFunction(() => game.closeup.step === 'aim' && game.closeup.current === 'player', null, { timeout: 30000 }); + await bocce.mouse.click(r.x + r.width * 0.45, r.y + r.height * 0.6); + const fixed = await bocce.evaluate(() => JSON.stringify(game.closeup.aim)); + await bocce.mouse.move(r.x + r.width * 0.5, r.y + r.height * 0.95, { steps: 8 }); + assert.equal(await bocce.evaluate(() => JSON.stringify(game.closeup.aim)), fixed, 'la mira resta dove si è cliccato'); + await bocce.keyboard.press('Escape'); + await bocce.waitForFunction(() => game.speech?.text.startsWith('Già stanco?'), null, { timeout: 5000 }); + await bocce.close(); + } + + // «Fai due tiri» al campo del Circolo: il mouse mira, tenendo premuto la forza sale, lasciando la palla vola; Esc esce. + { + const k = await open({ width: 1280, height: 860 }); + await k.click('#splash'); + await k.click('#new-game'); + await k.click('#rione [data-rione="campomigliaio"]'); + await k.click('#skip-intro'); + await settle(k); + await k.evaluate(() => game.go('via_cipriani', [340, 316])); + await settle(k); + await k.evaluate(() => game.act('use', 'campo_basket')); + await k.waitForFunction(() => game.dialogue && !game.busy, null, { timeout: 20000 }); + await k.locator('#choices li', { hasText: 'Due tiri seri.' }).click(); + await k.waitForFunction(() => game.closeup?.id === 'basket' && game.closeup.step === 'aim' && !game.speech, null, { timeout: 30000 }); + const r = await k.locator('#scene').boundingBox(); + const at = (x, y) => [r.x + x / 640 * r.width, r.y + y / 360 * r.height]; + await k.mouse.move(...at(560, 60)); + const high = await k.evaluate(() => game.closeup.angle); + await k.mouse.move(...at(600, 200)); + assert.ok(await k.evaluate(() => game.closeup.angle) < high, 'la mira segue il mouse'); + await k.mouse.down(); + await k.waitForTimeout(500); + assert.ok(await k.evaluate(() => game.closeup.power > 0.2 && game.closeup.holding), 'tenendo premuto la forza sale'); + await k.screenshot({ path: `${OUT}/03e-basket-carica.png` }); + await k.mouse.up(); + assert.equal(await k.evaluate(() => game.closeup.step), 'flight', 'lasciando, la palla parte'); + await k.waitForTimeout(350); + await k.screenshot({ path: `${OUT}/03f-basket-volo.png` }); + await k.waitForFunction(() => game.closeup.step === 'aim' && game.closeup.thrown === 1, null, { timeout: 20000 }); + await k.keyboard.press('Escape'); + await k.waitForFunction(() => game.speech?.text.startsWith('Lascio il pallone'), null, { timeout: 5000 }); + await k.close(); + } + + // La pesca al lago dell'Anatre: coi ragazzi in Fortezza «S'ha andà a fallo?!?», poi il lancio tenendo premuto e l'attesa. + { + const k = await open({ width: 1280, height: 860 }); + await k.click('#splash'); + await k.click('#new-game'); + await k.click('#rione [data-rione="campomigliaio"]'); + await k.click('#skip-intro'); + await settle(k); + await k.evaluate(() => game.go('fortezza', [240, 334])); + await settle(k); + await clickScene(k, await hotspotCenter(k, 'pescatori')); + await k.waitForFunction(() => game.dialogue && !game.busy, null, { timeout: 40000 }); + await k.locator('#choices li', { hasText: 'S’ha andà a fallo?!?' }).or(k.locator('#choices li', { hasText: "S'ha andà a fallo?!?" })).first().click(); + await k.waitForFunction(() => game.options().some(o => o.text === 'Seria.'), null, { timeout: 30000 }); + await k.locator('#choices li', { hasText: 'Seria.' }).click(); + await k.waitForFunction(() => game.closeup?.id === 'pesca' && game.closeup.step === 'aim' && !game.speech, null, { timeout: 30000 }); + const r = await k.locator('#scene').boundingBox(); + await k.mouse.move(r.x + r.width * 0.7, r.y + r.height * 0.7); + assert.ok(await k.evaluate(() => game.closeup.power > 0.5), 'toccando l\'acqua al largo la forza sale'); + await k.mouse.down(); + await k.waitForTimeout(500); + assert.ok(await k.evaluate(() => game.closeup.holding && game.closeup.power > 0.2), 'tenendo premuto si carica il lancio'); + await k.mouse.up(); + await k.waitForFunction(() => game.closeup.step === 'wait', null, { timeout: 5000 }); + await k.waitForTimeout(600); + await k.screenshot({ path: `${OUT}/03g-pesca-attesa.png` }); + assert.equal(await k.textContent('#bocce-throw'), 'Ferra'); + await k.keyboard.press('Escape'); + await k.waitForFunction(() => game.speech?.text.startsWith('Lascio la canna'), null, { timeout: 5000 }); + await k.close(); + } + + // Il biliardo del Circolo 4 Maggio: la sala, poi la partita; un tocco sul tavolo mira, tenendo premuto la forza sale. + { + const b = await open({ width: 1280, height: 860 }); + await b.click('#splash'); + await b.click('#new-game'); + await b.click('#rione [data-rione="campomigliaio"]'); + await b.click('#skip-intro'); + await settle(b); + await b.evaluate(() => game.go('circolo_4_maggio')); + await settle(b); + await b.waitForTimeout(1500); // la dissolvenza del cambio scena + await b.screenshot({ path: `${OUT}/03d-circolo.png` }); + await clickScene(b, await hotspotCenter(b, 'ing_tozzi')); + await b.waitForFunction(() => game.dialogue && !game.busy, null, { timeout: 20000 }); + await b.locator('#choices li', { hasText: 'una partita ai birilli' }).click(); + await b.waitForFunction(() => game.options().some(o => o.text === 'Seria.'), null, { timeout: 30000 }); + await b.locator('#choices li', { hasText: 'Seria.' }).click(); + await b.waitForFunction(() => game.closeup?.id === 'biliardo' && game.closeup.step === 'aim' && !game.speech, null, { timeout: 30000 }); + const r = await b.locator('#scene').boundingBox(); + const [tx, ty] = await b.evaluate(async () => { const { VIEW } = await import('/src/engine/biliardo.js'); const V = game.closeup.balls.cpu; return [VIEW.x + V.x * VIEW.k, VIEW.y + V.y * VIEW.k]; }); + await b.mouse.move(r.x + tx / 640 * r.width, r.y + ty / 360 * r.height); + await b.mouse.down(); + await b.waitForTimeout(500); + assert.ok(await b.evaluate(() => game.closeup.power > 0.2), 'tenendo premuto la forza sale'); + await b.screenshot({ path: `${OUT}/03e-biliardo-forza.png` }); + await b.mouse.up(); + assert.equal(await b.evaluate(() => game.closeup.step), 'rolling', 'lasciando, la biglia parte'); + await b.waitForFunction(() => game.closeup.step === 'aim' && game.closeup.current === 'cpu' || game.closeup.step === 'rolling' && game.closeup.shot?.shooter === 'cpu', null, { timeout: 20000 }); + await b.screenshot({ path: `${OUT}/03f-biliardo-socio.png` }); + await b.keyboard.press('Escape'); + await b.waitForFunction(() => game.speech?.text.startsWith('Lasci a metà?'), null, { timeout: 5000 }); + await b.close(); + } +} + +async function telefoni() { + 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('#rione [data-rione="campomigliaio"]'); + 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('#rione [data-rione="campomigliaio"]'); + 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` }); + // Telefono in verticale: la Pausa prende tutto lo schermo, e versione e spiegazione non finiscono sopra i verbi. + { + const tall = await browser.newPage({ viewport: { width: 412, height: 915 }, deviceScaleFactor: 2, hasTouch: true, isMobile: true }); + await tall.goto(`http://127.0.0.1:${PORT}/`); + await tall.waitForSelector('#splash', { state: 'hidden', timeout: 15000 }); + await tall.click('#new-game'); + await tall.click('#rione [data-rione="campomigliaio"]'); + await tall.click('#skip-intro'); + await settle(tall); + await tall.click('#pause-button'); + const fit = await tall.evaluate(() => { + const p = document.getElementById('pause'), v = p.querySelector('.version').getBoundingClientRect(); + return { inside: p.scrollHeight <= p.clientHeight + 1, top: document.elementFromPoint(v.x + v.width / 2, v.y + v.height / 2)?.closest('#pause') !== null }; + }); + assert.deepEqual(fit, { inside: true, top: true }, 'in verticale la pausa esce dallo schermo o finisce sotto i verbi'); + await tall.close(); + } + const landscape = await open({ width: 844, height: 390 }); + await landscape.click('#new-game'); + await landscape.click('#rione [data-rione="campomigliaio"]'); + await landscape.click('#skip-intro'); + await settle(landscape); + assert.equal(await landscape.evaluate(() => document.documentElement.scrollWidth > innerWidth), false); + assert.ok(await landscape.evaluate(() => [...document.querySelectorAll('#verbs button')].every(b => b.scrollWidth <= b.clientWidth + 1 && b.getBoundingClientRect().height >= 26)), 'in orizzontale i verbi si leggono interi e il dito li prende'); + await landscape.screenshot({ path: `${OUT}/09-orizzontale.png`, fullPage: true }); + + // Debug (?debug): area calpestabile e hotspot disegnati; Maiusc+clic segna punti senza muovere Andrea, X li azzera. + const dbg = await open({ width: 1280, height: 860 }, '?debug'); + await dbg.click('#new-game'); + await dbg.click('#rione [data-rione="campomigliaio"]'); + await dbg.click('#skip-intro'); + await settle(dbg); + const start = await dbg.evaluate(() => [game.state.x, game.state.y]); + await dbg.keyboard.down('Shift'); + await clickScene(dbg, [100, 300]); + await clickScene(dbg, [200, 320]); + await dbg.keyboard.up('Shift'); + await dbg.mouse.move(700, 400); + await dbg.waitForTimeout(200); + assert.deepEqual(await dbg.evaluate(() => [game.state.x, game.state.y]), start, 'Maiusc+clic non muove Andrea'); + await dbg.screenshot({ path: `${OUT}/10-debug.png` }); +} + try { - await checkMultimedia(browser, PORT, OUT, errors); - if (!process.argv.includes('--media-only')) { + if (process.argv.includes('--media-only')) await checkMultimedia(browser, PORT, OUT, errors); + else if (PARTE === 'minigiochi') { await minigiochi(); console.log('Browser OK: libro, bocce, tiri, pesca, biliardo.'); } + else if (PARTE === 'telefoni') { await checkMultimedia(browser, PORT, OUT, errors); await telefoni(); console.log('Browser OK: telefoni e debug.'); } + else { 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/); @@ -287,7 +557,7 @@ try { await page.mouse.move(box.x + at[0] / 640 * box.width, box.y + at[1] / 360 * box.height); await page.waitForFunction(() => game.closeup?.step === 'reveal'); await page.screenshot({ path: `${OUT}/05b-fessura-${i}.png` }); - for (let n = 0; n < 200; n++) { + for (const fine = Date.now() + 30000; Date.now() < fine;) { // con la macchina carica la rivelazione dura di più if (await page.evaluate(i => game.closeup.found > i, i)) break; if (await page.evaluate(() => !!game.speech)) await page.keyboard.press('Space'); await page.waitForTimeout(30); @@ -375,253 +645,8 @@ try { await page.click('#close-map'); await page.setViewportSize({ width: 1280, height: 860 }); - // Il libro dell'oratorio: preso, il primo piano; si sfoglia coi clic veri, «FINE» senza che nessuno lo dica, - // Esc chiude; dall'inventario «Apri» lo riapre e il pulsante Chiudi lo chiude. Intanto la scena dietro non risponde. - { - const book = await open({ width: 1280, height: 860 }); - await book.click('#splash'); - await book.click('#new-game'); - await book.click('#rione [data-rione="campomigliaio"]'); - await book.click('#skip-intro'); - await settle(book); - await book.evaluate(() => game.go('oratorio', [540, 300])); - await settle(book); - await book.click('#verbs [data-verb="take"]'); - await clickScene(book, await hotspotCenter(book, 'libro_dimenticato')); - await book.waitForFunction(() => game.closeup?.step === 'cover', null, { timeout: 15000 }); - await book.waitForTimeout(600); - await book.screenshot({ path: `${OUT}/03a-libro-copertina.png` }); - const where = await book.evaluate(() => [game.state.x, game.state.y]); - await clickScene(book, [320, 200]); - assert.equal(await book.evaluate(() => game.closeup.step), 'title', 'un clic apre il libro'); - await clickScene(book, [320, 200]); - assert.equal(await book.evaluate(() => game.closeup.step), 'turn', 'un altro gira la pagina'); - await book.waitForFunction(() => game.closeup?.step === 'end'); - assert.equal(await book.evaluate(() => game.speech), null, '«FINE» non lo dice nessuno'); - // Prima la battuta, poi la schermata: con swiftshader una schermata può durare più della battuta. - await book.waitForFunction(() => game.speech?.text === 'Mi ricordavo un finale diverso.', null, { timeout: 5000 }); - await book.screenshot({ path: `${OUT}/03b-libro-fine.png` }); - assert.deepEqual(await book.evaluate(() => [game.state.x, game.state.y]), where, 'i clic sul libro non muovono Andrea'); - await book.keyboard.press('Space'); - await book.keyboard.press('Escape'); - assert.equal(await book.evaluate(() => game.closeup), null, 'Esc chiude il libro'); - await book.waitForSelector('#closeup-close', { state: 'hidden' }); - await settle(book); - await book.click('#verbs [data-verb="open"]'); - await book.click('#inventory button'); - await book.waitForFunction(() => game.closeup?.step === 'cover'); - assert.ok(await book.isVisible('#closeup-close'), 'si vede il pulsante Chiudi'); - await book.click('#closeup-close'); - assert.equal(await book.evaluate(() => game.closeup), null, 'Chiudi chiude il libro'); - await book.close(); - } - - // Le bocce al pallaio: col mouse tenuto premuto la forza sale, lasciando la boccia parte; la scena dietro è ferma. - { - const bocce = await open({ width: 1280, height: 860 }); - await bocce.click('#splash'); - await bocce.click('#new-game'); - await bocce.click('#rione [data-rione="campomigliaio"]'); - await bocce.click('#skip-intro'); - await settle(bocce); - await bocce.evaluate(() => game.go('tennis_chiesa', [430, 310])); - await settle(bocce); - await clickScene(bocce, await hotspotCenter(bocce, 'mariano')); - await bocce.waitForFunction(() => game.dialogue && !game.busy, null, { timeout: 20000 }); - await bocce.locator('#choices li', { hasText: 'Facciamo una partita' }).click(); - await bocce.waitForFunction(() => game.options().some(o => o.text === 'Seria.'), null, { timeout: 30000 }); - await bocce.locator('#choices li', { hasText: 'Seria.' }).click(); - await bocce.waitForFunction(() => game.closeup?.id === 'bocce' && game.closeup.step === 'aim' && !game.speech, null, { timeout: 30000 }); - const r = await bocce.locator('#scene').boundingBox(); - await bocce.mouse.move(r.x + r.width / 2, r.y + r.height / 2); - await bocce.mouse.down(); - await bocce.waitForTimeout(500); - assert.ok(await bocce.evaluate(() => game.closeup.power > 0.2), 'tenendo premuto la forza sale'); - await bocce.screenshot({ path: `${OUT}/03c-bocce-forza.png` }); - await bocce.mouse.up(); - assert.equal(await bocce.evaluate(() => game.closeup.balls.length), 1, 'lasciando, la boccia parte'); - await bocce.waitForFunction(() => game.closeup.balls.length === 2, null, { timeout: 15000 }); - // Il clic fissa la mira: andando verso la forza o «Lancia» il puntatore attraversa il campo e non la sposta. - await bocce.waitForFunction(() => game.closeup.step === 'aim' && game.closeup.current === 'player', null, { timeout: 30000 }); - await bocce.mouse.click(r.x + r.width * 0.45, r.y + r.height * 0.6); - const fixed = await bocce.evaluate(() => JSON.stringify(game.closeup.aim)); - await bocce.mouse.move(r.x + r.width * 0.5, r.y + r.height * 0.95, { steps: 8 }); - assert.equal(await bocce.evaluate(() => JSON.stringify(game.closeup.aim)), fixed, 'la mira resta dove si è cliccato'); - await bocce.keyboard.press('Escape'); - await bocce.waitForFunction(() => game.speech?.text.startsWith('Già stanco?'), null, { timeout: 5000 }); - await bocce.close(); - } - - // «Fai due tiri» al campo del Circolo: il mouse mira, tenendo premuto la forza sale, lasciando la palla vola; Esc esce. - { - const k = await open({ width: 1280, height: 860 }); - await k.click('#splash'); - await k.click('#new-game'); - await k.click('#rione [data-rione="campomigliaio"]'); - await k.click('#skip-intro'); - await settle(k); - await k.evaluate(() => game.go('via_cipriani', [340, 316])); - await settle(k); - await k.evaluate(() => game.act('use', 'campo_basket')); - await k.waitForFunction(() => game.dialogue && !game.busy, null, { timeout: 20000 }); - await k.locator('#choices li', { hasText: 'Due tiri seri.' }).click(); - await k.waitForFunction(() => game.closeup?.id === 'basket' && game.closeup.step === 'aim' && !game.speech, null, { timeout: 30000 }); - const r = await k.locator('#scene').boundingBox(); - const at = (x, y) => [r.x + x / 640 * r.width, r.y + y / 360 * r.height]; - await k.mouse.move(...at(560, 60)); - const high = await k.evaluate(() => game.closeup.angle); - await k.mouse.move(...at(600, 200)); - assert.ok(await k.evaluate(() => game.closeup.angle) < high, 'la mira segue il mouse'); - await k.mouse.down(); - await k.waitForTimeout(500); - assert.ok(await k.evaluate(() => game.closeup.power > 0.2 && game.closeup.holding), 'tenendo premuto la forza sale'); - await k.screenshot({ path: `${OUT}/03e-basket-carica.png` }); - await k.mouse.up(); - assert.equal(await k.evaluate(() => game.closeup.step), 'flight', 'lasciando, la palla parte'); - await k.waitForTimeout(350); - await k.screenshot({ path: `${OUT}/03f-basket-volo.png` }); - await k.waitForFunction(() => game.closeup.step === 'aim' && game.closeup.thrown === 1, null, { timeout: 20000 }); - await k.keyboard.press('Escape'); - await k.waitForFunction(() => game.speech?.text.startsWith('Lascio il pallone'), null, { timeout: 5000 }); - await k.close(); - } - - // La pesca al lago dell'Anatre: coi ragazzi in Fortezza «S'ha andà a fallo?!?», poi il lancio tenendo premuto e l'attesa. - { - const k = await open({ width: 1280, height: 860 }); - await k.click('#splash'); - await k.click('#new-game'); - await k.click('#rione [data-rione="campomigliaio"]'); - await k.click('#skip-intro'); - await settle(k); - await k.evaluate(() => game.go('fortezza', [240, 334])); - await settle(k); - await clickScene(k, await hotspotCenter(k, 'pescatori')); - await k.waitForFunction(() => game.dialogue && !game.busy, null, { timeout: 40000 }); - await k.locator('#choices li', { hasText: 'S’ha andà a fallo?!?' }).or(k.locator('#choices li', { hasText: "S'ha andà a fallo?!?" })).first().click(); - await k.waitForFunction(() => game.options().some(o => o.text === 'Seria.'), null, { timeout: 30000 }); - await k.locator('#choices li', { hasText: 'Seria.' }).click(); - await k.waitForFunction(() => game.closeup?.id === 'pesca' && game.closeup.step === 'aim' && !game.speech, null, { timeout: 30000 }); - const r = await k.locator('#scene').boundingBox(); - await k.mouse.move(r.x + r.width * 0.7, r.y + r.height * 0.7); - assert.ok(await k.evaluate(() => game.closeup.power > 0.5), 'toccando l\'acqua al largo la forza sale'); - await k.mouse.down(); - await k.waitForTimeout(500); - assert.ok(await k.evaluate(() => game.closeup.holding && game.closeup.power > 0.2), 'tenendo premuto si carica il lancio'); - await k.mouse.up(); - await k.waitForFunction(() => game.closeup.step === 'wait', null, { timeout: 5000 }); - await k.waitForTimeout(600); - await k.screenshot({ path: `${OUT}/03g-pesca-attesa.png` }); - assert.equal(await k.textContent('#bocce-throw'), 'Ferra'); - await k.keyboard.press('Escape'); - await k.waitForFunction(() => game.speech?.text.startsWith('Lascio la canna'), null, { timeout: 5000 }); - await k.close(); - } - - // Il biliardo del Circolo 4 Maggio: la sala, poi la partita; un tocco sul tavolo mira, tenendo premuto la forza sale. - { - const b = await open({ width: 1280, height: 860 }); - await b.click('#splash'); - await b.click('#new-game'); - await b.click('#rione [data-rione="campomigliaio"]'); - await b.click('#skip-intro'); - await settle(b); - await b.evaluate(() => game.go('circolo_4_maggio')); - await settle(b); - await b.waitForTimeout(1500); // la dissolvenza del cambio scena - await b.screenshot({ path: `${OUT}/03d-circolo.png` }); - await clickScene(b, await hotspotCenter(b, 'ing_tozzi')); - await b.waitForFunction(() => game.dialogue && !game.busy, null, { timeout: 20000 }); - await b.locator('#choices li', { hasText: 'una partita ai birilli' }).click(); - await b.waitForFunction(() => game.options().some(o => o.text === 'Seria.'), null, { timeout: 30000 }); - await b.locator('#choices li', { hasText: 'Seria.' }).click(); - await b.waitForFunction(() => game.closeup?.id === 'biliardo' && game.closeup.step === 'aim' && !game.speech, null, { timeout: 30000 }); - const r = await b.locator('#scene').boundingBox(); - const [tx, ty] = await b.evaluate(async () => { const { VIEW } = await import('/src/engine/biliardo.js'); const V = game.closeup.balls.cpu; return [VIEW.x + V.x * VIEW.k, VIEW.y + V.y * VIEW.k]; }); - await b.mouse.move(r.x + tx / 640 * r.width, r.y + ty / 360 * r.height); - await b.mouse.down(); - await b.waitForTimeout(500); - assert.ok(await b.evaluate(() => game.closeup.power > 0.2), 'tenendo premuto la forza sale'); - await b.screenshot({ path: `${OUT}/03e-biliardo-forza.png` }); - await b.mouse.up(); - assert.equal(await b.evaluate(() => game.closeup.step), 'rolling', 'lasciando, la biglia parte'); - await b.waitForFunction(() => game.closeup.step === 'aim' && game.closeup.current === 'cpu' || game.closeup.step === 'rolling' && game.closeup.shot?.shooter === 'cpu', null, { timeout: 20000 }); - await b.screenshot({ path: `${OUT}/03f-biliardo-socio.png` }); - await b.keyboard.press('Escape'); - await b.waitForFunction(() => game.speech?.text.startsWith('Lasci a metà?'), null, { timeout: 5000 }); - await b.close(); - } - - 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('#rione [data-rione="campomigliaio"]'); - 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('#rione [data-rione="campomigliaio"]'); - 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` }); - // Telefono in verticale: la Pausa prende tutto lo schermo, e versione e spiegazione non finiscono sopra i verbi. - { - const tall = await browser.newPage({ viewport: { width: 412, height: 915 }, deviceScaleFactor: 2, hasTouch: true, isMobile: true }); - await tall.goto(`http://127.0.0.1:${PORT}/`); - await tall.waitForSelector('#splash', { state: 'hidden', timeout: 15000 }); - await tall.click('#new-game'); - await tall.click('#rione [data-rione="campomigliaio"]'); - await tall.click('#skip-intro'); - await settle(tall); - await tall.click('#pause-button'); - const fit = await tall.evaluate(() => { - const p = document.getElementById('pause'), v = p.querySelector('.version').getBoundingClientRect(); - return { inside: p.scrollHeight <= p.clientHeight + 1, top: document.elementFromPoint(v.x + v.width / 2, v.y + v.height / 2)?.closest('#pause') !== null }; - }); - assert.deepEqual(fit, { inside: true, top: true }, 'in verticale la pausa esce dallo schermo o finisce sotto i verbi'); - await tall.close(); - } - const landscape = await open({ width: 844, height: 390 }); - await landscape.click('#new-game'); - await landscape.click('#rione [data-rione="campomigliaio"]'); - await landscape.click('#skip-intro'); - await settle(landscape); - assert.equal(await landscape.evaluate(() => document.documentElement.scrollWidth > innerWidth), false); - assert.ok(await landscape.evaluate(() => [...document.querySelectorAll('#verbs button')].every(b => b.scrollWidth <= b.clientWidth + 1 && b.getBoundingClientRect().height >= 26)), 'in orizzontale i verbi si leggono interi e il dito li prende'); - await landscape.screenshot({ path: `${OUT}/09-orizzontale.png`, fullPage: true }); - - // Debug (?debug): area calpestabile e hotspot disegnati; Maiusc+clic segna punti senza muovere Andrea, X li azzera. - const dbg = await open({ width: 1280, height: 860 }, '?debug'); - await dbg.click('#new-game'); - await dbg.click('#rione [data-rione="campomigliaio"]'); - await dbg.click('#skip-intro'); - await settle(dbg); - const start = await dbg.evaluate(() => [game.state.x, game.state.y]); - await dbg.keyboard.down('Shift'); - await clickScene(dbg, [100, 300]); - await clickScene(dbg, [200, 320]); - await dbg.keyboard.up('Shift'); - await dbg.mouse.move(700, 400); - await dbg.waitForTimeout(200); - assert.deepEqual(await dbg.evaluate(() => [game.state.x, game.state.y]), start, 'Maiusc+clic non muove Andrea'); - await dbg.screenshot({ path: `${OUT}/10-debug.png` }); - assert.deepEqual(errors, [], 'errori nella console'); - console.log(`Browser OK: soluzione completa, ripresa del salvataggio, telefono. Schermate in ${OUT}`); + console.log(`Browser OK: soluzione completa, ripresa del salvataggio. Schermate in ${OUT}`); } assert.deepEqual(errors, [], 'errori nella console'); } finally { diff --git a/tests/multimedia.browser.mjs b/tests/multimedia.browser.mjs index 018d0c5..6ef7667 100644 --- a/tests/multimedia.browser.mjs +++ b/tests/multimedia.browser.mjs @@ -84,7 +84,7 @@ export async function checkMultimedia(browser, port, out, errors) { else await page.mouse.move(...await at(target)); await page.waitForFunction(() => game.closeup?.step === 'reveal'); await page.screenshot({ path: `${out}/media-torcia-${mobile ? 'telefono' : 'desktop'}-${i}.png` }); - for (let n = 0; n < 200; n++) { + for (const fine = Date.now() + 30000; Date.now() < fine;) { // con la macchina carica la rivelazione dura di più if (await page.evaluate(i => game.closeup.found > i, i)) break; if (await page.evaluate(() => !!game.speech)) await page.keyboard.press('Space'); await page.waitForTimeout(25);