@tmustier/pi-nes 0.2.22 → 0.2.24

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.
package/AGENTS.md CHANGED
@@ -36,6 +36,13 @@ pi-nes/
36
36
 
37
37
  The emulator uses the [`nes_rust`](https://crates.io/crates/nes_rust) crate (vendored + patched in `native/nes-core/vendor/nes_rust` for SRAM helpers) with [napi-rs](https://napi.rs) bindings.
38
38
 
39
+ ### Vendored `nes_rust` workflow
40
+
41
+ - Source of truth is the fork (intended): `https://github.com/tmustier/nes-rust`.
42
+ - Make changes in the fork first, then re-vendor via `scripts/update-vendor-nes-rust.sh`.
43
+ - Update `extensions/nes/native/nes-core/vendor/nes_rust/VENDOR.md` with the fork commit + date + patch summary.
44
+ - Keep TODO inventory in `extensions/nes/native/nes-core/vendor/nes_rust/TODO_INVENTORY.md`.
45
+
39
46
  **API exposed to JavaScript:**
40
47
  - `new NativeNes()` - Create emulator instance
41
48
  - `setRom(Uint8Array)` - Load ROM data
package/README.md CHANGED
@@ -106,6 +106,12 @@ Set `"renderer": "text"` if you prefer the ANSI renderer or have display issues.
106
106
  - **No audio** — Sound is not currently supported
107
107
  - **No save states** — Only battery-backed SRAM saves work
108
108
 
109
+ ## Vendored Dependencies
110
+
111
+ - `nes_rust` is vendored under `extensions/nes/native/nes-core/vendor/nes_rust`.
112
+ - Fork: https://github.com/tmustier/nes-rust (upstream: https://github.com/takahirox/nes-rust)
113
+ - Update helper: `scripts/update-vendor-nes-rust.sh`
114
+
109
115
  ---
110
116
 
111
117
  ## Building from Source
@@ -0,0 +1,60 @@
1
+ # nes_rust TODO Inventory (vendored)
2
+
3
+ This inventory tracks TODOs in the vendored `nes_rust` snapshot.
4
+
5
+ ## src/lib.rs
6
+ - L57: Audio buffer sample code T.B.D. (doc example). Change: replace TODO with note that audio output is omitted.
7
+
8
+ ## src/apu.rs
9
+ - L53: sample_period timing fix needed for audio accuracy.
10
+ - L67: APU reset behavior incomplete.
11
+ - L79: Sampling timing not precise.
12
+ - L93: Add note (DMC timer) — Delete: remove TODO unless a concrete note is added.
13
+ - L102: Frame sequencer timing not precise.
14
+ - L168: IRQ timing needs verification.
15
+ - L276: DMC CPU memory workaround is hacky; optional refactor.
16
+ - L400/612/775/949: Invalid register write handling — Change: document no-op and remove TODOs.
17
+ - L467: Sweep negation logic fix needed.
18
+ - L975: Remove DMC memory workaround; optional refactor.
19
+
20
+ ## src/default_audio.rs
21
+ - L29: Remove side effect in copy_sample_buffer (optional).
22
+ - L31: Replace magic number 4096 with constant.
23
+
24
+ ## src/ppu.rs
25
+ - L119: Data bus decay support (accuracy improvement).
26
+ - L322: Greyscale support comment — Delete: masking handled in load_palette (unless register-read behavior needed).
27
+ - L495: Pixel alignment off-by-one investigation.
28
+ - L594-L618: Missing cycle/subcycle fetch behavior.
29
+ - L660: Attribute fetch correctness.
30
+ - L690: Optional optimization.
31
+ - L758-L759: MMC3 IRQ timing/placement.
32
+ - L804/L830: Scroll updates conditional on rendering.
33
+ - L863: Optional optimization.
34
+ - L1020: Color emphasis implementation.
35
+ - L1069: PPU master/slave select — Change: document as ignored on NES, remove TODO.
36
+
37
+ ## src/register.rs
38
+ - L5: Combine Register<u8>/Register<u16> (optional refactor).
39
+
40
+ ## src/mapper.rs
41
+ - L45: MMC3 IRQ hook in trait (optional architecture cleanup).
42
+ - L149: MMC1 32KB banking fix needed.
43
+
44
+ ## src/cpu.rs
45
+ - L44: Unknown button mapping — Change: replace with unreachable! (exhaustive match).
46
+ - L242: Opcode table refactor (optional).
47
+ - L620: Page-cross cycle for ADC 0x71 needed.
48
+ - L1254/L1258: DMC sample handling simplification + stall timing fix.
49
+ - L1271: Frame update detection precision.
50
+ - L1285: Poweroff input handling.
51
+ - L1313: NMI vs IRQ ordering.
52
+ - L1368/1895: Cleanup notes — Delete: vague cleanup notes.
53
+ - L1417/1552/1557/1709/1716/1732/1738/1994: CPU logic correctness checks.
54
+ - L1531/L2116: Invalid instruction/addressing mode handling — Change: treat illegal opcodes as NOP placeholder and document unknown addressing mode fallback.
55
+ - L1909: DMA stall timing detail.
56
+ - L1952: Interrupt handling optimization (optional).
57
+
58
+ ## src/rom.rs
59
+ - L139: MMC3 IRQ in ROM (optional architecture cleanup).
60
+ - L145: Cache header fields (optional).
@@ -0,0 +1,24 @@
1
+ # Vendored Dependency: nes_rust
2
+
3
+ ## Upstream
4
+ - Repository: https://github.com/takahirox/nes-rust
5
+ - Upstream state: last known push (per GitHub API) 2020-08-28
6
+
7
+ ## Fork
8
+ - Repository: https://github.com/tmustier/nes-rust
9
+ - Purpose: carry project-specific fixes and act as upstream-of-record for this vendor copy.
10
+
11
+ ## Current Vendor Snapshot
12
+ - Source commit/tag: `28b6e9b` (fork master)
13
+ - Vendored on: 2026-02-02
14
+ - Local patch set: SRAM helpers, CHR RAM support, mapper fixes, PPU timing tweaks, debug hooks, palette index clamp.
15
+
16
+ ## Update Process
17
+ 1. Sync fork with upstream if needed.
18
+ 2. Apply/maintain project patches on the fork.
19
+ 3. Re-vendor from fork at a pinned commit/tag.
20
+ 4. Update this file with the new commit/tag + patch notes.
21
+
22
+ ## Notes
23
+ - This repo uses a vendored copy under `extensions/nes/native/nes-core/vendor/nes_rust`.
24
+ - Keep patch history in the fork so changes are auditable.
@@ -90,7 +90,6 @@ impl Apu {
90
90
  self.pulse1.drive_timer();
91
91
  self.pulse2.drive_timer();
92
92
  self.noise.drive_timer();
93
- // @TODO: Add note
94
93
  if self.dmc.drive_timer(dmc_sample_data) {
95
94
  self.dmc_irq_active = true;
96
95
  }
@@ -397,7 +396,7 @@ impl ApuPulse {
397
396
  self.timer_sequence = 0;
398
397
  self.envelope_start_flag = true;
399
398
  },
400
- _ => {} // @TODO: Throw an error?
399
+ _ => {} // Invalid register: no-op.
401
400
  };
402
401
  }
403
402
 
@@ -609,7 +608,7 @@ impl ApuTriangle {
609
608
 
610
609
  self.linear_reload_flag = true;
611
610
  },
612
- _ => {} // @TODO: Throw an error?
611
+ _ => {} // Invalid register: no-op.
613
612
  };
614
613
  }
615
614
 
@@ -772,7 +771,7 @@ impl ApuNoise {
772
771
 
773
772
  self.envelope_start_flag = true;
774
773
  },
775
- _ => {} // @TODO: Throw an error?
774
+ _ => {} // Invalid register: no-op.
776
775
  };
777
776
  }
778
777
 
@@ -946,7 +945,7 @@ impl ApuDmc {
946
945
  self.register3.store(value);
947
946
  self.remaining_bytes_counter = ((self.sample_length() as u16) << 4) | 1;
948
947
  },
949
- _ => {} // @TODO
948
+ _ => {} // Invalid register: no-op.
950
949
  }
951
950
  }
952
951
 
@@ -41,7 +41,7 @@ fn to_joypad_button(button: button::Button) -> joypad::Button {
41
41
  button::Button::Joypad2Right => joypad::Button::Right,
42
42
  button::Button::Start => joypad::Button::Start,
43
43
  button::Button::Select => joypad::Button::Select,
44
- _ => joypad::Button::A // dummy @TODO: Throw an error?
44
+ _ => unreachable!("Unhandled button mapping")
45
45
  }
46
46
  }
47
47
 
@@ -1365,7 +1365,6 @@ impl Cpu {
1365
1365
  }
1366
1366
  }
1367
1367
 
1368
- // @TODO: Clean up if needed
1369
1368
  fn operate(&mut self, op: &Operation) {
1370
1369
  match op.instruction_type {
1371
1370
  InstructionTypes::ADC => {
@@ -1528,7 +1527,7 @@ impl Cpu {
1528
1527
  self.update_z(result);
1529
1528
  },
1530
1529
  InstructionTypes::INV => {
1531
- // @TODO: Throw?
1530
+ // Illegal opcode placeholder (treated as NOP for now).
1532
1531
  println!("INV operation");
1533
1532
  },
1534
1533
  InstructionTypes::INX | InstructionTypes::INY => {
@@ -1892,8 +1891,6 @@ impl Cpu {
1892
1891
  self.apu.store_register(address, value);
1893
1892
  }
1894
1893
 
1895
- // @TODO: clean up
1896
-
1897
1894
  if address == 0x4014 {
1898
1895
  self.ppu.store_register(address, value, &mut self.rom);
1899
1896
 
@@ -2113,7 +2110,7 @@ impl Cpu {
2113
2110
  effective_address
2114
2111
  },
2115
2112
  _ => {
2116
- // @TODO: Throw?
2113
+ // Unknown addressing mode; returning 0 as a fallback.
2117
2114
  println!("Unknown addressing mode.");
2118
2115
  0
2119
2116
  }
@@ -54,7 +54,7 @@ use audio::Audio;
54
54
  /// nes.step_frame();
55
55
  /// nes.copy_pixels(rgba_pixels);
56
56
  /// // Render rgba_pixels
57
- /// // @TODO: Audio buffer sample code is T.B.D.
57
+ /// // Audio output omitted in this example.
58
58
  /// // Adjust sleep time for your platform
59
59
  /// std::thread::sleep(Duration::from_millis(1));
60
60
  /// }
@@ -271,6 +271,7 @@ impl Ppu {
271
271
  // ppustatus load
272
272
  0x2002 => {
273
273
  let value = self.ppustatus.load();
274
+ let was_vblank = (value & 0x80) != 0;
274
275
 
275
276
  // clear vblank after reading 0x2002
276
277
  self.ppustatus.clear_vblank();
@@ -287,10 +288,9 @@ impl Ppu {
287
288
  // clears the flag, and won't fire NMI
288
289
 
289
290
  // Note: update_flags() which can set vblank is called
290
- // after this method in the same cycle, so set supress_vblank true
291
- // even at cycle=1 not only cycle=0
292
-
293
- if self.scanline == 241 && (self.cycle == 0 || self.cycle == 1) {
291
+ // after this method in the same cycle. Only suppress if
292
+ // vblank was already set when read.
293
+ if was_vblank && self.scanline == 241 && self.cycle == 1 {
294
294
  self.suppress_vblank = true;
295
295
  }
296
296
 
@@ -319,8 +319,6 @@ impl Ppu {
319
319
  };
320
320
  self.vram_read_buffer = value;
321
321
 
322
- // @TODO: Support greyscale if needed
323
-
324
322
  // Accessing ppudata increments vram_address
325
323
  self.increment_vram_address();
326
324
  self.data_bus = return_value;
@@ -735,9 +733,13 @@ impl Ppu {
735
733
  fn update_flags(&mut self, rom: &mut Rom) {
736
734
  if self.cycle == 1 {
737
735
  if self.scanline == 241 {
738
- // set vblank and occur NMI at cycle 1 in scanline 241
736
+ // Set vblank and latch NMI at cycle 1 in scanline 241.
737
+ // NMI delivery is still delayed by CPU instruction timing.
739
738
  if !self.suppress_vblank {
740
739
  self.ppustatus.set_vblank();
740
+ if self.ppuctrl.is_nmi_enabled() {
741
+ self.nmi_interrupted = true;
742
+ }
741
743
  }
742
744
  self.suppress_vblank = false;
743
745
  // Pixels for this frame should be ready so update the display
@@ -751,41 +753,6 @@ impl Ppu {
751
753
  }
752
754
  }
753
755
 
754
- // According to http://wiki.nesdev.com/w/index.php/PPU_frame_timing#VBL_Flag_Timing
755
- // reading 0x2002 at cycle=2 and scanline=241 can suppress NMI
756
- // so firing NMI at some cycles away not at cycle=1 so far
757
-
758
- // There is a chance that CPU 0x2002 read gets the data vblank flag set
759
- // before CPU starts NMI interrupt routine.
760
- // CPU instructions take multiple CPU clocks to complete.
761
- // If CPU starts an operation of an istruction including 0x2002 read right before
762
- // PPU sets vblank flag and fires NMI,
763
- // the 0x2002 read gets the data with vblank flag set even before
764
- // CPU starts NMI routine.
765
- //
766
- // CPU PPU
767
- // 1. instruction operation start
768
- // 2. - doing something vblank start and fire NMI
769
- // 3. - read 0x2002 with
770
- // vblank flag set
771
- // 4. - doing something
772
- // 5. Notice NMI and start
773
- // NMI routine
774
- //
775
- // It seems some games rely on this behavior.
776
- // To simulate this behavior we fire NMI at cycle=20 so far.
777
- // If CPU reads 0x2002 between PPU cycle 3~20 it gets data
778
- // vblank flag set before NMI routine.
779
- // (reading at cycle 1~2 suppresses NMI, see load_register())
780
- // @TODO: Safer and more appropriate approach.
781
-
782
- if self.cycle == 20 && self.scanline == 241 {
783
- if self.ppustatus.is_vblank() &&
784
- self.ppuctrl.is_nmi_enabled() {
785
- self.nmi_interrupted = true;
786
- }
787
- }
788
-
789
756
  // @TODO: check this driving IRQ counter for MMC3Mapper timing is correct
790
757
  // @TODO: This is MMC3Mapper specific. Should this be here?
791
758
 
@@ -901,7 +868,7 @@ impl Ppu {
901
868
  // the PPU scans through OAM to determine which sprites
902
869
  // to render on the next scanline
903
870
 
904
- if self.scanline >= 240 {
871
+ if self.scanline >= 240 && self.scanline != 261 {
905
872
  return;
906
873
  }
907
874
 
@@ -917,16 +884,17 @@ impl Ppu {
917
884
  } else if self.cycle == 257 {
918
885
  // Evaluate at a time at cycle 257 due to performance
919
886
  // and simplicity so far
920
- self.process_sprite_pixels(rom);
887
+ let next_scanline = if self.scanline == 261 { 0 } else { self.scanline + 1 };
888
+ self.process_sprite_pixels(next_scanline, rom);
921
889
  }
922
890
  }
923
891
 
924
- fn process_sprite_pixels(&mut self, rom: &Rom) {
892
+ fn process_sprite_pixels(&mut self, scanline: u16, rom: &Rom) {
925
893
  for i in 0..self.sprite_availables.len() {
926
894
  self.sprite_availables[i] = false;
927
895
  }
928
896
 
929
- let y = self.scanline as u8;
897
+ let y = scanline as u8;
930
898
  let height = self.ppuctrl.sprite_height();
931
899
  let mut n = 0;
932
900
 
@@ -1041,7 +1009,7 @@ impl Ppu {
1041
1009
  // read from the grey column 0x00, 0x10, 0x20, or 0x30
1042
1010
  let mask = match self.ppumask.is_greyscale() {
1043
1011
  true => 0x30,
1044
- false => 0xFF
1012
+ false => 0x3F
1045
1013
  };
1046
1014
  PALETTES[(address & mask) as usize] & 0xFFFFFF
1047
1015
  }
@@ -1096,8 +1064,7 @@ impl PpuControlRegister {
1096
1064
  self.register.is_bit_set(7)
1097
1065
  }
1098
1066
 
1099
- // Bit 6. PPU master/slave select
1100
- // @TODO: Implement
1067
+ // Bit 6. PPU master/slave select (unused on NES; ignored)
1101
1068
 
1102
1069
  // Bit 5. Sprite height
1103
1070
  // -- 0: 8 (8x8 pixels), 1: 16 (8x16 pixels)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-nes",
3
- "version": "0.2.22",
3
+ "version": "0.2.24",
4
4
  "description": "NES emulator extension for pi",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -13,9 +13,19 @@
13
13
  "type": "git",
14
14
  "url": "https://github.com/tmustier/pi-nes.git"
15
15
  },
16
+ "scripts": {
17
+ "test": "node --import tsx --test tests/paths.test.ts tests/roms.test.ts tests/saves.test.ts tests/config.test.ts tests/input-map.test.ts",
18
+ "test:unit": "node --import tsx --test tests/paths.test.ts tests/roms.test.ts tests/saves.test.ts tests/config.test.ts tests/input-map.test.ts",
19
+ "test:core": "node --import tsx --test tests/core-smoke.test.ts",
20
+ "test:regression": "node --import tsx --test tests/regression.test.ts"
21
+ },
16
22
  "dependencies": {
17
23
  "pngjs": "^7.0.0"
18
24
  },
25
+ "devDependencies": {
26
+ "tsx": "^4.19.0",
27
+ "typescript": "^5.5.0"
28
+ },
19
29
  "peerDependencies": {
20
30
  "@mariozechner/pi-coding-agent": "*",
21
31
  "@mariozechner/pi-tui": "*"
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ FORK_URL="https://github.com/tmustier/nes-rust.git"
5
+ VENDOR_DIR="extensions/nes/native/nes-core/vendor/nes_rust"
6
+
7
+ if [[ -n "$(git status --porcelain)" ]]; then
8
+ echo "Working tree not clean. Commit or stash changes before updating vendor." >&2
9
+ exit 1
10
+ fi
11
+
12
+ TMP_DIR="$(mktemp -d -t nes-rust-vendor-XXXX)"
13
+ cleanup() {
14
+ rm -rf "$TMP_DIR"
15
+ }
16
+ trap cleanup EXIT
17
+
18
+ git clone --depth 1 "$FORK_URL" "$TMP_DIR"
19
+
20
+ echo "This will replace contents of $VENDOR_DIR with $FORK_URL"
21
+ read -r -p "Continue? [y/N] " confirm
22
+ if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
23
+ echo "Aborted."
24
+ exit 1
25
+ fi
26
+
27
+ rsync -a --delete --exclude ".git" "$TMP_DIR/" "$VENDOR_DIR/"
28
+
29
+ echo "Vendored from commit: $(git -C "$TMP_DIR" rev-parse HEAD)"
30
+ echo "Update $VENDOR_DIR/VENDOR.md with commit/tag + date + patch summary."
@@ -0,0 +1,122 @@
1
+ # Tests
2
+
3
+ ## Quick Start
4
+
5
+ ```bash
6
+ # Run unit tests (no ROMs needed)
7
+ npm test
8
+
9
+ # Run with a specific ROM
10
+ NES_TEST_ROM=~/roms/nes/Zelda.nes npm run test:core
11
+
12
+ # Run regression against all ROMs in a directory
13
+ NES_ROM_DIR=~/roms/nes npm run test:regression
14
+
15
+ # Debug a specific game (visual ASCII output)
16
+ npx tsx tests/debug-game.ts ~/roms/nes/Mario.nes
17
+ ```
18
+
19
+ ## Test Structure
20
+
21
+ ### Unit Tests (always run)
22
+
23
+ | File | What it tests |
24
+ |------|---------------|
25
+ | `paths.test.ts` | Path utilities (displayPath, expandHomePath, etc.) |
26
+ | `roms.test.ts` | ROM name parsing |
27
+ | `saves.test.ts` | Save path generation |
28
+ | `config.test.ts` | Config normalization and validation |
29
+ | `input-map.test.ts` | Keyboard-to-button mapping |
30
+
31
+ ### Core Smoke Tests (require ROM)
32
+
33
+ Set `NES_TEST_ROM=/path/to/rom.nes` to enable.
34
+
35
+ | File | What it tests |
36
+ |------|---------------|
37
+ | `core-smoke.test.ts` | ROM loading, frame execution, freeze detection, SRAM round-trip |
38
+
39
+ ### Regression Tests (require ROM directory)
40
+
41
+ Set `NES_ROM_DIR=/path/to/roms` to enable.
42
+
43
+ | File | What it tests |
44
+ |------|---------------|
45
+ | `regression.test.ts` | Scripted game tests with input sequences |
46
+ | `game-scripts.ts` | Game-specific input sequences (Start, move, etc.) |
47
+
48
+ ### Debug Tool
49
+
50
+ ```bash
51
+ npx tsx tests/debug-game.ts <rom-path>
52
+ ```
53
+
54
+ Shows ASCII rendering of the game after running the script, useful for diagnosing rendering issues.
55
+
56
+ ## CI Usage
57
+
58
+ For CI without commercial ROMs:
59
+
60
+ ```bash
61
+ npm run test:unit # Only pure function tests
62
+ ```
63
+
64
+ For local development with ROMs:
65
+
66
+ ```bash
67
+ NES_ROM_DIR=~/roms/nes npm run test:regression
68
+ ```
69
+
70
+ ## Interpreting Results
71
+
72
+ ### Regression Output
73
+
74
+ ```
75
+ ✅ OK Dragon Quest III [scripted] (425 frames) # Loaded, ran script, frames animated
76
+ ⚠️ FROZE Super Mario Bros [scripted] (475 frames) # Ran script but frames frozen
77
+ ❌ ERROR Broken.nes (0 frames) # Failed to load or crashed
78
+ ```
79
+
80
+ ### What "FROZE" Means
81
+
82
+ For **scripted** tests, "FROZE" indicates a likely bug:
83
+ - Background renders but sprites missing
84
+ - Game stuck after input sequence
85
+ - No animation after reaching gameplay
86
+
87
+ The scripted tests simulate actual gameplay (press Start, move character) and verify the screen animates. A frozen screen after pressing right in Mario means the sprite isn't rendering.
88
+
89
+ ### Debug Output Example
90
+
91
+ ```
92
+ Frame Analysis (after script):
93
+ Non-zero pixels: 59,440 / 61,440 (96.7%) ← Background renders
94
+ Unique colors: 9 ← Limited palette (no sprites)
95
+
96
+ ASCII Preview:
97
+ [Shows level but no Mario sprite visible]
98
+ ```
99
+
100
+ ## Adding Game Scripts
101
+
102
+ Edit `tests/game-scripts.ts` to add scripts for new ROMs:
103
+
104
+ ```typescript
105
+ "my game": {
106
+ description: "Start game, verify gameplay",
107
+ sequence: [
108
+ { type: "wait", frames: 180 }, // 3 seconds
109
+ { type: "press", button: "start" }, // tap Start
110
+ { type: "wait", frames: 120 }, // 2 seconds
111
+ { type: "hold", button: "right", frames: 30 }, // move
112
+ ],
113
+ postSequenceFrames: 60, // frames to check for animation
114
+ }
115
+ ```
116
+
117
+ ## Environment Variables
118
+
119
+ | Variable | Description |
120
+ |----------|-------------|
121
+ | `NES_TEST_ROM` | Path to a single ROM for core smoke tests |
122
+ | `NES_ROM_DIR` | Directory containing .nes files for regression tests |
@@ -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
+ });