@tmustier/pi-nes 0.2.21 → 0.2.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,243 @@
1
+ /**
2
+ * ROM Regression Tests
3
+ *
4
+ * Runs scripted smoke tests against ROMs.
5
+ * Set NES_ROM_DIR to enable.
6
+ *
7
+ * Usage:
8
+ * NES_ROM_DIR=~/roms/nes npm run test:regression
9
+ */
10
+ import { test, describe } from "node:test";
11
+ import assert from "node:assert";
12
+ import { readFile, readdir } from "node:fs/promises";
13
+ import path from "node:path";
14
+ import { createNesCore, type NesCore, type NesButton } from "../extensions/nes/nes-core.js";
15
+ import { findScript, type GameScript, type ScriptAction } from "./game-scripts.js";
16
+
17
+ const ROM_DIR = process.env.NES_ROM_DIR;
18
+ const SKIP_REASON = "Set NES_ROM_DIR=/path/to/roms to run regression tests";
19
+
20
+ // Default test for ROMs without a script
21
+ const DEFAULT_FRAMES = 300;
22
+ const DEFAULT_POST_SEQUENCE_FRAMES = 60;
23
+
24
+ interface RomResult {
25
+ name: string;
26
+ loaded: boolean;
27
+ framesRun: number;
28
+ froze: boolean;
29
+ scriptUsed: boolean;
30
+ scriptDescription?: string;
31
+ error?: string;
32
+ }
33
+
34
+ function hashFramebuffer(data: Uint8Array): number {
35
+ let hash = 0;
36
+ for (let i = 0; i < data.length; i += 500) {
37
+ hash = ((hash << 5) - hash + data[i]) | 0;
38
+ }
39
+ return hash;
40
+ }
41
+
42
+ /**
43
+ * Execute a scripted input sequence
44
+ */
45
+ function executeScript(core: NesCore, script: GameScript): { framesRun: number; froze: boolean } {
46
+ let totalFrames = 0;
47
+ const heldButtons = new Set<NesButton>();
48
+
49
+ // Execute each action in sequence
50
+ for (const action of script.sequence) {
51
+ switch (action.type) {
52
+ case "wait":
53
+ for (let i = 0; i < action.frames; i++) {
54
+ core.tick();
55
+ totalFrames++;
56
+ }
57
+ break;
58
+
59
+ case "press": {
60
+ // Tap: press, run a few frames, release
61
+ const holdFrames = action.frames ?? 5;
62
+ core.setButton(action.button, true);
63
+ for (let i = 0; i < holdFrames; i++) {
64
+ core.tick();
65
+ totalFrames++;
66
+ }
67
+ core.setButton(action.button, false);
68
+ break;
69
+ }
70
+
71
+ case "hold":
72
+ core.setButton(action.button, true);
73
+ heldButtons.add(action.button);
74
+ for (let i = 0; i < action.frames; i++) {
75
+ core.tick();
76
+ totalFrames++;
77
+ }
78
+ core.setButton(action.button, false);
79
+ heldButtons.delete(action.button);
80
+ break;
81
+
82
+ case "release":
83
+ core.setButton(action.button, false);
84
+ heldButtons.delete(action.button);
85
+ break;
86
+ }
87
+ }
88
+
89
+ // Release any still-held buttons
90
+ for (const button of heldButtons) {
91
+ core.setButton(button, false);
92
+ }
93
+
94
+ // Run post-sequence frames and check for freeze
95
+ const postFrames = script.postSequenceFrames ?? DEFAULT_POST_SEQUENCE_FRAMES;
96
+ const hashes: number[] = [];
97
+
98
+ for (let i = 0; i < postFrames; i++) {
99
+ core.tick();
100
+ totalFrames++;
101
+
102
+ // Sample hash every 10 frames
103
+ if (i % 10 === 0) {
104
+ const fb = core.getFrameBuffer();
105
+ hashes.push(hashFramebuffer(fb.data));
106
+ }
107
+ }
108
+
109
+ // Check if frames changed during post-sequence
110
+ const uniqueHashes = new Set(hashes);
111
+ const froze = uniqueHashes.size <= 1;
112
+
113
+ return { framesRun: totalFrames, froze };
114
+ }
115
+
116
+ /**
117
+ * Run default test (no script) - just run frames and check for freeze
118
+ */
119
+ function executeDefault(core: NesCore): { framesRun: number; froze: boolean } {
120
+ let lastHash = 0;
121
+ let sameHashCount = 0;
122
+ let froze = false;
123
+
124
+ for (let i = 0; i < DEFAULT_FRAMES; i++) {
125
+ core.tick();
126
+
127
+ // Check for freeze every 30 frames
128
+ if (i % 30 === 0) {
129
+ const fb = core.getFrameBuffer();
130
+ const hash = hashFramebuffer(fb.data);
131
+ if (hash === lastHash) {
132
+ sameHashCount++;
133
+ if (sameHashCount >= 4) {
134
+ froze = true;
135
+ // Don't break - continue to verify no crash
136
+ }
137
+ } else {
138
+ sameHashCount = 0;
139
+ }
140
+ lastHash = hash;
141
+ }
142
+ }
143
+
144
+ return { framesRun: DEFAULT_FRAMES, froze };
145
+ }
146
+
147
+ async function testRom(romPath: string): Promise<RomResult> {
148
+ const filename = path.basename(romPath);
149
+ const name = filename.replace(/\.nes$/i, "");
150
+ const result: RomResult = { name, loaded: false, framesRun: 0, froze: false, scriptUsed: false };
151
+
152
+ let core: NesCore | null = null;
153
+ try {
154
+ const romData = new Uint8Array(await readFile(romPath));
155
+ core = createNesCore();
156
+ core.loadRom(romData);
157
+ result.loaded = true;
158
+
159
+ const script = findScript(filename);
160
+ if (script) {
161
+ result.scriptUsed = true;
162
+ result.scriptDescription = script.description;
163
+ const { framesRun, froze } = executeScript(core, script);
164
+ result.framesRun = framesRun;
165
+ result.froze = froze;
166
+ } else {
167
+ const { framesRun, froze } = executeDefault(core);
168
+ result.framesRun = framesRun;
169
+ result.froze = froze;
170
+ }
171
+ } catch (err) {
172
+ result.error = err instanceof Error ? err.message : String(err);
173
+ } finally {
174
+ core?.dispose();
175
+ }
176
+
177
+ return result;
178
+ }
179
+
180
+ describe("regression", { skip: !ROM_DIR ? SKIP_REASON : undefined }, () => {
181
+ test("all ROMs load and run without crashing", async () => {
182
+ if (!ROM_DIR) return;
183
+
184
+ const entries = await readdir(ROM_DIR, { withFileTypes: true });
185
+ const romFiles = entries
186
+ .filter(e => e.isFile() && e.name.toLowerCase().endsWith(".nes"))
187
+ .map(e => path.join(ROM_DIR, e.name));
188
+
189
+ if (romFiles.length === 0) {
190
+ console.log(`No .nes files found in ${ROM_DIR}`);
191
+ return;
192
+ }
193
+
194
+ console.log(`\nTesting ${romFiles.length} ROMs from ${ROM_DIR}\n`);
195
+
196
+ const results: RomResult[] = [];
197
+ for (const romPath of romFiles) {
198
+ const result = await testRom(romPath);
199
+ results.push(result);
200
+
201
+ const status = result.error ? "❌ ERROR"
202
+ : result.froze ? "⚠️ FROZE"
203
+ : "✅ OK";
204
+
205
+ const scriptInfo = result.scriptUsed ? " [scripted]" : "";
206
+ console.log(` ${status} ${result.name}${scriptInfo} (${result.framesRun} frames)`);
207
+
208
+ if (result.scriptDescription) {
209
+ console.log(` Script: ${result.scriptDescription}`);
210
+ }
211
+ if (result.error) {
212
+ console.log(` Error: ${result.error}`);
213
+ }
214
+ if (result.froze && result.scriptUsed) {
215
+ console.log(` ⚠️ Game appears frozen after scripted input - possible sprite/rendering issue`);
216
+ }
217
+ }
218
+
219
+ const failed = results.filter(r => r.error);
220
+ const frozen = results.filter(r => r.froze && !r.error);
221
+ const passed = results.filter(r => !r.error && !r.froze);
222
+
223
+ console.log(`\n${"─".repeat(60)}`);
224
+ console.log(`Summary: ${passed.length} passed, ${frozen.length} frozen, ${failed.length} failed`);
225
+
226
+ if (frozen.length > 0) {
227
+ console.log(`\nFrozen games (may indicate rendering issues):`);
228
+ for (const r of frozen) {
229
+ console.log(` - ${r.name}${r.scriptUsed ? " (scripted)" : ""}`);
230
+ }
231
+ }
232
+ console.log();
233
+
234
+ // Fail if any ROM crashes on load or during execution
235
+ assert.strictEqual(failed.length, 0,
236
+ `${failed.length} ROMs failed: ${failed.map(r => r.name).join(", ")}`);
237
+
238
+ // Also fail if scripted games freeze (they should work after input)
239
+ const scriptedFrozen = frozen.filter(r => r.scriptUsed);
240
+ assert.strictEqual(scriptedFrozen.length, 0,
241
+ `${scriptedFrozen.length} scripted games froze (possible rendering bug): ${scriptedFrozen.map(r => r.name).join(", ")}`);
242
+ });
243
+ });
@@ -0,0 +1,27 @@
1
+ import { test, describe } from "node:test";
2
+ import assert from "node:assert";
3
+ import { getRomDisplayName } from "../extensions/nes/roms.js";
4
+
5
+ describe("roms", () => {
6
+ describe("getRomDisplayName", () => {
7
+ test("strips .nes extension", () => {
8
+ assert.strictEqual(getRomDisplayName("/path/to/Super Mario Bros.nes"), "Super Mario Bros");
9
+ });
10
+
11
+ test("strips .NES extension (uppercase)", () => {
12
+ assert.strictEqual(getRomDisplayName("/roms/ZELDA.NES"), "ZELDA");
13
+ });
14
+
15
+ test("handles paths with multiple dots", () => {
16
+ assert.strictEqual(getRomDisplayName("/roms/Game v1.0.nes"), "Game v1.0");
17
+ });
18
+
19
+ test("handles simple filename", () => {
20
+ assert.strictEqual(getRomDisplayName("game.nes"), "game");
21
+ });
22
+
23
+ test("handles path with spaces", () => {
24
+ assert.strictEqual(getRomDisplayName("/my roms/Legend of Zelda.nes"), "Legend of Zelda");
25
+ });
26
+ });
27
+ });
@@ -0,0 +1,32 @@
1
+ import { test, describe } from "node:test";
2
+ import assert from "node:assert";
3
+ import path from "node:path";
4
+ import { getSavePath } from "../extensions/nes/saves.js";
5
+
6
+ describe("saves", () => {
7
+ describe("getSavePath", () => {
8
+ test("creates save path with rom name and hash", () => {
9
+ const result = getSavePath("/saves", "/roms/Zelda.nes");
10
+ // Should be /saves/Zelda-<hash>.sav
11
+ assert.ok(result.startsWith("/saves/Zelda-"), `Expected path to start with /saves/Zelda-, got: ${result}`);
12
+ assert.ok(result.endsWith(".sav"), `Expected path to end with .sav, got: ${result}`);
13
+ });
14
+
15
+ test("different rom paths produce different hashes", () => {
16
+ const save1 = getSavePath("/saves", "/roms/Zelda.nes");
17
+ const save2 = getSavePath("/saves", "/other/Zelda.nes");
18
+ assert.notStrictEqual(save1, save2, "Same ROM name from different paths should have different hashes");
19
+ });
20
+
21
+ test("same rom path produces consistent hash", () => {
22
+ const save1 = getSavePath("/saves", "/roms/Zelda.nes");
23
+ const save2 = getSavePath("/saves", "/roms/Zelda.nes");
24
+ assert.strictEqual(save1, save2, "Same ROM path should produce same save path");
25
+ });
26
+
27
+ test("uses provided save directory", () => {
28
+ const result = getSavePath("/custom/saves", "/roms/Game.nes");
29
+ assert.ok(result.startsWith("/custom/saves/"), `Expected custom save dir, got: ${result}`);
30
+ });
31
+ });
32
+ });