#![cfg(all(test, feature = "wasm", target_arch = "wasm32"))]
use crate::bus::ControllerStateWrapper;
use crate::console::SaveState;
use crate::input::ArkanoidState;
use crate::wasm::{WasmNes, gamepad_init_toast_message};
use wasm_bindgen_test::*;
wasm_bindgen_test_configure!(run_in_browser);
fn minimal_nrom() -> Vec<u8> {
let mut data = vec![0u8; 16 + 16384 + 8192];
data[0..4].copy_from_slice(b"NES\x1A");
data[4] = 1; data[5] = 1; data
}
fn minimal_nrom_nop_at_8000() -> Vec<u8> {
let prg_size = 16384usize;
let chr_size = 8192usize;
let header_size = 16usize;
let mut data = vec![0u8; header_size + prg_size + chr_size];
data[0..4].copy_from_slice(b"NES\x1A");
data[4] = 1; data[5] = 1; let prg_start = header_size;
data[prg_start..prg_start + prg_size].fill(0xEA);
data[prg_start + 0x3FFA] = 0x00; data[prg_start + 0x3FFB] = 0x80; data[prg_start + 0x3FFC] = 0x00; data[prg_start + 0x3FFD] = 0x80; data[prg_start + 0x3FFE] = 0x00; data[prg_start + 0x3FFF] = 0x80; data
}
fn read_save_state(nes: &WasmNes) -> SaveState {
SaveState::from_bytes(&nes.save_state_bytes()).expect("save state should decode")
}
fn port1_arkanoid_state(state: &SaveState) -> ArkanoidState {
match &state.bus.port1_controller {
ControllerStateWrapper::Arkanoid(arkanoid) => arkanoid.clone(),
ControllerStateWrapper::Joypad(_) => panic!("expected Arkanoid controller on port 1"),
ControllerStateWrapper::SnesAdapter(_) => {
panic!("expected Arkanoid controller on port 1")
}
ControllerStateWrapper::Zapper(_) => panic!("expected Arkanoid controller on port 1"),
ControllerStateWrapper::PowerPad(_) => panic!("expected Arkanoid controller on port 1"),
}
}
fn enable_arkanoid_on_port1(nes: &mut WasmNes) {
nes.set_controller_type(1, "arkanoid")
.expect("should set controller type");
}
fn parse_disasm_addrs_and_current_index(json: &str) -> (Vec<u16>, usize) {
let trimmed = json.trim();
let body = trimmed
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.expect("disasm JSON should be an array");
let mut addrs = Vec::new();
let mut current_index = None;
if body.is_empty() {
return (addrs, 0);
}
for (index, raw_entry) in body.split("},{").enumerate() {
let entry = raw_entry.trim_start_matches('{').trim_end_matches('}');
let addr_start = entry
.find("\"addr\":")
.map(|p| p + "\"addr\":".len())
.expect("entry should contain addr");
let addr_end_rel = entry[addr_start..]
.find(',')
.expect("addr field should be followed by comma");
let addr_end = addr_start + addr_end_rel;
let addr = entry[addr_start..addr_end]
.parse::<u16>()
.expect("addr should be u16");
addrs.push(addr);
if entry.contains("\"is_current\":true") {
current_index = Some(index);
}
}
(
addrs,
current_index.expect("one disasm entry should be current"),
)
}
#[wasm_bindgen_test]
fn wasm_nes_constructs() {
let _nes = WasmNes::new();
}
#[wasm_bindgen_test]
fn load_rom_accepts_valid_data() {
let mut nes = WasmNes::new();
let rom = minimal_nrom();
nes.load_rom(&rom, "test.nes")
.expect("valid rom should load");
}
#[wasm_bindgen_test]
fn load_rom_rejects_invalid_header() {
let mut nes = WasmNes::new();
let mut rom = minimal_nrom();
rom[0] = 0; let err = nes
.load_rom(&rom, "broken.nes")
.expect_err("invalid rom should error");
assert!(
err.as_string()
.unwrap_or_default()
.to_lowercase()
.contains("invalid"),
"unexpected err: {:?}",
err.as_string()
);
let drained = nes.drain_toasts();
assert_eq!(drained.len(), 1);
assert_eq!(
drained[0].as_string().as_deref(),
Some("Cartridge load failed: broken.nes")
);
}
#[wasm_bindgen_test]
fn load_rom_success_enqueues_loaded_and_timing_toasts() {
let mut nes = WasmNes::new();
let rom = minimal_nrom();
nes.load_rom(&rom, "mario.nes")
.expect("valid rom should load");
let drained = nes.drain_toasts();
assert_eq!(drained.len(), 3);
assert_eq!(
drained[0].as_string().as_deref(),
Some("Cartridge loaded: mario.nes")
);
let timing = drained[1].as_string().unwrap_or_default();
assert!(timing == "Emulator timing: NTSC" || timing == "Emulator timing: PAL");
let hardware = drained[2].as_string().unwrap_or_default();
assert!(
hardware.starts_with("Hardware: "),
"expected hardware toast, got: {}",
hardware
);
}
#[wasm_bindgen_test]
fn drain_toasts_clears_queue_after_read() {
let mut nes = WasmNes::new();
let rom = minimal_nrom();
nes.load_rom(&rom, "mario.nes")
.expect("valid rom should load");
let first = nes.drain_toasts();
assert!(!first.is_empty());
let second = nes.drain_toasts();
assert!(second.is_empty());
}
#[wasm_bindgen_test]
fn gamepad_init_toast_export_uses_shared_wording() {
assert_eq!(
gamepad_init_toast_message(true, 1),
"Gamepad found: using 1 gamepad"
);
}
#[wasm_bindgen_test]
fn render_frame_returns_expected_size() {
let mut nes = WasmNes::new();
let rom = minimal_nrom();
nes.load_rom(&rom, "test.nes")
.expect("valid rom should load");
let frame = nes.render_frame();
assert_eq!(frame.len(), 256 * (240 - 16) * 3);
}
#[wasm_bindgen_test]
fn render_frame_rgba_returns_expected_size() {
let mut nes = WasmNes::new();
let rom = minimal_nrom();
nes.load_rom(&rom, "test.nes")
.expect("valid rom should load");
let frame = nes.render_frame_rgba();
assert_eq!(frame.len(), 256 * (240 - 16) * 4);
assert!(frame.iter().skip(3).step_by(4).all(|a| *a == 0xFF));
}
#[wasm_bindgen_test]
fn render_frame_without_rom_succeeds() {
let mut nes = WasmNes::new();
let frame = nes.render_frame();
assert_eq!(frame.len(), 256 * (240 - 16) * 3);
}
#[wasm_bindgen_test]
fn render_frame_rgba_without_rom_succeeds() {
let mut nes = WasmNes::new();
let frame = nes.render_frame_rgba();
assert_eq!(frame.len(), 256 * (240 - 16) * 4);
assert!(frame.iter().skip(3).step_by(4).all(|a| *a == 0xFF));
}
#[wasm_bindgen_test]
fn set_button_accepts_valid_inputs() {
let mut nes = WasmNes::new();
for controller in 1..=2 {
for button in 0..=7 {
nes.set_button(controller, button, true);
nes.set_button(controller, button, false);
}
}
}
#[wasm_bindgen_test]
fn set_button_ignores_invalid_button() {
let mut nes = WasmNes::new();
nes.set_button(1, 8, true);
nes.set_button(1, 255, true);
}
#[wasm_bindgen_test]
fn get_audio_samples_returns_vec() {
let mut nes = WasmNes::new();
let rom = minimal_nrom();
nes.load_rom(&rom, "test.nes")
.expect("valid rom should load");
let _frame = nes.render_frame_rgba();
let _samples = nes.get_audio_samples();
}
#[wasm_bindgen_test]
fn audio_samples_unmuted_returns_samples() {
let mut nes = WasmNes::new();
nes.push_audio_sample_for_test(0.5);
nes.set_audio_muted(false);
let samples = nes.get_audio_samples();
assert_eq!(samples.len(), 1);
assert!((samples[0] - 0.5).abs() < f32::EPSILON);
}
#[wasm_bindgen_test]
fn audio_samples_muted_drops_samples() {
let mut nes = WasmNes::new();
nes.push_audio_sample_for_test(0.5);
nes.set_audio_muted(true);
let samples = nes.get_audio_samples();
assert!(samples.is_empty());
nes.set_audio_muted(false);
let samples_after_unmute = nes.get_audio_samples();
assert!(samples_after_unmute.is_empty());
}
#[wasm_bindgen_test]
fn get_audio_samples_without_rom_succeeds() {
let mut nes = WasmNes::new();
let _samples = nes.get_audio_samples();
}
#[wasm_bindgen_test]
fn save_state_roundtrip_returns_same_bytes() {
let mut nes = WasmNes::new();
let rom = minimal_nrom();
nes.load_rom(&rom, "test.nes")
.expect("valid rom should load");
let state1 = nes.save_state_bytes();
assert!(!state1.is_empty());
nes.load_state_bytes(&state1).expect("state should load");
let state2 = nes.save_state_bytes();
assert_eq!(state1, state2);
}
#[wasm_bindgen_test]
fn reset_restores_initial_state() {
let mut nes = WasmNes::new();
let rom = minimal_nrom();
nes.load_rom(&rom, "test.nes")
.expect("valid rom should load");
let initial = nes.save_state_bytes();
assert!(!initial.is_empty());
let _frame = nes.render_frame();
let modified = nes.save_state_bytes();
assert_ne!(initial, modified);
nes.reset(true);
let after_reset = nes.save_state_bytes();
assert!(!after_reset.is_empty());
assert_ne!(modified, after_reset);
let _state = SaveState::from_bytes(&after_reset).expect("save state should decode");
}
#[wasm_bindgen_test]
fn set_mouse_x_position_updates_save_state() {
let mut nes = WasmNes::new();
enable_arkanoid_on_port1(&mut nes);
nes.set_mouse_x_position(0x80);
let state = read_save_state(&nes);
let arkanoid = port1_arkanoid_state(&state);
assert_eq!(arkanoid.position, 0x80);
}
#[wasm_bindgen_test]
fn set_mouse_x_position_clamps_to_valid_range() {
let mut nes = WasmNes::new();
enable_arkanoid_on_port1(&mut nes);
nes.set_mouse_x_position(0x20);
let state = read_save_state(&nes);
let arkanoid = port1_arkanoid_state(&state);
assert_eq!(arkanoid.position, 0x62);
nes.set_mouse_x_position(0xFF);
let state = read_save_state(&nes);
let arkanoid = port1_arkanoid_state(&state);
assert_eq!(arkanoid.position, 0xF2);
}
#[wasm_bindgen_test]
fn set_mouse_left_button_updates_save_state() {
let mut nes = WasmNes::new();
enable_arkanoid_on_port1(&mut nes);
nes.set_mouse_left_button(true);
let state = read_save_state(&nes);
let arkanoid = port1_arkanoid_state(&state);
assert!(arkanoid.trigger);
nes.set_mouse_left_button(false);
let state = read_save_state(&nes);
let arkanoid = port1_arkanoid_state(&state);
assert!(!arkanoid.trigger);
}
#[wasm_bindgen_test]
fn is_mouse_emulated_controller_reflects_port_configuration() {
let mut nes = WasmNes::new();
assert!(!nes.is_mouse_emulated_controller(1));
assert!(!nes.is_mouse_emulated_controller(2));
enable_arkanoid_on_port1(&mut nes);
assert!(nes.is_mouse_emulated_controller(1));
assert!(!nes.is_mouse_emulated_controller(2));
}
#[wasm_bindgen_test]
fn has_expansion_mouse_controller_reflects_expansion_arkanoid() {
let mut nes = WasmNes::new();
assert!(!nes.has_expansion_mouse_controller());
nes.set_hardware_mode("famicom").expect("set famicom mode");
nes.set_expansion_port("arkanoid")
.expect("set expansion port");
assert!(nes.has_expansion_mouse_controller());
}
#[wasm_bindgen_test]
fn has_expansion_mouse_controller_is_false_for_port_arkanoid() {
let mut nes = WasmNes::new();
enable_arkanoid_on_port1(&mut nes);
assert!(!nes.has_expansion_mouse_controller());
}
#[wasm_bindgen_test]
fn debugger_is_closed_by_default() {
let nes = WasmNes::new();
assert!(!nes.is_debugger_open());
}
#[wasm_bindgen_test]
fn debugger_open_pauses_emulator() {
let mut nes = WasmNes::new();
let rom = minimal_nrom();
nes.load_rom(&rom, "test.nes").expect("valid rom");
assert!(!nes.is_debugger_open());
nes.debugger_open();
assert!(nes.is_debugger_open());
}
#[wasm_bindgen_test]
fn debugger_continue_resumes_emulator() {
let mut nes = WasmNes::new();
let rom = minimal_nrom();
nes.load_rom(&rom, "test.nes").expect("valid rom");
nes.debugger_open();
assert!(nes.is_debugger_open());
nes.debugger_continue();
assert!(!nes.is_debugger_open());
}
#[wasm_bindgen_test]
fn debugger_step_into_keeps_debugger_open_and_advances_one_instruction() {
let mut nes = WasmNes::new();
let rom = minimal_nrom();
nes.load_rom(&rom, "test.nes").expect("valid rom");
let _ = nes.render_frame_rgba();
nes.debugger_open();
nes.debugger_step_into();
assert!(nes.is_debugger_open());
let _pc_after = nes.debugger_cpu_pc();
assert!(nes.is_debugger_open());
}
#[wasm_bindgen_test]
fn debugger_step_over_keeps_debugger_open() {
let mut nes = WasmNes::new();
let rom = minimal_nrom();
nes.load_rom(&rom, "test.nes").expect("valid rom");
nes.debugger_open();
nes.debugger_step_over();
assert!(nes.is_debugger_open());
}
#[wasm_bindgen_test]
fn debugger_snapshot_returns_json_when_open() {
let mut nes = WasmNes::new();
let rom = minimal_nrom();
nes.load_rom(&rom, "test.nes").expect("valid rom");
nes.debugger_open();
let json = nes.debugger_snapshot_json();
assert!(!json.is_empty());
assert!(json.contains("pc"));
}
#[wasm_bindgen_test]
fn debugger_disasm_json_returns_json_array_when_open() {
let mut nes = WasmNes::new();
let rom = minimal_nrom();
nes.load_rom(&rom, "test.nes").expect("valid rom");
nes.debugger_open();
let json = nes.debugger_disasm_json();
assert!(!json.is_empty());
assert!(
json.trim_start().starts_with('['),
"expected JSON array, got: {json}"
);
}
#[wasm_bindgen_test]
fn debugger_disasm_json_contains_current_instruction_marker() {
let mut nes = WasmNes::new();
let rom = minimal_nrom_nop_at_8000();
nes.load_rom(&rom, "test.nes").expect("valid rom");
nes.debugger_open();
let json = nes.debugger_disasm_json();
assert!(
json.contains("\"is_current\":true"),
"expected is_current:true in: {json}"
);
}
#[wasm_bindgen_test]
fn debugger_disasm_json_entries_have_required_fields() {
let mut nes = WasmNes::new();
let rom = minimal_nrom();
nes.load_rom(&rom, "test.nes").expect("valid rom");
nes.debugger_open();
let json = nes.debugger_disasm_json();
assert!(json.contains("\"addr\""), "expected addr field in: {json}");
assert!(json.contains("\"text\""), "expected text field in: {json}");
assert!(
json.contains("\"bytes\""),
"expected bytes field in: {json}"
);
assert!(
json.contains("\"is_current\""),
"expected is_current field in: {json}"
);
}
#[wasm_bindgen_test]
fn debugger_disasm_current_addr_matches_cpu_pc() {
let mut nes = WasmNes::new();
let rom = minimal_nrom_nop_at_8000();
nes.load_rom(&rom, "test.nes").expect("valid rom");
let _ = nes.render_frame_rgba();
let pc = nes.debugger_cpu_pc();
nes.debugger_open();
let json = nes.debugger_disasm_json();
let expected_fragment = format!("\"addr\":{pc},");
assert!(
json.contains(&expected_fragment),
"expected PC {pc} as addr in disasm JSON.\njson: {json}"
);
}
#[wasm_bindgen_test]
fn debugger_disasm_keeps_window_until_last_two_then_recenters() {
let mut nes = WasmNes::new();
let rom = minimal_nrom_nop_at_8000();
nes.load_rom(&rom, "test.nes").expect("valid rom");
nes.debugger_open();
let (addrs0, mut idx) = parse_disasm_addrs_and_current_index(&nes.debugger_disasm_json());
let target_lines = addrs0.len();
let mut saw_centered = idx == target_lines / 2;
for _ in 0..(target_lines * 4) {
nes.debugger_step_over();
let (_addrs, new_idx) = parse_disasm_addrs_and_current_index(&nes.debugger_disasm_json());
idx = new_idx;
saw_centered |= idx == target_lines / 2;
}
assert!(
saw_centered,
"expected disassembly window to recenter at least once"
);
}
#[wasm_bindgen_test]
fn render_frame_rgba_does_not_advance_when_debugger_is_open() {
let mut nes = WasmNes::new();
let rom = minimal_nrom();
nes.load_rom(&rom, "test.nes").expect("valid rom");
let _ = nes.render_frame_rgba();
let state_before = nes.save_state_bytes();
nes.debugger_open();
let _ = nes.render_frame_rgba();
let state_after = nes.save_state_bytes();
assert_eq!(
state_before, state_after,
"emulator state must not change when debugger is open"
);
}
#[wasm_bindgen_test]
fn debugger_snapshot_json_includes_frame_count() {
let mut nes = WasmNes::new();
let rom = minimal_nrom_nop_at_8000();
nes.load_rom(&rom, "test.nes").expect("valid rom");
nes.debugger_open();
let json = nes.debugger_snapshot_json();
assert!(
json.contains("\"frame_count\""),
"expected frame_count field in snapshot JSON: {json}"
);
}
#[wasm_bindgen_test]
fn debugger_snapshot_json_includes_interrupt_field() {
let mut nes = WasmNes::new();
let rom = minimal_nrom_nop_at_8000();
nes.load_rom(&rom, "test.nes").expect("valid rom");
nes.debugger_open();
let json = nes.debugger_snapshot_json();
assert!(
json.contains("\"interrupt\""),
"expected interrupt field in snapshot JSON: {json}"
);
}
#[wasm_bindgen_test]
fn debugger_snapshot_json_includes_interrupt_vectors() {
let mut nes = WasmNes::new();
let rom = minimal_nrom_nop_at_8000();
nes.load_rom(&rom, "test.nes").expect("valid rom");
nes.debugger_open();
let json = nes.debugger_snapshot_json();
assert!(
json.contains("\"nmi_vector\""),
"expected nmi_vector field: {json}"
);
assert!(
json.contains("\"reset_vector\""),
"expected reset_vector field: {json}"
);
assert!(
json.contains("\"irq_vector\""),
"expected irq_vector field: {json}"
);
}
#[wasm_bindgen_test]
fn debugger_snapshot_json_includes_prg_hexdump_fields() {
let mut nes = WasmNes::new();
let rom = minimal_nrom_nop_at_8000();
nes.load_rom(&rom, "test.nes").expect("valid rom");
nes.debugger_open();
let json = nes.debugger_snapshot_json();
assert!(
json.contains("\"prg_hexdump_base\""),
"expected prg_hexdump_base field: {json}"
);
assert!(
json.contains("\"prg_hexdump_bytes\""),
"expected prg_hexdump_bytes field: {json}"
);
}
#[wasm_bindgen_test]
fn debugger_hexdump_navigation_moves_by_16_bytes() {
let mut nes = WasmNes::new();
let rom = minimal_nrom_nop_at_8000();
nes.load_rom(&rom, "test.nes").expect("valid rom");
nes.debugger_open();
let initial = nes.debugger_snapshot_json();
let initial_base = parse_u16_field(&initial, "prg_hexdump_base");
nes.debugger_hexdump_next_16();
let after_next = nes.debugger_snapshot_json();
let next_base = parse_u16_field(&after_next, "prg_hexdump_base");
assert_eq!(next_base, initial_base.saturating_add(16));
nes.debugger_hexdump_prev_16();
let after_prev = nes.debugger_snapshot_json();
let prev_base = parse_u16_field(&after_prev, "prg_hexdump_base");
assert_eq!(prev_base, initial_base);
}
#[wasm_bindgen_test]
fn debugger_hexdump_set_base_jumps_to_specific_address() {
let mut nes = WasmNes::new();
let rom = minimal_nrom_nop_at_8000();
nes.load_rom(&rom, "test.nes").expect("valid rom");
nes.debugger_open();
nes.debugger_hexdump_set_base(0xC123);
let json = nes.debugger_snapshot_json();
let base = parse_u16_field(&json, "prg_hexdump_base");
assert_eq!(base, 0xC120);
nes.debugger_hexdump_set_base(0x7000);
let clamped_json = nes.debugger_snapshot_json();
let clamped_base = parse_u16_field(&clamped_json, "prg_hexdump_base");
assert_eq!(clamped_base, 0x8000);
}
#[wasm_bindgen_test]
fn debugger_ppu_viewer_is_closed_by_default() {
let nes = WasmNes::new();
assert!(
!nes.debugger_is_ppu_viewer_open(),
"PPU viewer should be closed by default"
);
}
#[wasm_bindgen_test]
fn debugger_ppu_viewer_toggle_opens_then_closes() {
let mut nes = WasmNes::new();
let rom = minimal_nrom_nop_at_8000();
nes.load_rom(&rom, "test.nes").expect("valid rom");
nes.debugger_open();
assert!(!nes.debugger_is_ppu_viewer_open());
nes.debugger_toggle_ppu_viewer();
assert!(
nes.debugger_is_ppu_viewer_open(),
"toggle should open the PPU viewer"
);
nes.debugger_toggle_ppu_viewer();
assert!(
!nes.debugger_is_ppu_viewer_open(),
"second toggle should close the PPU viewer"
);
}
#[wasm_bindgen_test]
fn debugger_ppu_viewer_rgba_buffers_match_expected_dimensions() {
let mut nes = WasmNes::new();
let rom = minimal_nrom_nop_at_8000();
nes.load_rom(&rom, "test.nes").expect("valid rom");
nes.debugger_open();
nes.debugger_toggle_ppu_viewer();
let pattern_tables = nes.debugger_ppu_pattern_tables_rgba();
let nametables = nes.debugger_ppu_nametables_rgba();
assert_eq!(
pattern_tables.len(),
256 * 128 * 4,
"pattern table viewer buffer should be 256x128 RGBA"
);
assert_eq!(
nametables.len(),
512 * 480 * 4,
"nametable viewer buffer should be 512x480 RGBA"
);
}
#[wasm_bindgen_test]
fn debugger_ppu_viewer_visibility_persists_across_close_and_reopen() {
let mut nes = WasmNes::new();
let rom = minimal_nrom_nop_at_8000();
nes.load_rom(&rom, "test.nes").expect("valid rom");
nes.debugger_open();
nes.debugger_toggle_ppu_viewer();
assert!(nes.debugger_is_ppu_viewer_open());
nes.debugger_continue();
assert!(!nes.is_debugger_open());
nes.debugger_open();
assert!(
nes.debugger_is_ppu_viewer_open(),
"PPU viewer visibility should persist when reopening debugger"
);
}
#[wasm_bindgen_test]
fn debugger_ppu_scroll_json_exposes_scroll_coordinates() {
let mut nes = WasmNes::new();
let rom = minimal_nrom_nop_at_8000();
nes.load_rom(&rom, "test.nes").expect("valid rom");
nes.debugger_open();
let json = nes.debugger_ppu_scroll_json();
let scroll_x = parse_u16_field(&json, "scroll_x");
let scroll_y = parse_u16_field(&json, "scroll_y");
assert!(scroll_x < 512, "scroll_x should be within nametable width");
assert!(scroll_y < 480, "scroll_y should be within nametable height");
}
fn parse_u16_field(json: &str, field: &str) -> u16 {
let key = format!("\"{field}\":");
let start = json
.find(&key)
.map(|idx| idx + key.len())
.expect("field should exist in JSON");
let end = json[start..]
.find(|c: char| !c.is_ascii_digit())
.map(|offset| start + offset)
.unwrap_or(json.len());
json[start..end]
.parse::<u16>()
.expect("field should parse as u16")
}
#[wasm_bindgen_test]
fn debugger_snapshot_json_reset_vector_matches_rom_header() {
let mut nes = WasmNes::new();
let rom = minimal_nrom_nop_at_8000();
nes.load_rom(&rom, "test.nes").expect("valid rom");
nes.debugger_open();
let json = nes.debugger_snapshot_json();
assert!(
json.contains("\"reset_vector\":32768"),
"expected reset_vector:32768 ($8000) in snapshot JSON: {json}"
);
}