Le prove nel browser a sezioni (idea di Andrea): scripts/verify.mjs rifà solo le prove i cui ingressi sono cambiati dall'ultimo verde (.verify-ok.json fuori da git); make verify le usa, make verify-plan spiega; i minigiochi entrano in make verify; due alla volta, perché con quattro le schermate vanno in timeout. Test del pianificatore (Andrea)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T
This commit is contained in:
2026-11-01 14:51:42 +01:00
co-authored by Claude Opus 5.5
parent be099aa810
commit a455e3f12f
7 changed files with 235 additions and 3 deletions
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env node
// Le prove nel browser a sezioni (idea di Andrea, 1 novembre 2026): ogni prova ha i suoi ingressi, i file da cui dipende;
// si rifà solo se la loro impronta (i blob di git, più i file modificati nella cartella di lavoro) è cambiata dall'ultimo
// verde, ricordato in .verify-ok.json (fuori da git). Se cambia la briscola si riprova la briscola; se cambia la storia, il
// motore, l'impaginazione o le risorse si riprova la partita intera (e le prove delle scene speciali), non la briscola.
// node scripts/verify.mjs le prove da rifare, in parallelo (JOBS=2: con quattro Chromium swiftshader insieme le schermate vanno in timeout), e aggiorna i verdi
// node scripts/verify.mjs --plan dice solo cosa rifarebbe e perché
// node scripts/verify.mjs --all tutte, senza guardare i verdi
// node scripts/verify.mjs briscola solo quelle nominate (anche se verdi)
// I test unitari e check-public non stanno qui: costano pochi secondi e girano sempre (make verify).
import { execFileSync, spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { cpus } from 'node:os';
// Il motore comune a tutto: cambia questo e si riprova ogni minigioco.
export const ENGINE = ['index.html', 'style.css', 'src/main.js', 'src/engine/game.js', 'src/engine/render.js',
'tests/helpers.mjs', 'package-lock.json', 'scripts/verify.mjs'];
// Non toccano il gioco nel browser: documenti, strumenti, pubblicazione, versione (make bump la cambia prima di verificare),
// test unitari (girano sempre, a parte).
export const IGNORED = [/^docs\//, /^[^/]*\.md$/, /^src\/version\.js$/, /^package\.json$/, /^Makefile$/, /^scripts\/(?!verify\.mjs)/,
/^tools\//, /^video\//, /^\.gitea\//, /^prompts\//, /^LICENSE$/, /^Dockerfile$/, /^nginx\.conf$/, /^docker-compose/, /^requirements\.txt$/,
/^\.(gitignore|dockerignore|verify-ok\.json)$/, /^tests\/[^/]*\.test\.mjs$/, /^tests\/test_[^/]*\.py$/, /^deploy\.local\.mk$/, /(^|\/)__pycache__\//];
// Le prove. `core: true` = dipende da tutto il gioco tranne i file dei minigiochi a sé (che hanno la loro prova); altrimenti
// solo dal motore e dai propri file: quelli che si chiamano come la prova (src/engine/flipper.js, flipper-render.js,
// assets/closeups/flipper-*.png, docs/BRISCOLA.md…) più gli `extra`. `run` sono gli argomenti di node.
export const SUITES = {
principale: { core: true, run: ['tests/browser.mjs', '--parte=principale'], note: 'la partita intera, pausa e salvataggi' },
minigiochi: { core: true, run: ['tests/browser.mjs', '--parte=minigiochi'], note: 'libro, bocce, tiri, pesca e biliardo dalla partita' },
telefoni: { core: true, run: ['tests/browser.mjs', '--parte=telefoni'], note: 'multimedia, telefoni, la finestra di Andrea, debug' },
ambient: { core: true, run: ['tests/ambient.browser.mjs'], note: 'tutte le scene nelle tre fasi e gli effetti' },
veicoli: { core: true, run: ['tests/veicoli.browser.mjs'], note: 'i veicoli nelle scene' },
ingorgo: { core: true, run: ['tests/ingorgo.browser.mjs'], note: "l'Ingorgo Sonoro" },
smarronata: { core: true, run: ['tests/smarronata.browser.mjs'], note: 'la Smarronata del 1993' },
briscola: { run: ['tests/briscola.browser.mjs'] },
tressette: { run: ['tests/tressette.browser.mjs'] },
scopa: { run: ['tests/scopa.browser.mjs'] },
biliardo: { run: ['tests/biliardo.browser.mjs'] },
flipper: { run: ['tests/flipper.browser.mjs'] },
micro: { run: ['tests/micro.browser.mjs'] },
cerbottana: { run: ['tests/cerbottana.browser.mjs'] },
condor: { run: ['tests/condor.browser.mjs'] },
campana: { run: ['tests/campana.browser.mjs'] },
bruciate: { run: ['tests/bruciate.browser.mjs'] },
gara: { run: ['tests/gara.browser.mjs'], extra: ['src/engine/corsa.js', 'src/story/piloti.js', /^assets\/[^/]*\/(gara|gp|pilot)/] },
telefono: { run: ['tests/telefono.browser.mjs'], extra: [/^assets\/muro\//] },
risveglio: { run: ['tests/risveglio.browser.mjs'] },
};
const ownRe = name => new RegExp(`(^|/)${name}([-_.][^/]*)?$`, 'i');
const matches = (path, rule) => rule instanceof RegExp ? rule.test(path) : rule === path;
export const ignored = path => IGNORED.some(r => r.test(path));
export function ownFiles(name, files) {
const s = SUITES[name], re = ownRe(name);
return files.filter(p => re.test(p) || (s.extra || []).some(r => matches(p, r)));
}
// Gli ingressi di una prova fra i file dati (tutti quelli di git più quelli nuovi nella cartella).
export function inputsOf(name, files) {
const s = SUITES[name], live = files.filter(p => !ignored(p));
if (!s.core) return [...new Set([...ENGINE.filter(p => files.includes(p)), ...ownFiles(name, live)])].sort();
const others = Object.keys(SUITES).filter(n => !SUITES[n].core);
const foreign = new Set(others.flatMap(n => ownFiles(n, live)));
return live.filter(p => !foreign.has(p)).sort();
}
export const fingerprint = (paths, hashOf) => createHash('sha1').update(paths.map(p => `${p} ${hashOf(p)}`).join('\n')).digest('hex');
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const git = (...a) => execFileSync('git', a, { cwd: ROOT, encoding: 'utf8', maxBuffer: 1 << 26 });
// I file del progetto con l'impronta di ognuno: il blob dell'indice di git, o il contenuto attuale se modificato o nuovo.
export function snapshot() {
const hashes = new Map();
for (const line of git('ls-files', '-s', '-z').split('\0')) if (line) { const [meta, path] = line.split('\t'); hashes.set(path, meta.split(' ')[1]); }
const dirty = [];
for (const line of git('status', '--porcelain', '-z', '--untracked-files=all').split('\0')) {
if (!line) continue;
const code = line.slice(0, 2), path = line.slice(3);
if (code.includes('D')) hashes.delete(path); else dirty.push(path);
}
for (const path of dirty) if (existsSync(`${ROOT}/${path}`)) hashes.set(path, git('hash-object', '--', path).trim());
return hashes;
}
const OK_FILE = `${ROOT}/.verify-ok.json`;
const readOk = () => { try { return JSON.parse(readFileSync(OK_FILE, 'utf8')); } catch { return {}; } };
export function plan(hashes, ok, names = Object.keys(SUITES)) {
const files = [...hashes.keys()], out = [];
for (const name of names) {
const inputs = inputsOf(name, files), fp = fingerprint(inputs, p => hashes.get(p));
const was = ok[name], changed = was?.fingerprint === fp ? [] : inputs.filter(p => !was?.hashes || was.hashes[p] !== hashes.get(p));
out.push({ name, fp, inputs, changed, green: was?.fingerprint === fp, seconds: was?.seconds });
}
return out;
}
function run(name) {
return new Promise(resolve => {
const t0 = Date.now(), lines = [];
const p = spawn(process.execPath, SUITES[name].run, { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'] });
let rest = '';
const onData = chunk => { const parts = (rest + chunk).split('\n'); rest = parts.pop(); for (const l of parts) { lines.push(l); console.log(`[${name}] ${l}`); } };
p.stdout.on('data', onData); p.stderr.on('data', onData);
p.on('close', code => { if (rest) console.log(`[${name}] ${rest}`); resolve({ name, code, seconds: Math.round((Date.now() - t0) / 1000) }); });
});
}
async function main() {
const args = process.argv.slice(2), all = args.includes('--all'), onlyPlan = args.includes('--plan');
const named = args.filter(a => !a.startsWith('--'));
for (const n of named) if (!SUITES[n]) { console.error(`prova sconosciuta: ${n} (${Object.keys(SUITES).join(', ')})`); process.exit(2); }
const hashes = snapshot(), ok = readOk();
const todo = plan(hashes, ok, named.length ? named : undefined).filter(p => all || named.length || !p.green);
const green = Object.keys(SUITES).filter(n => !todo.some(t => t.name === n));
if (!todo.length) { console.log(`Prove nel browser: tutte verdi dall'ultimo giro (${green.length}); niente da rifare.`); return; }
for (const t of todo) {
const why = all ? 'tutte' : named.includes(t.name) ? 'chiesta' : !ok[t.name] ? 'mai provata' : `cambiati ${t.changed.slice(0, 4).join(', ')}${t.changed.length > 4 ? ` e altri ${t.changed.length - 4}` : ''}`;
console.log(`da rifare: ${t.name}${t.seconds ? ` (~${t.seconds} s)` : ''}: ${why}`);
}
if (green.length) console.log(`già verdi: ${green.join(', ')}`);
if (onlyPlan) return;
const jobs = Math.max(1, Number(process.env.JOBS) || Math.min(2, Math.floor(cpus().length / 4)));
const queue = todo.sort((a, b) => (b.seconds || 999) - (a.seconds || 999)), results = [];
const worker = async () => { for (let t; (t = queue.shift());) {
const r = await run(t.name); results.push(r);
if (r.code === 0) {
const now = readOk();
now[t.name] = { fingerprint: t.fp, seconds: r.seconds, when: new Date().toISOString(), hashes: Object.fromEntries(t.inputs.map(p => [p, hashes.get(p)])) };
writeFileSync(OK_FILE, JSON.stringify(now, null, 1) + '\n');
}
} };
await Promise.all(Array.from({ length: Math.min(jobs, queue.length) }, worker));
for (const r of results.sort((a, b) => a.name.localeCompare(b.name))) console.log(`${r.code === 0 ? 'OK ' : 'FAIL'} ${r.name} (${r.seconds} s)`);
const failed = results.filter(r => r.code !== 0).map(r => r.name);
if (failed.length) { console.log(`Prove fallite: ${failed.join(', ')}`); process.exit(1); }
console.log(`Prove nel browser OK: ${results.length} rifatte, ${green.length} già verdi.`);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) main();