From a455e3f12fca71302e2dfff1b912f00c46c91c88 Mon Sep 17 00:00:00 2001 From: Andrea Manzi Date: Sun, 1 Nov 2026 14:51:42 +0100 Subject: [PATCH] =?UTF-8?q?Le=20prove=20nel=20browser=20a=20sezioni=20(ide?= =?UTF-8?q?a=20di=20Andrea):=20scripts/verify.mjs=20rif=C3=A0=20solo=20le?= =?UTF-8?q?=20prove=20i=20cui=20ingressi=20sono=20cambiati=20dall'ultimo?= =?UTF-8?q?=20verde=20(.verify-ok.json=20fuori=20da=20git);=20make=20verif?= =?UTF-8?q?y=20le=20usa,=20make=20verify-plan=20spiega;=20i=20minigiochi?= =?UTF-8?q?=20entrano=20in=20make=20verify;=20due=20alla=20volta,=20perch?= =?UTF-8?q?=C3=A9=20con=20quattro=20le=20schermate=20vanno=20in=20timeout.?= =?UTF-8?q?=20Test=20del=20pianificatore=20(Andrea)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GiECd9RWGMKV7n3K1mdy8T --- .gitignore | 1 + CLAUDE.md | 3 + Makefile | 12 +++- README.md | 2 +- docs/STATO.md | 7 +++ scripts/verify.mjs | 141 ++++++++++++++++++++++++++++++++++++++++++ tests/verify.test.mjs | 72 +++++++++++++++++++++ 7 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 scripts/verify.mjs create mode 100644 tests/verify.test.mjs diff --git a/.gitignore b/.gitignore index aae70f6..7ad9ce9 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ assets/generated/ .venv/ .env deploy.local.mk +.verify-ok.json diff --git a/CLAUDE.md b/CLAUDE.md index 1e5040d..9560ce7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,9 @@ i suoi ricordi: non inventare persone, luoghi o fatti del paese al posto suoi; p (`tools/slice_sprites.py`, `scripts/build-site.sh`, `scripts/bump.sh`, `scripts/check-public.sh`, action di Pages). - Nessun build step: ES modules statici, server locale con `python3 -m http.server`. - Ogni funzionalità con il suo test; un test automatico percorre tutta la soluzione dell'avventura. +- Le prove nel browser sono a sezioni (`scripts/verify.mjs`, dal 2026-11-01): `make verify` rifà solo quelle i cui ingressi + sono cambiati dall'ultimo verde (la briscola se cambia la briscola, la partita intera se cambiano storia, motore, stile + o risorse). Una prova nuova in `tests/*.browser.mjs` va aggiunta a `SUITES` (il test `verify.test.mjs` lo pretende). - Verifica visiva nel browser con Playwright e swiftshader prima di dichiarare finito qualcosa di grafico. - Pubblicare (tag/bump/push) solo quando Andrea lo chiede. - Persone vere nel gioco solo con consenso; niente nomi, personaggi o musiche di giochi esistenti. diff --git a/Makefile b/Makefile index a5d107a..910a014 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ DST ?= assets/scenes/$(notdir $(SRC)) CROP ?= COLORS ?= 64 -.PHONY: help serve open test browser verify check-public hooks bump voci voci-elenco sfondi sfondi-genera sfondi-confronto background character \ +.PHONY: help serve open test browser verify verify-browser verify-plan check-public hooks bump voci voci-elenco sfondi sfondi-genera sfondi-confronto background character \ build up down restart logs ps shell image-size deploy deploy-logs runner clean distclean ## ------------------------------------------------------------ sviluppo @@ -50,7 +50,15 @@ test: ## Test della logica, della storia, della soluzione completa e del generat 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 +verify: test check-public verify-browser ## Tutti i controlli: test unitari, file pubblicabili, prove nel browser cambiate (ALL=1: tutte) + +# Le prove nel browser a sezioni: si rifanno solo quelle i cui ingressi sono cambiati dall'ultimo verde (.verify-ok.json, +# fuori da git); make verify-browser ALL=1 le rifà tutte, make verify-plan dice solo cosa rifarebbe; JOBS=2 in parallelo (con 4 vanno in timeout). +verify-browser: ## Le prove nel browser da rifare (ALL=1 tutte; PROVE="briscola flipper" solo quelle) + $(NODE) scripts/verify.mjs $(if $(ALL),--all,) $(PROVE) + +verify-plan: ## Dice quali prove nel browser rifarebbe make verify, e perché + $(NODE) scripts/verify.mjs --plan check-public: ## Prima di pubblicare: niente prompt, fogli grezzi, segreti o nomi della rete di casa (tutta la storia) bash scripts/check-public.sh diff --git a/README.md b/README.md index 6745a37..39f4561 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Oppure `make up` (Docker, su http://localhost:8080). Non aprire `index.html` con - `docs/`: `PROGETTO.md` (l'idea, i personaggi, la mappa) e `STATO.md` (dove siamo, cosa manca). ```zsh -make verify # sintassi, test, prova nel browser: quello che gira prima di ogni versione +make verify # test, file pubblicabili e le prove nel browser cambiate dall'ultimo verde (ALL=1: tutte) make voci # le voci delle battute nuove (XTTS-v2 su GPU) make bump # nuova versione: verifica, commit e tag make deploy # pubblica la versione: immagine sul registry di Gitea, il server la scarica diff --git a/docs/STATO.md b/docs/STATO.md index bc67a73..7c38c6c 100644 --- a/docs/STATO.md +++ b/docs/STATO.md @@ -310,6 +310,13 @@ versione nuova. la frase è una riga sola coi puntini. «Schermo intero» nella Pausa (dove il browser lo permette). A 1040×590 la scena passa da 604 a 842 px di larghezza, a 1366×650 da 711 a 935; sui telefoni niente cambia. Prova in `browser.mjs`. +- **Prove a sezioni (1 novembre, idea di Andrea):** `scripts/verify.mjs` conosce gli ingressi di ogni prova nel browser + (i file da cui dipende: i propri, più il motore comune; per la partita intera tutto il gioco tranne i minigiochi a sé) + e rifà solo quelle la cui impronta è cambiata dall'ultimo verde, ricordato in `.verify-ok.json` fuori da git. Così + `make bump` dopo una modifica alla briscola riprova la briscola e basta; dopo una modifica alla storia la partita intera + e le scene speciali (ambient, veicoli, ingorgo, smarronata), non la briscola; dopo `style.css` o `game.js` tutto. + Le prove dei minigiochi, che prima non entravano in `make verify`, ora ci stanno. `make verify-plan` spiega. + ## Materiale ricevuto da Andrea (in `assets/raw/`, escluso da git) | File | Cosa | Uso previsto | diff --git a/scripts/verify.mjs b/scripts/verify.mjs new file mode 100644 index 0000000..98e7455 --- /dev/null +++ b/scripts/verify.mjs @@ -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(); diff --git a/tests/verify.test.mjs b/tests/verify.test.mjs new file mode 100644 index 0000000..872811a --- /dev/null +++ b/tests/verify.test.mjs @@ -0,0 +1,72 @@ +// Le prove nel browser a sezioni (scripts/verify.mjs): ogni file del gioco finisce negli ingressi delle prove giuste, +// e l'impronta cambia solo se cambia un ingresso. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readdirSync } from 'node:fs'; +import { SUITES, ENGINE, inputsOf, ignored, fingerprint, plan } from '../scripts/verify.mjs'; + +const FILES = ['index.html', 'style.css', 'src/main.js', 'src/version.js', 'package.json', 'package-lock.json', 'Makefile', 'README.md', + 'src/engine/game.js', 'src/engine/render.js', 'src/engine/ambient.js', 'src/engine/bocce.js', 'src/engine/nuovo.js', + 'src/engine/briscola.js', 'src/engine/briscola-render.js', 'src/engine/flipper.js', 'src/engine/corsa.js', 'src/story/piloti.js', + 'src/story/story.js', 'src/story/solution.js', 'assets/scenes/piazza.png', 'assets/closeups/micro-parcheggio.png', + 'assets/closeups/gp/boccino.png', 'assets/muro/index.html', 'assets/video/briscola.mp4', 'docs/BRISCOLA.md', 'docs/STATO.md', + 'tests/helpers.mjs', 'tests/browser.mjs', 'tests/briscola.browser.mjs', 'tests/briscola.test.mjs', 'tests/story.test.mjs', + 'scripts/verify.mjs', 'scripts/bump.sh', 'tools/slice_sprites.py', '.verify-ok.json']; +const suitesWith = path => Object.keys(SUITES).filter(n => inputsOf(n, FILES).includes(path)); +const islands = Object.keys(SUITES).filter(n => !SUITES[n].core), cores = Object.keys(SUITES).filter(n => SUITES[n].core); + +test('ogni prova nel browser di tests/ ha la sua voce, e una sola', () => { + const runs = Object.values(SUITES).map(s => s.run[0]); + for (const f of readdirSync('tests').filter(f => f.endsWith('.browser.mjs'))) { + if (f === 'multimedia.browser.mjs') continue; // la chiama la parte «telefoni» di browser.mjs + const n = runs.filter(r => r === `tests/${f}`).length; + assert.equal(n, f === 'browser.mjs' ? 3 : 1, `tests/${f} è in ${n} prove`); + } +}); + +test('la briscola si riprova solo se cambia la briscola o il motore', () => { + assert.deepEqual(suitesWith('src/engine/briscola.js'), ['briscola']); + assert.deepEqual(suitesWith('src/engine/briscola-render.js'), ['briscola']); + assert.deepEqual(suitesWith('assets/video/briscola.mp4'), ['briscola']); + assert.deepEqual(suitesWith('tests/briscola.browser.mjs'), ['briscola']); + assert.deepEqual(suitesWith('docs/BRISCOLA.md'), [], 'i documenti non fanno rifare niente'); + assert.ok(!inputsOf('briscola', FILES).includes('src/story/story.js'), 'la storia non fa riprovare la briscola'); + assert.deepEqual(suitesWith('src/engine/corsa.js'), ['gara']); + assert.deepEqual(suitesWith('src/story/piloti.js'), ['gara']); + assert.deepEqual(suitesWith('assets/closeups/gp/boccino.png'), ['gara']); + assert.deepEqual(suitesWith('assets/muro/index.html'), ['telefono']); + assert.deepEqual(suitesWith('assets/closeups/micro-parcheggio.png'), ['micro']); +}); + +test('la storia, le scene, i minigiochi dentro la partita e un modulo nuovo fanno rifare la partita intera, non i minigiochi a sé', () => { + for (const path of ['src/story/story.js', 'src/story/solution.js', 'assets/scenes/piazza.png', 'src/engine/bocce.js', 'src/engine/ambient.js', 'src/engine/nuovo.js', 'tests/browser.mjs']) { + assert.deepEqual(suitesWith(path), cores, path); + } +}); + +test('il motore comune fa rifare tutto', () => { + for (const path of ENGINE) assert.deepEqual(suitesWith(path), Object.keys(SUITES), path); + assert.ok(islands.length >= 10 && cores.length >= 3); +}); + +test('versione, package.json, Makefile, documenti, strumenti e test unitari non fanno rifare niente', () => { + for (const path of ['src/version.js', 'package.json', 'Makefile', 'README.md', 'docs/STATO.md', 'scripts/bump.sh', 'tools/slice_sprites.py', 'tests/briscola.test.mjs', 'tests/story.test.mjs', '.verify-ok.json']) { + assert.ok(ignored(path), `${path} dovrebbe essere ignorato`); + assert.deepEqual(suitesWith(path), [], path); + } +}); + +test("l'impronta cambia solo se cambia un ingresso, e il piano dice quali", () => { + const hashes = new Map(FILES.map(p => [p, 'h-' + p])); + const before = plan(hashes, {}); + assert.ok(before.every(p => !p.green), 'mai provate: tutte da rifare'); + const ok = Object.fromEntries(before.map(p => [p.name, { fingerprint: p.fp, hashes: Object.fromEntries(p.inputs.map(f => [f, hashes.get(f)])) }])); + assert.ok(plan(hashes, ok).every(p => p.green), 'niente cambiato: tutte verdi'); + hashes.set('docs/STATO.md', 'altro'); + assert.ok(plan(hashes, ok).every(p => p.green), 'un documento non cambia niente'); + hashes.set('src/engine/briscola.js', 'altro'); + const after = plan(hashes, ok); + assert.deepEqual(after.filter(p => !p.green).map(p => p.name), ['briscola']); + assert.deepEqual(after.find(p => p.name === 'briscola').changed, ['src/engine/briscola.js']); + assert.notEqual(fingerprint(['a'], () => '1'), fingerprint(['a'], () => '2')); +});