From ee55d344af050f6e042a2c0e1fe23af3c26f61e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20QUEROL?= Date: Fri, 2 Oct 2026 12:00:38 +0200 Subject: [PATCH] Add saves and board support for manual testing The board gains an "A tester" column for the checks that need a person: the project-board hook now reads it, so a commit that mentions one of its cards moves the card instead of adding it to a second column. A test that runs only with MAKE_TEST_SAVES=1 writes the backup those checks import, built from the local ROM and kept out of git. [no-card] Co-Authored-By: Claude Opus 5.5 --- docs/SESSION_HANDOFF.md | 22 +++++++++++ scripts/project-board.mjs | 9 ++++- tests/human-test-saves.test.ts | 69 ++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 tests/human-test-saves.test.ts diff --git a/docs/SESSION_HANDOFF.md b/docs/SESSION_HANDOFF.md index 2f74cd4..b7baae4 100644 --- a/docs/SESSION_HANDOFF.md +++ b/docs/SESSION_HANDOFF.md @@ -1017,3 +1017,25 @@ normal play yet (no Storm Badge, caves or further flypoints); Teleport works after a Pokémon Center visit for a Pokémon that knows it. Next: Headbutt and Rock Smash encounters (board #26). + +The increment was committed as `a7aceda` (`Closes #25`). Not pushed. + +## Manual test cards: 2026-10-02 + +Asked for the checks only a person can do. The board gained an "A tester" +column (between En cours and Fait) with #46 (Fly map, flight and landing), +#47 (Teleport and the Escape Rope outdoors), #48 (touch screens, part of +#40), #49 (screen reader, related to #38) and #50 (Dig and the Escape Rope +in Dark Cave, blocked by #31). `scripts/project-board.mjs` now also reads +that column, so a commit mentioning one of those cards moves it instead of +adding it to a second column. + +`tests/human-test-saves.test.ts` writes the backups the cards use, only with +`MAKE_TEST_SAVES=1` and the local ROM: `test-saves/fly-teleport.crystal-save.json` +(ignored by git, as it holds names read from the ROM). It reloads each save +in a fresh world before writing it; importing it through Savegames and using +Teleport then Fly was checked once in Chromium and WebKit. + +Not made into cards, because they can be automated: emulator comparisons +of the flight paths and of the Teleport/Dig warps through the reference +harness. diff --git a/scripts/project-board.mjs b/scripts/project-board.mjs index 0fe0dc4..d1385a8 100644 --- a/scripts/project-board.mjs +++ b/scripts/project-board.mjs @@ -5,7 +5,8 @@ // // A commit message mentioning "Closes #N" (also close/closed, fix/fixes/fixed, // resolve/resolves/resolved) moves card N to Fait and closes the issue. Any -// other "#N" moves card N to En cours. A commit on the default branch with no +// other "#N" moves card N to En cours, including from the optional "A tester" +// column of manual test cards. A commit on the default branch with no // reference creates a closed card in Fait from the commit message. Merges, // amends, rebases and messages containing [no-card] are skipped. On other // branches only referenced cards move, and only to En cours. @@ -21,6 +22,8 @@ import { execFileSync } from "node:child_process"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; const COLUMNS = { todo: "A faire", doing: "En cours", done: "Fait" }; +// Columns whose cards commits may move, but which the board may lack. +const OPTIONAL_COLUMNS = { test: "A tester" }; const CLOSING = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s+#(\d+)\b/gi; const REFERENCE = /(? c.title === title); + if (column) id[key] = column.id; + } const membership = new Map(); for (const [key, columnId] of Object.entries(id)) for (let page = 1; ; page++) { diff --git a/tests/human-test-saves.test.ts b/tests/human-test-saves.test.ts new file mode 100644 index 0000000..a49814d --- /dev/null +++ b/tests/human-test-saves.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { CrystalAssets } from "../src/game/rom/assets"; +import { GameWorld } from "../src/game/world/world"; +import { generatePartyMon } from "../src/game/party/pokemon"; +import { CRYSTAL_ROM } from "../src/rom/validate"; +import { encodeSave } from "../src/storage/saves"; +import { foundationFixture } from "./foundation-fixture"; + +// Backups for the manual checks on the project board's "A tester" column, +// built from the local ROM and imported through Savegames → Import native +// backup. They hold names read from the ROM, so they stay out of the +// repository (*.crystal-save.json is ignored). +// +// MAKE_TEST_SAVES=1 npx vitest run tests/human-test-saves.test.ts +const romPath = + process.env.CRYSTAL_ROM_PATH ?? + "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc"; +const OUT = "test-saves"; +/** NativeRuntime.id, which an imported backup must name. */ +const CORE_ID = "crystal-native-world-v1"; + +describe.skipIf(!existsSync(romPath) || !process.env.MAKE_TEST_SAVES)( + "saves for manual testing", + () => { + const assets = () => + new CrystalAssets(new Uint8Array(readFileSync(romPath))); + function write(name: string, world: GameWorld) { + const state = new TextEncoder().encode(JSON.stringify(world.save())); + // The backup must load as the app would load it. + new GameWorld(assets()).restore(JSON.parse(new TextDecoder().decode(state))); + mkdirSync(OUT, { recursive: true }); + writeFileSync( + `${OUT}/${name}.crystal-save.json`, + encodeSave({ + version: 1, + romHash: CRYSTAL_ROM.sha1, + coreId: CORE_ID, + savedAt: new Date().toISOString(), + state, + }), + ); + } + + it("writes fly-teleport: New Bark Town with Fly, Teleport and Escape Ropes", () => { + const a = assets(); + // Pidgeot with Fly, the Storm Badge, New Bark and Cherrygrove visited. + const world = foundationFixture(a, "fly"); + const abra = generatePartyMon(a.pokemon, { + species: 63, // ABRA + level: 20, + item: 0, + otId: world.playerId, + ot: "CHRIS", + dvs: 0x9abc, + caughtData: 0, + }); + abra.moves = [0x64, 0, 0, 0]; // TELEPORT + abra.pp = [20, 0, 0, 0]; + world.state.party.push(abra); + // Violet City too, whose map is not ported yet. + world.state.flags.add(a.spawnEngineFlag(16)!); + world.state.lastSpawnMap = "26:3"; // Cherrygrove City + world.state.bag.items = [{ item: 0x13, quantity: 3 }]; // ESCAPE ROPE + expect(world.mapId).toBe("24:4"); + write("fly-teleport", world); + }); + }, +);