@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,96 @@
1
+ import { test, describe } from "node:test";
2
+ import assert from "node:assert";
3
+ import { normalizeConfig, DEFAULT_CONFIG, formatConfig } from "../extensions/nes/config.js";
4
+
5
+ describe("config", () => {
6
+ describe("normalizeConfig", () => {
7
+ test("returns defaults for empty object", () => {
8
+ const config = normalizeConfig({});
9
+ assert.strictEqual(config.enableAudio, DEFAULT_CONFIG.enableAudio);
10
+ assert.strictEqual(config.renderer, DEFAULT_CONFIG.renderer);
11
+ assert.strictEqual(config.imageQuality, DEFAULT_CONFIG.imageQuality);
12
+ assert.strictEqual(config.pixelScale, DEFAULT_CONFIG.pixelScale);
13
+ });
14
+
15
+ test("returns defaults for null", () => {
16
+ const config = normalizeConfig(null);
17
+ assert.strictEqual(config.renderer, "image");
18
+ });
19
+
20
+ test("returns defaults for non-object", () => {
21
+ const config = normalizeConfig("invalid");
22
+ assert.strictEqual(config.renderer, "image");
23
+ });
24
+
25
+ test("accepts valid renderer value", () => {
26
+ const config = normalizeConfig({ renderer: "text" });
27
+ assert.strictEqual(config.renderer, "text");
28
+ });
29
+
30
+ test("defaults invalid renderer to image", () => {
31
+ const config = normalizeConfig({ renderer: "invalid" });
32
+ assert.strictEqual(config.renderer, "image");
33
+ });
34
+
35
+ test("accepts valid imageQuality", () => {
36
+ const config = normalizeConfig({ imageQuality: "high" });
37
+ assert.strictEqual(config.imageQuality, "high");
38
+ });
39
+
40
+ test("defaults invalid imageQuality to balanced", () => {
41
+ const config = normalizeConfig({ imageQuality: "ultra" });
42
+ assert.strictEqual(config.imageQuality, "balanced");
43
+ });
44
+
45
+ test("clamps pixelScale to valid range", () => {
46
+ assert.strictEqual(normalizeConfig({ pixelScale: 0.1 }).pixelScale, 0.5);
47
+ assert.strictEqual(normalizeConfig({ pixelScale: 10 }).pixelScale, 4);
48
+ assert.strictEqual(normalizeConfig({ pixelScale: 2 }).pixelScale, 2);
49
+ });
50
+
51
+ test("handles NaN pixelScale", () => {
52
+ const config = normalizeConfig({ pixelScale: NaN });
53
+ assert.strictEqual(config.pixelScale, DEFAULT_CONFIG.pixelScale);
54
+ });
55
+
56
+ test("preserves valid keybindings", () => {
57
+ const config = normalizeConfig({
58
+ keybindings: { a: ["k", "l"] }
59
+ });
60
+ assert.deepStrictEqual(config.keybindings.a, ["k", "l"]);
61
+ // Other keys should have defaults
62
+ assert.ok(config.keybindings.up.length > 0);
63
+ });
64
+
65
+ test("ignores invalid keybinding values", () => {
66
+ const config = normalizeConfig({
67
+ keybindings: { a: "not-an-array" }
68
+ });
69
+ // Should fall back to default
70
+ assert.deepStrictEqual(config.keybindings.a, DEFAULT_CONFIG.keybindings.a);
71
+ });
72
+
73
+ test("accepts boolean enableAudio", () => {
74
+ assert.strictEqual(normalizeConfig({ enableAudio: true }).enableAudio, true);
75
+ assert.strictEqual(normalizeConfig({ enableAudio: false }).enableAudio, false);
76
+ });
77
+
78
+ test("defaults non-boolean enableAudio", () => {
79
+ const config = normalizeConfig({ enableAudio: "yes" });
80
+ assert.strictEqual(config.enableAudio, DEFAULT_CONFIG.enableAudio);
81
+ });
82
+ });
83
+
84
+ describe("formatConfig", () => {
85
+ test("produces valid JSON", () => {
86
+ const json = formatConfig(DEFAULT_CONFIG);
87
+ const parsed = JSON.parse(json);
88
+ assert.strictEqual(parsed.renderer, DEFAULT_CONFIG.renderer);
89
+ });
90
+
91
+ test("is pretty-printed", () => {
92
+ const json = formatConfig(DEFAULT_CONFIG);
93
+ assert.ok(json.includes("\n"), "Should be multi-line");
94
+ });
95
+ });
96
+ });
@@ -0,0 +1,124 @@
1
+ import { test, describe, before, after } from "node:test";
2
+ import assert from "node:assert";
3
+ import { readFile } from "node:fs/promises";
4
+ import { createNesCore, type NesCore } from "../extensions/nes/nes-core.js";
5
+
6
+ const TEST_ROM = process.env.NES_TEST_ROM;
7
+ const SKIP_REASON = "Set NES_TEST_ROM=/path/to/rom.nes to run core tests";
8
+
9
+ function hashFramebuffer(data: Uint8Array): number {
10
+ let hash = 0;
11
+ // Sample every 1000th byte for speed
12
+ for (let i = 0; i < data.length; i += 1000) {
13
+ hash = ((hash << 5) - hash + data[i]) | 0;
14
+ }
15
+ return hash;
16
+ }
17
+
18
+ describe("core-smoke", { skip: !TEST_ROM ? SKIP_REASON : undefined }, () => {
19
+ let core: NesCore;
20
+ let romData: Uint8Array;
21
+
22
+ before(async () => {
23
+ if (!TEST_ROM) return;
24
+ romData = new Uint8Array(await readFile(TEST_ROM));
25
+ });
26
+
27
+ after(() => {
28
+ core?.dispose();
29
+ });
30
+
31
+ test("native core is available", () => {
32
+ // This will throw if native module not built
33
+ core = createNesCore();
34
+ assert.ok(core, "Core should be created");
35
+ });
36
+
37
+ test("loads ROM without crashing", () => {
38
+ core.loadRom(romData);
39
+ // If we get here, it loaded
40
+ assert.ok(true);
41
+ });
42
+
43
+ test("runs 60 frames without crashing", () => {
44
+ for (let i = 0; i < 60; i++) {
45
+ core.tick();
46
+ }
47
+ assert.ok(true);
48
+ });
49
+
50
+ test("framebuffer contains data", () => {
51
+ const fb = core.getFrameBuffer();
52
+ assert.ok(fb.data.length > 0, "Framebuffer should have data");
53
+ // Check it's not all zeros (at least some pixels rendered)
54
+ const hasNonZero = fb.data.some(b => b !== 0);
55
+ assert.ok(hasNonZero, "Framebuffer should have non-zero pixels");
56
+ });
57
+
58
+ test("frames change over time (not frozen)", () => {
59
+ const hashes: number[] = [];
60
+ for (let i = 0; i < 30; i++) {
61
+ core.tick();
62
+ const fb = core.getFrameBuffer();
63
+ hashes.push(hashFramebuffer(fb.data));
64
+ }
65
+ const uniqueHashes = new Set(hashes);
66
+ // Should have at least a few different frames
67
+ assert.ok(uniqueHashes.size > 1, `Expected frame changes, got ${uniqueHashes.size} unique frames out of 30`);
68
+ });
69
+
70
+ test("button input does not crash", () => {
71
+ core.setButton("a", true);
72
+ core.tick();
73
+ core.setButton("a", false);
74
+ core.setButton("start", true);
75
+ core.tick();
76
+ core.setButton("start", false);
77
+ core.tick();
78
+ assert.ok(true);
79
+ });
80
+
81
+ // Note: reset() exists in native but isn't exposed via NesCore interface
82
+ });
83
+
84
+ describe("sram-roundtrip", { skip: !TEST_ROM ? SKIP_REASON : undefined }, () => {
85
+ test("SRAM save and restore", async () => {
86
+ if (!TEST_ROM) return;
87
+
88
+ const romData = new Uint8Array(await readFile(TEST_ROM));
89
+ const core = createNesCore();
90
+
91
+ try {
92
+ core.loadRom(romData);
93
+
94
+ // Run some frames
95
+ for (let i = 0; i < 60; i++) {
96
+ core.tick();
97
+ }
98
+
99
+ const sram = core.getSram();
100
+ if (!sram) {
101
+ // ROM doesn't have battery-backed SRAM, skip
102
+ return;
103
+ }
104
+
105
+ // Modify SRAM dirty state
106
+ assert.strictEqual(typeof core.isSramDirty(), "boolean");
107
+
108
+ // Create new core and restore SRAM
109
+ const core2 = createNesCore();
110
+ try {
111
+ core2.loadRom(romData);
112
+ core2.setSram(sram);
113
+
114
+ const restored = core2.getSram();
115
+ assert.ok(restored, "Should be able to get SRAM after setting");
116
+ assert.strictEqual(restored.length, sram.length, "SRAM length should match");
117
+ } finally {
118
+ core2.dispose();
119
+ }
120
+ } finally {
121
+ core.dispose();
122
+ }
123
+ });
124
+ });
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Debug script to visually inspect what a game looks like after scripted input
3
+ *
4
+ * Usage:
5
+ * npx tsx tests/debug-game.ts ~/roms/nes/Mario.nes
6
+ */
7
+ import { readFile } from "node:fs/promises";
8
+ import path from "node:path";
9
+ import { createNesCore, type NesCore, type NesButton } from "../extensions/nes/nes-core.js";
10
+ import { findScript, type GameScript } from "./game-scripts.js";
11
+
12
+ const FRAME_WIDTH = 256;
13
+ const FRAME_HEIGHT = 240;
14
+
15
+ function hashFramebuffer(data: Uint8Array): number {
16
+ let hash = 0;
17
+ for (let i = 0; i < data.length; i += 500) {
18
+ hash = ((hash << 5) - hash + data[i]) | 0;
19
+ }
20
+ return hash;
21
+ }
22
+
23
+ function analyzeFrame(data: Uint8Array): { nonZeroPixels: number; uniqueColors: number; brightness: number } {
24
+ let nonZeroPixels = 0;
25
+ let totalBrightness = 0;
26
+ const colors = new Set<number>();
27
+
28
+ for (let i = 0; i < data.length; i += 3) {
29
+ const r = data[i];
30
+ const g = data[i + 1];
31
+ const b = data[i + 2];
32
+
33
+ if (r !== 0 || g !== 0 || b !== 0) {
34
+ nonZeroPixels++;
35
+ }
36
+
37
+ const color = (r << 16) | (g << 8) | b;
38
+ colors.add(color);
39
+ totalBrightness += (r + g + b) / 3;
40
+ }
41
+
42
+ return {
43
+ nonZeroPixels,
44
+ uniqueColors: colors.size,
45
+ brightness: totalBrightness / (FRAME_WIDTH * FRAME_HEIGHT),
46
+ };
47
+ }
48
+
49
+ function renderFrameAscii(data: Uint8Array, width: number = 64, height: number = 24): string[] {
50
+ const lines: string[] = [];
51
+ const scaleX = FRAME_WIDTH / width;
52
+ const scaleY = FRAME_HEIGHT / height;
53
+ const chars = " .:-=+*#%@";
54
+
55
+ for (let y = 0; y < height; y++) {
56
+ let line = "";
57
+ for (let x = 0; x < width; x++) {
58
+ const srcX = Math.floor(x * scaleX);
59
+ const srcY = Math.floor(y * scaleY);
60
+ const idx = (srcY * FRAME_WIDTH + srcX) * 3;
61
+ const r = data[idx] || 0;
62
+ const g = data[idx + 1] || 0;
63
+ const b = data[idx + 2] || 0;
64
+ const brightness = (r + g + b) / 3 / 255;
65
+ const charIdx = Math.min(chars.length - 1, Math.floor(brightness * chars.length));
66
+ line += chars[charIdx];
67
+ }
68
+ lines.push(line);
69
+ }
70
+ return lines;
71
+ }
72
+
73
+ async function debugGame(romPath: string) {
74
+ const filename = path.basename(romPath);
75
+ console.log(`\n${"=".repeat(70)}`);
76
+ console.log(`Debugging: ${filename}`);
77
+ console.log(`${"=".repeat(70)}\n`);
78
+
79
+ const romData = new Uint8Array(await readFile(romPath));
80
+ const core = createNesCore();
81
+
82
+ try {
83
+ core.loadRom(romData);
84
+ console.log("✅ ROM loaded successfully\n");
85
+
86
+ const script = findScript(filename);
87
+ if (!script) {
88
+ console.log("⚠️ No script found for this ROM, running default frames\n");
89
+ for (let i = 0; i < 300; i++) {
90
+ core.tick();
91
+ }
92
+ } else {
93
+ console.log(`📜 Script: ${script.description}\n`);
94
+ console.log("Executing script...\n");
95
+
96
+ let totalFrames = 0;
97
+ for (const action of script.sequence) {
98
+ switch (action.type) {
99
+ case "wait":
100
+ console.log(` ⏳ Wait ${action.frames} frames (${(action.frames / 60).toFixed(1)}s)`);
101
+ for (let i = 0; i < action.frames; i++) {
102
+ core.tick();
103
+ totalFrames++;
104
+ }
105
+ break;
106
+ case "press":
107
+ console.log(` 🎮 Press ${action.button}`);
108
+ core.setButton(action.button, true);
109
+ for (let i = 0; i < (action.frames ?? 5); i++) {
110
+ core.tick();
111
+ totalFrames++;
112
+ }
113
+ core.setButton(action.button, false);
114
+ break;
115
+ case "hold":
116
+ console.log(` 🎮 Hold ${action.button} for ${action.frames} frames`);
117
+ core.setButton(action.button, true);
118
+ for (let i = 0; i < action.frames; i++) {
119
+ core.tick();
120
+ totalFrames++;
121
+ }
122
+ core.setButton(action.button, false);
123
+ break;
124
+ case "release":
125
+ console.log(` 🎮 Release ${action.button}`);
126
+ core.setButton(action.button, false);
127
+ break;
128
+ }
129
+ }
130
+ console.log(`\n✅ Script complete, ran ${totalFrames} frames\n`);
131
+ }
132
+
133
+ // Analyze current frame
134
+ const fb = core.getFrameBuffer();
135
+ const analysis = analyzeFrame(fb.data);
136
+
137
+ console.log("Frame Analysis (after script):");
138
+ console.log(` Non-zero pixels: ${analysis.nonZeroPixels.toLocaleString()} / ${(FRAME_WIDTH * FRAME_HEIGHT).toLocaleString()} (${(analysis.nonZeroPixels / (FRAME_WIDTH * FRAME_HEIGHT) * 100).toFixed(1)}%)`);
139
+ console.log(` Unique colors: ${analysis.uniqueColors}`);
140
+ console.log(` Average brightness: ${analysis.brightness.toFixed(1)}`);
141
+ console.log(` Frame hash: ${hashFramebuffer(fb.data)}`);
142
+
143
+ console.log("\nASCII Preview:");
144
+ console.log("┌" + "─".repeat(64) + "┐");
145
+ for (const line of renderFrameAscii(fb.data)) {
146
+ console.log("│" + line + "│");
147
+ }
148
+ console.log("└" + "─".repeat(64) + "┘");
149
+
150
+ // Run more frames and check for changes
151
+ console.log("\nRunning 60 more frames to check for animation...\n");
152
+ const hashes: number[] = [hashFramebuffer(fb.data)];
153
+
154
+ for (let i = 0; i < 60; i++) {
155
+ core.tick();
156
+ if (i % 10 === 0) {
157
+ const currentFb = core.getFrameBuffer();
158
+ hashes.push(hashFramebuffer(currentFb.data));
159
+ }
160
+ }
161
+
162
+ const uniqueHashes = new Set(hashes);
163
+ console.log(`Frame hashes sampled: ${hashes.length}`);
164
+ console.log(`Unique hashes: ${uniqueHashes.size}`);
165
+ console.log(`Hashes: ${hashes.join(", ")}`);
166
+
167
+ if (uniqueHashes.size <= 1) {
168
+ console.log("\n⚠️ FROZEN: No frame changes detected!");
169
+ } else {
170
+ console.log("\n✅ ANIMATED: Frames are changing");
171
+ }
172
+
173
+ // Final frame
174
+ const finalFb = core.getFrameBuffer();
175
+ const finalAnalysis = analyzeFrame(finalFb.data);
176
+
177
+ console.log("\nFinal Frame Analysis:");
178
+ console.log(` Non-zero pixels: ${finalAnalysis.nonZeroPixels.toLocaleString()} (${(finalAnalysis.nonZeroPixels / (FRAME_WIDTH * FRAME_HEIGHT) * 100).toFixed(1)}%)`);
179
+ console.log(` Unique colors: ${finalAnalysis.uniqueColors}`);
180
+
181
+ console.log("\nFinal ASCII Preview:");
182
+ console.log("┌" + "─".repeat(64) + "┐");
183
+ for (const line of renderFrameAscii(finalFb.data)) {
184
+ console.log("│" + line + "│");
185
+ }
186
+ console.log("└" + "─".repeat(64) + "┘");
187
+
188
+ } finally {
189
+ core.dispose();
190
+ }
191
+ }
192
+
193
+ // Main
194
+ const romPath = process.argv[2];
195
+ if (!romPath) {
196
+ console.error("Usage: npx tsx tests/debug-game.ts <rom-path>");
197
+ process.exit(1);
198
+ }
199
+
200
+ debugGame(romPath).catch(err => {
201
+ console.error("Error:", err);
202
+ process.exit(1);
203
+ });
@@ -0,0 +1,123 @@
1
+ import type { NesButton } from "../extensions/nes/nes-core.js";
2
+
3
+ /**
4
+ * Input action in a game script
5
+ */
6
+ export type ScriptAction =
7
+ | { type: "wait"; frames: number }
8
+ | { type: "press"; button: NesButton; frames?: number } // tap (default 5 frames)
9
+ | { type: "hold"; button: NesButton; frames: number } // hold for duration
10
+ | { type: "release"; button: NesButton };
11
+
12
+ /**
13
+ * Script for testing a specific game
14
+ */
15
+ export interface GameScript {
16
+ /** Input sequence to execute */
17
+ sequence: ScriptAction[];
18
+ /** Description of what the script does */
19
+ description: string;
20
+ /** Frames to run after sequence to check for freeze (default 60) */
21
+ postSequenceFrames?: number;
22
+ }
23
+
24
+ // Helper to convert seconds to frames (at 60fps)
25
+ const sec = (s: number) => Math.round(s * 60);
26
+
27
+ /**
28
+ * Game-specific test scripts
29
+ * Keys should match ROM filename (without extension, case-insensitive)
30
+ */
31
+ export const GAME_SCRIPTS: Record<string, GameScript> = {
32
+ "dragon quest iii": {
33
+ description: "Wait for title, press Start, wait for game to load",
34
+ sequence: [
35
+ { type: "wait", frames: sec(3) }, // Wait for title screen
36
+ { type: "press", button: "start" }, // Press Start
37
+ { type: "wait", frames: sec(3) }, // Wait for game to load
38
+ ],
39
+ postSequenceFrames: 60,
40
+ },
41
+
42
+ "super mario bros": {
43
+ description: "Start game, move Mario right to verify sprites render",
44
+ sequence: [
45
+ { type: "wait", frames: sec(2) }, // Wait for title
46
+ { type: "press", button: "start" }, // Press Start (1 player game)
47
+ { type: "wait", frames: sec(3) }, // Wait for level to load
48
+ { type: "hold", button: "right", frames: 30 }, // Move right
49
+ { type: "wait", frames: 10 },
50
+ { type: "hold", button: "right", frames: 30 }, // Move right again
51
+ { type: "wait", frames: 10 },
52
+ { type: "hold", button: "right", frames: 30 }, // Move right again
53
+ ],
54
+ postSequenceFrames: 60,
55
+ },
56
+
57
+ // Multi-cart version
58
+ "super mario bros. + duck hunt + world class track meet (usa) (rev 1)": {
59
+ description: "Select SMB from menu, start game, move Mario",
60
+ sequence: [
61
+ { type: "wait", frames: sec(2) }, // Wait for menu
62
+ { type: "press", button: "start" }, // Select first game (SMB)
63
+ { type: "wait", frames: sec(2) }, // Wait for SMB title
64
+ { type: "press", button: "start" }, // Start game
65
+ { type: "wait", frames: sec(3) }, // Wait for level
66
+ { type: "hold", button: "right", frames: 30 },
67
+ { type: "wait", frames: 10 },
68
+ { type: "hold", button: "right", frames: 30 },
69
+ ],
70
+ postSequenceFrames: 60,
71
+ },
72
+
73
+ "legend of zelda": {
74
+ description: "Start game, select file, begin playing",
75
+ sequence: [
76
+ { type: "wait", frames: sec(3) }, // Wait for title
77
+ { type: "press", button: "start" }, // Press Start
78
+ { type: "wait", frames: sec(1) }, // Wait for file select
79
+ { type: "press", button: "start" }, // Select first file
80
+ { type: "wait", frames: sec(2) }, // Wait for game
81
+ { type: "hold", button: "up", frames: 20 }, // Move up
82
+ { type: "wait", frames: 10 },
83
+ { type: "hold", button: "down", frames: 20 }, // Move down
84
+ ],
85
+ postSequenceFrames: 60,
86
+ },
87
+
88
+ "metroid": {
89
+ description: "Start game, move Samus",
90
+ sequence: [
91
+ { type: "wait", frames: sec(3) }, // Wait for title
92
+ { type: "press", button: "start" }, // Press Start
93
+ { type: "wait", frames: sec(2) }, // Wait for game
94
+ { type: "hold", button: "right", frames: 30 }, // Move right
95
+ { type: "wait", frames: 10 },
96
+ { type: "hold", button: "left", frames: 30 }, // Move left
97
+ ],
98
+ postSequenceFrames: 60,
99
+ },
100
+ };
101
+
102
+ /**
103
+ * Find a script for a ROM by filename
104
+ */
105
+ export function findScript(romFilename: string): GameScript | null {
106
+ const baseName = romFilename
107
+ .replace(/\.nes$/i, "")
108
+ .toLowerCase();
109
+
110
+ // Exact match first
111
+ if (GAME_SCRIPTS[baseName]) {
112
+ return GAME_SCRIPTS[baseName];
113
+ }
114
+
115
+ // Partial match (for ROMs with extra suffixes)
116
+ for (const [key, script] of Object.entries(GAME_SCRIPTS)) {
117
+ if (baseName.includes(key) || key.includes(baseName)) {
118
+ return script;
119
+ }
120
+ }
121
+
122
+ return null;
123
+ }
@@ -0,0 +1,46 @@
1
+ import { test, describe } from "node:test";
2
+ import assert from "node:assert";
3
+ import { getMappedButtons, DEFAULT_INPUT_MAPPING } from "../extensions/nes/input-map.js";
4
+
5
+ describe("input-map", () => {
6
+ describe("DEFAULT_INPUT_MAPPING", () => {
7
+ test("has all required buttons", () => {
8
+ const buttons = ["up", "down", "left", "right", "a", "b", "start", "select"];
9
+ for (const button of buttons) {
10
+ assert.ok(button in DEFAULT_INPUT_MAPPING, `Missing button: ${button}`);
11
+ assert.ok(Array.isArray(DEFAULT_INPUT_MAPPING[button as keyof typeof DEFAULT_INPUT_MAPPING]));
12
+ }
13
+ });
14
+ });
15
+
16
+ describe("getMappedButtons", () => {
17
+ test("maps 'z' to 'a' button", () => {
18
+ const buttons = getMappedButtons("z");
19
+ assert.ok(buttons.includes("a"), `Expected 'a' in ${JSON.stringify(buttons)}`);
20
+ });
21
+
22
+ test("maps 'x' to 'b' button", () => {
23
+ const buttons = getMappedButtons("x");
24
+ assert.ok(buttons.includes("b"), `Expected 'b' in ${JSON.stringify(buttons)}`);
25
+ });
26
+
27
+ test("maps 'w' to 'up' button", () => {
28
+ const buttons = getMappedButtons("w");
29
+ assert.ok(buttons.includes("up"), `Expected 'up' in ${JSON.stringify(buttons)}`);
30
+ });
31
+
32
+ test("returns empty for unmapped key", () => {
33
+ const buttons = getMappedButtons("q");
34
+ assert.strictEqual(buttons.length, 0);
35
+ });
36
+
37
+ test("uses custom mapping when provided", () => {
38
+ const customMapping = {
39
+ ...DEFAULT_INPUT_MAPPING,
40
+ a: ["k"],
41
+ };
42
+ const buttons = getMappedButtons("k", customMapping);
43
+ assert.ok(buttons.includes("a"));
44
+ });
45
+ });
46
+ });
@@ -0,0 +1,78 @@
1
+ import { test, describe } from "node:test";
2
+ import assert from "node:assert";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { displayPath, expandHomePath, normalizePath, resolvePathInput } from "../extensions/nes/paths.js";
6
+
7
+ const HOME = os.homedir();
8
+
9
+ describe("paths", () => {
10
+ describe("displayPath", () => {
11
+ test("replaces home directory with ~", () => {
12
+ assert.strictEqual(displayPath(`${HOME}/roms/nes`), "~/roms/nes");
13
+ });
14
+
15
+ test("leaves non-home paths unchanged", () => {
16
+ assert.strictEqual(displayPath("/usr/local/bin"), "/usr/local/bin");
17
+ });
18
+
19
+ test("handles home directory exactly", () => {
20
+ assert.strictEqual(displayPath(HOME), "~");
21
+ });
22
+ });
23
+
24
+ describe("expandHomePath", () => {
25
+ test("expands ~ to home directory", () => {
26
+ assert.strictEqual(expandHomePath("~"), HOME);
27
+ });
28
+
29
+ test("expands ~/path to home + path", () => {
30
+ assert.strictEqual(expandHomePath("~/roms/nes"), path.join(HOME, "roms/nes"));
31
+ });
32
+
33
+ test("leaves absolute paths unchanged", () => {
34
+ assert.strictEqual(expandHomePath("/usr/local"), "/usr/local");
35
+ });
36
+
37
+ test("leaves relative paths unchanged", () => {
38
+ assert.strictEqual(expandHomePath("roms/nes"), "roms/nes");
39
+ });
40
+ });
41
+
42
+ describe("normalizePath", () => {
43
+ test("returns fallback for empty string", () => {
44
+ assert.strictEqual(normalizePath("", "/default"), "/default");
45
+ });
46
+
47
+ test("returns fallback for whitespace-only", () => {
48
+ assert.strictEqual(normalizePath(" ", "/default"), "/default");
49
+ });
50
+
51
+ test("expands ~ and returns path", () => {
52
+ assert.strictEqual(normalizePath("~/roms", "/default"), path.join(HOME, "roms"));
53
+ });
54
+
55
+ test("trims whitespace", () => {
56
+ assert.strictEqual(normalizePath(" /roms ", "/default"), "/roms");
57
+ });
58
+ });
59
+
60
+ describe("resolvePathInput", () => {
61
+ test("returns cwd for empty input", () => {
62
+ assert.strictEqual(resolvePathInput("", "/home/user"), "/home/user");
63
+ });
64
+
65
+ test("expands ~ paths", () => {
66
+ assert.strictEqual(resolvePathInput("~/roms", "/cwd"), path.join(HOME, "roms"));
67
+ });
68
+
69
+ test("resolves relative paths against cwd", () => {
70
+ const result = resolvePathInput("roms/nes", "/home/user");
71
+ assert.strictEqual(result, "/home/user/roms/nes");
72
+ });
73
+
74
+ test("keeps absolute paths as-is", () => {
75
+ assert.strictEqual(resolvePathInput("/absolute/path", "/cwd"), "/absolute/path");
76
+ });
77
+ });
78
+ });