Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ks2K99uqCZjAxvy7Rdj1R
164 lines
9.7 KiB
JavaScript
164 lines
9.7 KiB
JavaScript
// Caccia ai difetti dell'interfaccia (scalette in video/COLLAUDO.md): tutta la soluzione in tre modalità
|
||
// (desktop pieno, metà schermo, smartphone col dito), una schermata a ogni passo e a ogni battuta, e un elenco
|
||
// di difetti trovati da soli: bersagli piccoli o coperti, fumetti fuori scena o sotto i pulsanti, testi tagliati,
|
||
// pagina che scorre di lato.
|
||
// node video/collaudo.mjs [desktop|meta|telefono ...] -> video/out/collaudo/<modo>/ e DIFETTI.md
|
||
import { chromium } from 'playwright';
|
||
import { spawn } from 'node:child_process';
|
||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||
import { solution } from '../src/story/solution.js';
|
||
|
||
export const MODI = {
|
||
desktop: { nome: 'Desktop pieno', viewport: { width: 1600, height: 900 } },
|
||
meta: { nome: 'Metà schermo', viewport: { width: 800, height: 900 } },
|
||
telefono: { nome: 'Smartphone in orizzontale', viewport: { width: 844, height: 390 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true },
|
||
};
|
||
// Sotto questa misura (pixel di schermo) un bersaglio è difficile da prendere: col dito di più. Il dito però ha
|
||
// la tolleranza del gioco (TOUCH_REACH in src/main.js) da ogni lato.
|
||
const MIN_MOUSE = 12, MIN_DITO = 28, TOUCH_REACH = 22;
|
||
|
||
const OUT = new URL('./out/collaudo/', import.meta.url).pathname;
|
||
const PORT = 8600 + Math.floor(Math.random() * 300);
|
||
const chosen = process.argv.slice(2).filter(a => MODI[a]);
|
||
const server = spawn('python3', ['-m', 'http.server', String(PORT), '--bind', '127.0.0.1'], { stdio: 'ignore', cwd: new URL('..', import.meta.url).pathname });
|
||
const browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
||
|
||
// I controlli sulla pagina così com'è: ognuno restituisce le frasi dei difetti visti adesso.
|
||
function controlli() {
|
||
const r = el => el.getBoundingClientRect(), $ = id => document.getElementById(id);
|
||
const over = (a, b) => a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
|
||
const visible = el => el && !el.hidden && r(el).width > 0 && getComputedStyle(el).display !== 'none';
|
||
const out = [];
|
||
if (document.documentElement.scrollWidth > innerWidth + 1) out.push(`la pagina scorre di lato (${document.documentElement.scrollWidth} su ${innerWidth})`);
|
||
const stage = r($('stage')), speech = $('speech');
|
||
if (speech.textContent) {
|
||
const s = r(speech);
|
||
if (s.left < stage.left - 1 || s.right > stage.right + 1 || s.top < stage.top - 1) out.push(`fumetto fuori dalla scena: «${speech.textContent.slice(0, 40)}…»`);
|
||
for (const id of ['sound', 'corner']) if (visible($(id)) && over(s, r($(id)))) out.push(`fumetto sotto i pulsanti (${id}): «${speech.textContent.slice(0, 40)}…»`);
|
||
// Un fumetto stretto e altissimo (una parola per riga) si legge male: più alto che largo è un difetto.
|
||
if (s.height > s.width * 1.2) out.push(`fumetto a colonna (${Math.round(s.width)}×${Math.round(s.height)}): «${speech.textContent.slice(0, 40)}…»`);
|
||
}
|
||
for (const b of document.querySelectorAll('#verbs button, #inventory button, #choices li, #sentence'))
|
||
if (visible(b) && b.scrollWidth > b.clientWidth + 1) out.push(`testo tagliato: «${b.textContent.trim().slice(0, 40)}»`);
|
||
const ch = $('choices');
|
||
if (visible(ch)) for (const li of ch.children) if (r(li).bottom > innerHeight + 1) out.push(`scelta fuori dallo schermo: «${li.textContent.slice(0, 40)}»`);
|
||
return out;
|
||
}
|
||
|
||
// Dove cliccare un hotspot, quanto è grande sullo schermo e cosa c'è sopra il suo centro.
|
||
function bersaglio(id) {
|
||
const h = game.hotspot(id), c = document.getElementById('scene'), b = c.getBoundingClientRect(), k = b.width / 640;
|
||
if (!h) return null;
|
||
const [x, y, w, hh] = h.box, cx = b.left + (x + w / 2) * k, cy = b.top + (y + hh / 2) * k;
|
||
const top = document.elementFromPoint(cx, cy);
|
||
return { cx, cy, w: w * k, h: hh * k, sopra: top === c ? null : top?.id || top?.className || top?.tagName };
|
||
}
|
||
|
||
async function giro(modo) {
|
||
const conf = MODI[modo], dir = `${OUT}${modo}/`, difetti = [], visti = new Set();
|
||
rmSync(dir, { recursive: true, force: true });
|
||
mkdirSync(dir, { recursive: true });
|
||
const page = await browser.newPage({ viewport: conf.viewport, deviceScaleFactor: conf.deviceScaleFactor ?? 1, isMobile: !!conf.isMobile, hasTouch: !!conf.hasTouch });
|
||
page.on('pageerror', e => difetti.push({ passo: '-', cosa: `errore JavaScript: ${e.message}` }));
|
||
await page.goto(`http://127.0.0.1:${PORT}/`);
|
||
await page.evaluate(() => localStorage.clear());
|
||
await page.reload();
|
||
const dito = !!conf.hasTouch;
|
||
const tocca = async (x, y) => (dito ? page.touchscreen.tap(x, y) : page.mouse.click(x, y));
|
||
const premi = async sel => (dito ? page.tap(sel) : page.click(sel));
|
||
let n = 0;
|
||
const foto = async (nome, passo) => {
|
||
const file = `${String(++n).padStart(3, '0')}-${nome.replace(/[^a-z0-9]+/gi, '-').slice(0, 40)}.png`;
|
||
await page.screenshot({ path: dir + file });
|
||
for (const cosa of await page.evaluate(controlli)) {
|
||
if (visti.has(cosa)) continue;
|
||
visti.add(cosa);
|
||
difetti.push({ passo, cosa, file });
|
||
}
|
||
return file;
|
||
};
|
||
// Aspetta che il gioco si fermi; ogni battuta nuova ha la sua schermata, poi si salta.
|
||
const fermo = async passo => {
|
||
let ultima = null;
|
||
for (let i = 0; i < 600; i++) {
|
||
const search = await page.evaluate(() => {
|
||
const c = game.closeup;
|
||
if (c?.id !== 'fessura' || game.speech) return null;
|
||
return c.step === 'done' ? { done: true } : c.step === 'search' ? { at: game.story.closeups.fessura.targets[c.found].at } : null;
|
||
});
|
||
if (search?.done) await premi('#closeup-close');
|
||
else if (search?.at) {
|
||
const r = await page.locator('#scene').boundingBox(), [x, y] = search.at;
|
||
await tocca(r.x + x / 640 * r.width, r.y + y / 360 * r.height);
|
||
}
|
||
const s = await page.evaluate(() => ({ t: game.speech?.text ?? null, idle: !game.busy && !game.walking && !game.queue.length && !game.task }));
|
||
if (s.t && s.t !== ultima) { ultima = s.t; await page.waitForTimeout(60); await foto(`battuta-${s.t}`, passo); await page.evaluate(() => game.skip()); }
|
||
else if (!s.t && s.idle) return;
|
||
await page.waitForTimeout(40);
|
||
}
|
||
throw new Error(`${modo}: il gioco non si ferma (${passo})`);
|
||
};
|
||
|
||
await page.waitForSelector('#splash', { state: 'hidden', timeout: 15000 }).catch(() => {});
|
||
await foto('titolo', 'titolo');
|
||
await premi('#new-game');
|
||
await page.waitForSelector('#rione:not([hidden])');
|
||
await foto('rione', 'rione');
|
||
await premi('#rione [data-rione="campomigliaio"]');
|
||
await page.waitForSelector('#intro:not([hidden])');
|
||
await foto('introduzione', 'introduzione');
|
||
await premi('#skip-intro');
|
||
await fermo('inizio');
|
||
await foto('piazza', 'inizio');
|
||
|
||
for (const [verb, target, item] of solution) {
|
||
const passo = [verb, target, item].filter(Boolean).join(' ');
|
||
if (verb === 'choose') {
|
||
await (dito ? page.locator('#choices li', { hasText: target }).tap() : page.locator('#choices li', { hasText: target }).click());
|
||
} else {
|
||
const smart = await page.evaluate(([v, t]) => game.defaultVerb(t) === v, [verb, target]);
|
||
if (item) { await premi(`#verbs [data-verb="${verb}"]`); await premi(`#inventory [data-item="${item}"]`); }
|
||
else if (!smart) await premi(`#verbs [data-verb="${verb}"]`);
|
||
if (await page.evaluate(t => !!game.story.items[t], target)) await premi(`#inventory [data-item="${target}"]`);
|
||
else {
|
||
const b = await page.evaluate(bersaglio, target);
|
||
if (!b) throw new Error(`${modo}: non c'è ${target}`);
|
||
const min = dito ? MIN_DITO : MIN_MOUSE, size = Math.min(b.w, b.h) + (dito ? 2 * TOUCH_REACH : 0);
|
||
if (size < min) difetti.push({ passo, cosa: `bersaglio piccolo: ${target} ${Math.round(b.w)}×${Math.round(b.h)} px (meno di ${min})` });
|
||
if (b.sopra) difetti.push({ passo, cosa: `il centro di ${target} è coperto da «${b.sopra}»: il clic non arriva` });
|
||
await tocca(b.cx, b.cy);
|
||
}
|
||
await page.waitForTimeout(60);
|
||
const preso = await page.evaluate(() => !!(game.task || game.queue.length || game.speech || game.walking));
|
||
if (!preso && verb !== 'choose') difetti.push({ passo, cosa: `il ${dito ? 'tocco' : 'clic'} non ha fatto niente` });
|
||
}
|
||
await fermo(passo);
|
||
await foto(`dopo-${passo}`, passo);
|
||
}
|
||
await page.waitForSelector('#ending:not([hidden])', { timeout: 10000 }).catch(() => difetti.push({ passo: 'fine', cosa: 'la schermata di fine non compare' }));
|
||
await foto('fine', 'fine');
|
||
await page.close();
|
||
return { modo, nome: conf.nome, difetti, foto: n };
|
||
}
|
||
|
||
try {
|
||
const risultati = [];
|
||
for (const modo of chosen.length ? chosen : Object.keys(MODI)) {
|
||
const r = await giro(modo);
|
||
risultati.push(r);
|
||
console.log(`${r.nome}: ${r.foto} schermate, ${r.difetti.length} difetti`);
|
||
}
|
||
const md = ['# Difetti trovati dal collaudo', '', `Generato da \`node video/collaudo.mjs\` il ${new Date().toLocaleString('it-IT')}.`, ''];
|
||
for (const r of risultati) {
|
||
md.push(`## ${r.nome} (\`${r.modo}/\`, ${r.foto} schermate)`, '');
|
||
if (!r.difetti.length) md.push('Nessun difetto trovato.', '');
|
||
for (const d of r.difetti) md.push(`- **${d.passo}**: ${d.cosa}${d.file ? ` — \`${r.modo}/${d.file}\`` : ''}`);
|
||
md.push('');
|
||
}
|
||
writeFileSync(`${OUT}DIFETTI.md`, md.join('\n'));
|
||
console.log(`-> ${OUT}DIFETTI.md`);
|
||
} finally {
|
||
await browser.close();
|
||
server.kill();
|
||
}
|