use crate::platform::autorun::AutorunMode;
use crate::platform::autorun::state::AutorunState;
use crate::platform::emulator::Console;
use winit::keyboard::ModifiersState;
#[derive(Default)]
pub struct CartridgeSwitchState {
pub open: bool,
pub entries: Vec<String>,
pub filter: String,
pub selection: usize,
filtered_indices: Vec<usize>,
}
impl CartridgeSwitchState {
pub fn close(&mut self) {
self.open = false;
self.filter.clear();
self.selection = 0;
self.filtered_indices.clear();
}
pub fn has_active_filter(&self) -> bool {
!self.filter.is_empty()
}
pub fn refresh_filtered(&mut self) {
if self.filter.is_empty() {
self.filtered_indices.clear();
return;
}
let needle = self.filter.to_ascii_lowercase();
self.filtered_indices = self
.entries
.iter()
.enumerate()
.filter_map(|(i, path)| {
if entry_matches_filter(path, &needle) {
Some(i)
} else {
None
}
})
.collect();
let count = self.visible_count();
if count == 0 {
self.selection = 0;
} else {
self.selection = self.selection.min(count - 1);
}
}
pub fn visible_count(&self) -> usize {
if self.filter.is_empty() {
self.entries.len()
} else {
self.filtered_indices.len()
}
}
pub fn move_selection_next(&mut self) {
let count = self.visible_count();
if count == 0 {
self.selection = 0;
return;
}
self.selection = (self.selection + 1) % count;
}
pub fn move_selection_prev(&mut self) {
let count = self.visible_count();
if count == 0 {
self.selection = 0;
return;
}
self.selection = if self.selection == 0 {
count - 1
} else {
self.selection - 1
};
}
pub fn selected_entry(&self) -> Option<&str> {
self.visible_entry_at(self.selection)
}
fn visible_entry_at(&self, visible_idx: usize) -> Option<&str> {
let entry_idx = if self.filter.is_empty() {
Some(visible_idx)
} else {
self.filtered_indices.get(visible_idx).copied()
};
entry_idx
.and_then(|i| self.entries.get(i))
.map(String::as_str)
}
}
fn entry_matches_filter(path: &str, needle: &str) -> bool {
if needle.is_empty() {
return true;
}
let filename_haystack = std::path::Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(path)
.to_ascii_lowercase();
if fuzzy_matches(needle, &filename_haystack) {
return true;
}
let dir_haystack = std::path::Path::new(path)
.parent()
.and_then(|p| p.to_str())
.unwrap_or("")
.to_ascii_lowercase();
dir_haystack.contains(needle)
}
fn fuzzy_matches(needle: &str, haystack: &str) -> bool {
if needle.is_empty() {
return true;
}
let mut needle_chars = needle.chars();
let mut current = needle_chars.next().expect("needle is non-empty");
for ch in haystack.chars() {
if ch == current {
match needle_chars.next() {
Some(next) => current = next,
None => return true,
}
}
}
false
}
#[derive(Default)]
pub struct NativeAppState {
pub paused: bool,
pub fullscreen: bool,
pub debugger_open: bool,
pub show_fps: bool,
pub help_overlay_visible: bool,
pub mouse_grabbed: bool,
pub mouse_released_by_escape: bool,
pub window_focused: bool,
pub virtual_cursor: (f32, f32),
pub last_zapper_position: Option<(u8, u8)>,
pub modifiers: ModifiersState,
pub cart_switch: CartridgeSwitchState,
pub gamepad_count: usize,
pub four_score_enabled: bool,
}
impl NativeAppState {
pub fn overlay_text(
&self,
console: &Console,
autorun_state: Option<&AutorunState>,
) -> Option<String> {
if self.cart_switch.open {
return Some(cart_switch_overlay_text(&self.cart_switch));
}
if let Some(autorun) = autorun_state {
let frame_duration = console.as_core().target_frame_duration();
let fps = (1.0 / frame_duration.as_secs_f64()).round().max(1.0) as usize;
return Some(autorun_overlay_text(autorun, fps));
}
if self.help_overlay_visible {
let (port1_is_power_pad, port2_is_power_pad) = match console {
Console::Nes(nes) => {
use crate::nes::input::ControllerType;
(
nes.active_controller_port_type(1) == ControllerType::PowerPad,
nes.active_controller_port_type(2) == ControllerType::PowerPad,
)
}
Console::GameBoy(_) | Console::GameBoyAdvance(_) => (false, false),
};
return Some(help_overlay_text(
console,
self.gamepad_count,
self.four_score_enabled,
port1_is_power_pad,
port2_is_power_pad,
));
}
None
}
pub fn keyboard_captured_by_ui(&self) -> bool {
self.debugger_open
}
}
fn help_overlay_text(
console: &Console,
gamepad_count: usize,
four_score: bool,
port1_is_power_pad: bool,
port2_is_power_pad: bool,
) -> String {
let hotkeys = "Controls\n\
H: Toggle help\n\
Ctrl+Q: Quit\n\
Space: Pause\n\
(Shift+)Ctrl+R: Reset emulator (use Shift for hard reset)\n\
Ctrl+F: Toggle fullscreen\n\
Ctrl+O: Switch cartridge\n\
F2/F3: Volume up/down\n\
F4: Cycle visual filter\n\
F5: Debugger (open/continue)\n\
F10/F11: Step over/into\n\
F6/F7: Save/Load state";
let max_ports: usize = if four_score { 4 } else { 2 };
let keyboard_ports =
crate::frontends::native::keyboard::keyboard_target_ports(gamepad_count, four_score);
let is_power_pad_for = |port: usize| match port {
1 => port1_is_power_pad,
2 => port2_is_power_pad,
_ => false,
};
let mut controllers = String::new();
if matches!(console, Console::GameBoyAdvance(_)) {
return format!("{hotkeys}{}", gba_keyboard_section(gamepad_count));
}
if matches!(console, Console::GameBoy(_)) {
return format!("{hotkeys}{}", gameboy_keyboard_section(gamepad_count));
}
for port in 1..=max_ports {
let port_u8 = port as u8;
if port <= gamepad_count {
controllers.push_str(&format!("\n\nPort {port}: Gamepad"));
} else if let Some(slot) = keyboard_ports.iter().position(|&p| p == port_u8) {
if is_power_pad_for(port) {
controllers.push_str(&power_pad_keyboard_section(port, slot));
} else {
controllers.push_str(&joypad_keyboard_section(port, slot));
}
} else {
controllers.push_str(&format!("\n\nPort {port}: Empty"));
}
}
format!("{hotkeys}{controllers}")
}
fn power_pad_keyboard_section(port: usize, slot: usize) -> String {
if slot == 0 {
format!(
"\n\nPower Pad (Port {port} via keyboard)\n\
1/2/3: Buttons 1-3\n\
Q/W/E: Buttons 4-6\n\
A/S/D: Buttons 7-9\n\
Z/X/C: Buttons 10-12"
)
} else {
format!(
"\n\nPower Pad (Port {port} via keyboard)\n\
7/8/9: Buttons 1-3\n\
U/I/O: Buttons 4-6\n\
J/K/L: Buttons 7-9\n\
M/,/.: Buttons 10-12"
)
}
}
fn joypad_keyboard_section(port: usize, slot: usize) -> String {
if slot == 0 {
format!(
"\n\nPort {port}: Keyboard\n\
W/A/S/D: D-Pad\n\
R: B\n\
T: A\n\
4: Select\n\
5: Start"
)
} else {
format!(
"\n\nPort {port}: Keyboard\n\
I/J/K/L: D-Pad\n\
O: A\n\
P: B\n\
9: Select\n\
0: Start"
)
}
}
fn gameboy_keyboard_section(gamepad_count: usize) -> String {
let controller = if gamepad_count == 0 {
"Keyboard".to_string()
} else {
"Gamepad + Keyboard aliases".to_string()
};
format!(
"\n\nGame Boy: {controller}\n\
W/A/S/D: D-Pad\n\
Arrow keys: D-Pad\n\
R: B\n\
T: A\n\
4: Select\n\
5: Start"
)
}
fn gba_keyboard_section(gamepad_count: usize) -> String {
let controller = if gamepad_count == 0 {
"Keyboard".to_string()
} else {
"Gamepad + Keyboard shoulders/aliases".to_string()
};
format!(
"\n\nGBA: {controller}\n\
W/A/S/D: D-Pad\n\
Arrow keys: D-Pad\n\
Q: L\n\
E: R\n\
R: B\n\
T: A\n\
4: Select\n\
5: Start"
)
}
fn cart_switch_overlay_text(cart_switch: &CartridgeSwitchState) -> String {
if cart_switch.entries.is_empty() {
return "Switch cartridge\nNo catalog entries found\n\nPress Esc to close".to_string();
}
let filter_label = if cart_switch.filter.is_empty() {
"(none)"
} else {
cart_switch.filter.as_str()
};
let visible_count = cart_switch.visible_count();
let total_count = cart_switch.entries.len();
let matches_label = format!("Matches: {visible_count}/{total_count}");
if visible_count == 0 {
return format!(
"Switch cartridge\nFilter: {filter_label}\n{matches_label}\n\
No matching entries\n\nBackspace: Edit filter Esc: Cancel"
);
}
const MAX_VISIBLE: usize = 12;
let selected = cart_switch.selection.min(visible_count.saturating_sub(1));
let mut start = selected.saturating_sub(MAX_VISIBLE / 2);
let mut end = (start + MAX_VISIBLE).min(visible_count);
if end - start < MAX_VISIBLE {
start = end.saturating_sub(MAX_VISIBLE);
}
end = (start + MAX_VISIBLE).min(visible_count);
let mut lines = vec![
"Switch cartridge".to_string(),
format!("Filter: {filter_label}"),
matches_label,
"Up/Down: Select".to_string(),
"Type to filter Backspace: Delete".to_string(),
"Enter: Load Esc: Cancel".to_string(),
String::new(),
];
for visible_idx in start..end {
let Some(entry_text) = cart_switch.visible_entry_at(visible_idx) else {
continue;
};
let line = if visible_idx == selected {
format!(">> {entry_text} <<")
} else {
format!(" {entry_text}")
};
lines.push(line);
}
lines.join("\n")
}
fn autorun_overlay_text(autorun_state: &AutorunState, fps: usize) -> String {
match autorun_state.mode() {
AutorunMode::Playback => {
let current = autorun_state.current_frame_index();
let total = autorun_state.total_frames();
let (elapsed, total_str) = format_time_pair(current, total, fps);
format!("Playback\n{elapsed} / {total_str}")
}
AutorunMode::Record if autorun_state.is_extending_playback() => {
let current = autorun_state.current_frame_index();
let total = autorun_state.total_frames();
let (elapsed, total_str) = format_time_pair(current, total, fps);
format!("Playback\n{elapsed} / {total_str}")
}
AutorunMode::Record => {
let current = autorun_state.total_frames();
let (elapsed, _) = format_time_pair(current, current, fps);
format!("Recording\n{elapsed} / {elapsed}")
}
AutorunMode::None => String::new(),
}
}
fn format_time_pair(current_frames: usize, total_frames: usize, fps: usize) -> (String, String) {
let fps = fps.max(1);
let current_secs = current_frames / fps;
let total_secs = total_frames / fps;
(format_mm_ss(current_secs), format_mm_ss(total_secs))
}
fn format_mm_ss(seconds: usize) -> String {
let minutes = seconds / 60;
let secs = seconds % 60;
format!("{minutes:02}:{secs:02}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::nes::console::{Config, Nes, NesConfig};
use crate::platform::app_context::AppContext;
fn make_console() -> Console {
Console::Nes(Box::new(Nes::new(AppContext::new_with_config(
Config::default(),
))))
}
fn make_gba_console() -> Console {
Console::new_gba(AppContext::new_with_config(Config::default()))
}
fn make_gameboy_console() -> Console {
Console::new_gameboy(AppContext::new_with_config(Config::default()))
}
#[test]
fn test_overlay_text_returns_none_when_nothing_visible() {
let state = NativeAppState::default();
assert!(state.overlay_text(&make_console(), None).is_none());
}
#[test]
fn test_overlay_text_returns_controls_when_help_visible() {
let state = NativeAppState {
help_overlay_visible: true,
..NativeAppState::default()
};
let text = state.overlay_text(&make_console(), None);
assert!(
text.is_some(),
"overlay_text should be Some when help is visible"
);
assert!(
text.unwrap().contains("Controls"),
"help overlay must contain 'Controls' section"
);
}
#[test]
fn test_overlay_text_help_contains_wasd() {
let state = NativeAppState {
help_overlay_visible: true,
..NativeAppState::default()
};
let text = state.overlay_text(&make_console(), None).unwrap();
assert!(
text.contains("W/A/S/D"),
"help overlay should list W/A/S/D keys"
);
}
#[test]
fn test_overlay_text_help_contains_hotkeys() {
let state = NativeAppState {
help_overlay_visible: true,
..NativeAppState::default()
};
let text = state.overlay_text(&make_console(), None).unwrap();
assert!(
text.contains("Ctrl+Q"),
"help overlay should mention Ctrl+Q"
);
assert!(
text.contains("Ctrl+R"),
"help overlay should mention Ctrl+R"
);
}
#[test]
fn test_help_overlay_wasd_labeled_as_port1_only_when_no_gamepad() {
let state = NativeAppState {
help_overlay_visible: true,
gamepad_count: 0,
..NativeAppState::default()
};
let text = state.overlay_text(&make_console(), None).unwrap();
assert!(
!text.contains("Port 1 + Port 2"),
"WASD should not be labelled 'Port 1 + Port 2'; each keyboard set controls one port, got:\n{text}"
);
}
#[test]
fn test_help_overlay_shows_gamepad_for_port1_when_one_gamepad_connected() {
let state = NativeAppState {
help_overlay_visible: true,
gamepad_count: 1,
..NativeAppState::default()
};
let text = state.overlay_text(&make_console(), None).unwrap();
let lower = text.to_lowercase();
assert!(
lower.contains("gamepad"),
"help overlay should mention 'gamepad' when one is connected, got:\n{text}"
);
}
#[test]
fn test_help_overlay_keyboard_disabled_note_when_two_gamepads() {
let state = NativeAppState {
help_overlay_visible: true,
gamepad_count: 2,
..NativeAppState::default()
};
let text = state.overlay_text(&make_console(), None).unwrap();
assert!(
!text.contains("W/A/S/D"),
"help overlay should NOT show WASD when two gamepads control both ports, got:\n{text}"
);
}
#[test]
fn test_overlay_text_returns_cart_switch_when_open() {
let mut state = NativeAppState::default();
state.cart_switch.open = true;
let text = state.overlay_text(&make_console(), None);
assert!(
text.is_some(),
"overlay_text should be Some when cart-switch is open"
);
assert!(
text.unwrap().contains("Esc"),
"cart-switch overlay should mention Esc to close"
);
}
#[test]
fn test_overlay_text_cart_switch_takes_priority_over_help() {
let mut state = NativeAppState::default();
state.cart_switch.open = true;
state.help_overlay_visible = true;
let text = state.overlay_text(&make_console(), None).unwrap();
assert!(
!text.contains("W/A/S/D"),
"cart-switch overlay should not show help text"
);
}
fn make_recording_autorun_state() -> AutorunState {
let dir = tempfile::tempdir().expect("create temp dir");
let rom_path = dir.path().join("test.nes");
std::fs::write(&rom_path, b"dummy").expect("write dummy rom");
let (state, _) = AutorunState::new(
AutorunMode::Record,
rom_path.to_str().unwrap(),
true,
false,
None,
crate::platform::autorun::AutorunFormat::Json,
)
.expect("create recording autorun state");
state
}
#[test]
fn test_overlay_text_shows_autorun_when_active() {
let state = NativeAppState::default();
let autorun = make_recording_autorun_state();
let text = state.overlay_text(&make_console(), Some(&autorun));
assert!(text.is_some(), "overlay_text should be Some for autorun");
assert!(
text.unwrap().contains("Recording"),
"autorun overlay should show 'Recording'"
);
}
#[test]
fn test_overlay_text_autorun_takes_priority_over_help() {
let state = NativeAppState {
help_overlay_visible: true,
..NativeAppState::default()
};
let autorun = make_recording_autorun_state();
let text = state.overlay_text(&make_console(), Some(&autorun)).unwrap();
assert!(
text.contains("Recording"),
"autorun overlay should take priority over help"
);
assert!(
!text.contains("W/A/S/D"),
"help overlay should not appear when autorun is active"
);
}
#[test]
fn test_overlay_text_cart_switch_takes_priority_over_autorun() {
let mut state = NativeAppState::default();
state.cart_switch.open = true;
let autorun = make_recording_autorun_state();
let text = state.overlay_text(&make_console(), Some(&autorun)).unwrap();
assert!(
text.contains("Switch cartridge"),
"cart-switch should take priority over autorun"
);
}
#[test]
fn test_format_mm_ss() {
assert_eq!(format_mm_ss(0), "00:00");
assert_eq!(format_mm_ss(59), "00:59");
assert_eq!(format_mm_ss(60), "01:00");
assert_eq!(format_mm_ss(125), "02:05");
assert_eq!(format_mm_ss(3661), "61:01");
}
#[test]
fn test_fuzzy_matches_empty_needle() {
assert!(fuzzy_matches("", "anything"));
}
#[test]
fn test_fuzzy_matches_consecutive_chars() {
assert!(fuzzy_matches("abc", "abc"));
}
#[test]
fn test_fuzzy_matches_non_consecutive_chars() {
assert!(fuzzy_matches("ac", "abc"));
}
#[test]
fn test_fuzzy_matches_spread_chars() {
assert!(fuzzy_matches("smb", "super mario bros"));
}
#[test]
fn test_fuzzy_matches_no_match() {
assert!(!fuzzy_matches("ba", "abc"));
}
#[test]
fn test_fuzzy_matches_partial_match_at_end() {
assert!(!fuzzy_matches("abcd", "abc"));
}
#[test]
fn test_entry_matches_filter_empty_needle() {
assert!(entry_matches_filter("/roms/test.nes", ""));
}
#[test]
fn test_entry_matches_filter_by_filename() {
assert!(entry_matches_filter("/roms/super_mario.nes", "smr"));
}
#[test]
fn test_entry_matches_filter_by_directory_substring() {
assert!(entry_matches_filter("/home/user/roms/game.nes", "roms"));
}
#[test]
fn test_entry_matches_filter_case_insensitive() {
assert!(entry_matches_filter("/roms/Zelda.nes", "zelda"));
}
fn make_cart_switch(entries: &[&str]) -> CartridgeSwitchState {
CartridgeSwitchState {
open: true,
entries: entries.iter().map(|s| s.to_string()).collect(),
filter: String::new(),
selection: 0,
filtered_indices: Vec::new(),
}
}
#[test]
fn test_visible_count_no_filter() {
let cs = make_cart_switch(&["a.nes", "b.nes", "c.nes"]);
assert_eq!(cs.visible_count(), 3);
}
#[test]
fn test_visible_count_with_filter() {
let mut cs = make_cart_switch(&["alpha.nes", "beta.nes", "gamma.nes"]);
cs.filter = "lph".to_string();
cs.refresh_filtered();
assert_eq!(cs.visible_count(), 1, "only alpha matches 'lph'");
}
#[test]
fn test_refresh_filtered_clamps_selection() {
let mut cs = make_cart_switch(&["alpha.nes", "beta.nes", "gamma.nes"]);
cs.selection = 2;
cs.filter = "beta".to_string();
cs.refresh_filtered();
assert_eq!(cs.selection, 0, "selection clamped to single match");
}
#[test]
fn test_move_selection_next_wraps() {
let mut cs = make_cart_switch(&["a.nes", "b.nes", "c.nes"]);
cs.selection = 2;
cs.move_selection_next();
assert_eq!(cs.selection, 0, "should wrap to first");
}
#[test]
fn test_move_selection_prev_wraps() {
let mut cs = make_cart_switch(&["a.nes", "b.nes", "c.nes"]);
cs.selection = 0;
cs.move_selection_prev();
assert_eq!(cs.selection, 2, "should wrap to last");
}
#[test]
fn test_move_selection_next_empty() {
let mut cs = make_cart_switch(&[]);
cs.move_selection_next();
assert_eq!(cs.selection, 0);
}
#[test]
fn test_selected_entry_no_filter() {
let mut cs = make_cart_switch(&["a.nes", "b.nes", "c.nes"]);
cs.selection = 1;
assert_eq!(cs.selected_entry(), Some("b.nes"));
}
#[test]
fn test_selected_entry_with_filter() {
let mut cs = make_cart_switch(&["alpha.nes", "beta.nes", "gamma.nes"]);
cs.filter = "mm".to_string();
cs.refresh_filtered();
assert_eq!(cs.visible_count(), 1);
cs.selection = 0;
assert_eq!(cs.selected_entry(), Some("gamma.nes"));
}
#[test]
fn test_selected_entry_empty() {
let cs = make_cart_switch(&[]);
assert_eq!(cs.selected_entry(), None);
}
#[test]
fn test_close_resets_filter_and_selection() {
let mut cs = make_cart_switch(&["a.nes", "b.nes"]);
cs.filter = "test".to_string();
cs.selection = 1;
cs.refresh_filtered();
cs.close();
assert!(!cs.open);
assert!(cs.filter.is_empty());
assert_eq!(cs.selection, 0);
}
#[test]
fn test_refresh_filtered_empty_filter_does_not_populate_indices() {
let mut cs = make_cart_switch(&["a.nes", "b.nes", "c.nes"]);
cs.filter.clear();
cs.refresh_filtered();
assert!(
!cs.has_active_filter(),
"empty filter should not populate filtered_indices"
);
assert_eq!(cs.visible_count(), 3, "all entries visible without filter");
}
#[test]
fn test_cart_switch_overlay_no_catalog() {
let cs = CartridgeSwitchState {
open: true,
..Default::default()
};
let text = cart_switch_overlay_text(&cs);
assert!(
text.contains("No catalog"),
"should show no-catalog message"
);
}
#[test]
fn test_cart_switch_overlay_shows_filter() {
let mut cs = make_cart_switch(&["a.nes", "b.nes"]);
cs.filter = "test".to_string();
cs.refresh_filtered();
let text = cart_switch_overlay_text(&cs);
assert!(text.contains("Filter: test"), "should show filter text");
}
#[test]
fn test_cart_switch_overlay_shows_match_count() {
let mut cs = make_cart_switch(&["alpha.nes", "beta.nes", "gamma.nes"]);
cs.filter = "lph".to_string();
cs.refresh_filtered();
let text = cart_switch_overlay_text(&cs);
assert!(
text.contains("Matches: 1/3"),
"should show 1/3 matches, got: {text}"
);
}
#[test]
fn test_cart_switch_overlay_no_filter_shows_all() {
let cs = make_cart_switch(&["a.nes", "b.nes"]);
let text = cart_switch_overlay_text(&cs);
assert!(text.contains("Matches: 2/2"), "got: {text}");
}
#[test]
fn test_cart_switch_overlay_selected_entry_marked() {
let cs = make_cart_switch(&["a.nes", "b.nes"]);
let text = cart_switch_overlay_text(&cs);
assert!(
text.contains(">> a.nes <<"),
"first entry should be selected, got: {text}"
);
assert!(
text.contains(" b.nes"),
"second entry should not be selected, got: {text}"
);
}
#[test]
fn test_cart_switch_overlay_no_matches() {
let mut cs = make_cart_switch(&["a.nes", "b.nes"]);
cs.filter = "zzzzz".to_string();
cs.refresh_filtered();
let text = cart_switch_overlay_text(&cs);
assert!(
text.contains("No matching entries"),
"should show no-match message, got: {text}"
);
}
#[test]
fn keyboard_not_captured_by_ui_when_debugger_closed() {
let state = NativeAppState::default();
let captured = state.keyboard_captured_by_ui();
assert!(
!captured,
"UI must not capture keyboard during normal gameplay"
);
}
#[test]
fn keyboard_captured_by_ui_when_debugger_open() {
let state = NativeAppState {
debugger_open: true,
..NativeAppState::default()
};
let captured = state.keyboard_captured_by_ui();
assert!(captured, "UI must capture keyboard when debugger is open");
}
#[test]
fn test_help_overlay_four_score_2_gamepads_shows_ports_3_4_keyboard() {
let state = NativeAppState {
help_overlay_visible: true,
gamepad_count: 2,
four_score_enabled: true,
..NativeAppState::default()
};
let text = state.overlay_text(&make_console(), None).unwrap();
assert!(
text.contains("Port 3"),
"four-score with 2 gamepads should mention Port 3, got:\n{text}"
);
assert!(
text.contains("Port 4"),
"four-score with 2 gamepads should mention Port 4, got:\n{text}"
);
assert!(
text.contains("W/A/S/D"),
"four-score with 2 gamepads should show keyboard keys for port 3, got:\n{text}"
);
}
#[test]
fn test_help_overlay_four_score_1_gamepad_shows_ports_2_3_keyboard() {
let state = NativeAppState {
help_overlay_visible: true,
gamepad_count: 1,
four_score_enabled: true,
..NativeAppState::default()
};
let text = state.overlay_text(&make_console(), None).unwrap();
assert!(
text.contains("Port 1: Gamepad"),
"port 1 should be gamepad, got:\n{text}"
);
assert!(
text.contains("Port 2"),
"should mention Port 2 for keyboard, got:\n{text}"
);
assert!(
text.contains("Port 3"),
"should mention Port 3 for keyboard, got:\n{text}"
);
}
#[test]
fn test_help_overlay_four_score_3_gamepads_shows_port_4_keyboard() {
let state = NativeAppState {
help_overlay_visible: true,
gamepad_count: 3,
four_score_enabled: true,
..NativeAppState::default()
};
let text = state.overlay_text(&make_console(), None).unwrap();
assert!(
text.contains("Port 4"),
"four-score with 3 gamepads should mention Port 4, got:\n{text}"
);
assert!(
text.contains("W/A/S/D"),
"should show keyboard keys for port 4, got:\n{text}"
);
assert!(
!text.contains("I/J/K/L"),
"should NOT show P2 keyboard keys (only one keyboard slot), got:\n{text}"
);
}
#[test]
fn test_help_overlay_four_score_4_gamepads_no_keyboard() {
let state = NativeAppState {
help_overlay_visible: true,
gamepad_count: 4,
four_score_enabled: true,
..NativeAppState::default()
};
let text = state.overlay_text(&make_console(), None).unwrap();
assert!(
text.contains("Port 4: Gamepad"),
"all 4 ports should be gamepads, got:\n{text}"
);
assert!(
!text.contains("W/A/S/D"),
"no keyboard keys when all 4 ports have gamepads, got:\n{text}"
);
}
#[test]
fn test_help_overlay_shows_power_pad_section_when_port2_is_power_pad() {
let config = Config {
nes: NesConfig {
controller_port2: crate::nes::input::ControllerType::PowerPad,
..Default::default()
},
..Default::default()
};
let console = Console::Nes(Box::new(Nes::new(AppContext::new_with_config(config))));
let state = NativeAppState {
help_overlay_visible: true,
..NativeAppState::default()
};
let text = state.overlay_text(&console, None).unwrap();
assert!(
text.contains("Power Pad"),
"Help overlay should show Power Pad section when port 2 is Power Pad, got:\n{text}"
);
assert!(
text.contains("7/8/9"),
"Help overlay should show P2 Power Pad keys, got:\n{text}"
);
}
#[test]
fn test_help_overlay_shows_power_pad_section_when_port1_is_power_pad() {
let config = Config {
nes: NesConfig {
controller_port1: crate::nes::input::ControllerType::PowerPad,
..Default::default()
},
..Default::default()
};
let console = Console::Nes(Box::new(Nes::new(AppContext::new_with_config(config))));
let state = NativeAppState {
help_overlay_visible: true,
..NativeAppState::default()
};
let text = state.overlay_text(&console, None).unwrap();
assert!(
text.contains("Power Pad"),
"Help overlay should show Power Pad section when port 1 is Power Pad, got:\n{text}"
);
assert!(
text.contains("1/2/3"),
"Help overlay should show P1 Power Pad keys, got:\n{text}"
);
}
#[test]
fn test_help_overlay_no_power_pad_section_for_default_joypads() {
let state = NativeAppState {
help_overlay_visible: true,
..NativeAppState::default()
};
let text = state.overlay_text(&make_console(), None).unwrap();
assert!(
!text.contains("Power Pad"),
"Help overlay should not show Power Pad section for default joypads, got:\n{text}"
);
}
#[test]
fn test_help_overlay_lists_gba_keyboard_shoulders() {
let state = NativeAppState {
help_overlay_visible: true,
..NativeAppState::default()
};
let text = state.overlay_text(&make_gba_console(), None).unwrap();
assert!(
text.contains("Q: L"),
"GBA help overlay should list Q as L shoulder, got:\n{text}"
);
assert!(
text.contains("E: R"),
"GBA help overlay should list E as R shoulder, got:\n{text}"
);
assert!(
text.contains("R: B"),
"GBA help overlay should list R as B, got:\n{text}"
);
assert!(
text.contains("T: A"),
"GBA help overlay should list T as A, got:\n{text}"
);
}
#[test]
fn test_help_overlay_lists_gba_gamepad_and_keyboard_when_gamepad_connected() {
let state = NativeAppState {
help_overlay_visible: true,
gamepad_count: 1,
..NativeAppState::default()
};
let text = state.overlay_text(&make_gba_console(), None).unwrap();
assert!(
text.contains("Gamepad + Keyboard shoulders/aliases"),
"GBA help overlay should mention connected gamepad plus keyboard-only controls, got:\n{text}"
);
assert!(
text.contains("Q: L") && text.contains("E: R"),
"GBA help overlay should still list keyboard shoulders with a gamepad connected, got:\n{text}"
);
}
#[test]
fn test_help_overlay_lists_gameboy_arrow_key_aliases() {
let state = NativeAppState {
help_overlay_visible: true,
..NativeAppState::default()
};
let text = state.overlay_text(&make_gameboy_console(), None).unwrap();
assert!(
text.contains("Game Boy: Keyboard"),
"Game Boy help overlay should use a Game Boy-specific section, got:\n{text}"
);
assert!(
text.contains("Arrow keys: D-Pad"),
"Game Boy help overlay should list arrow-key D-pad aliases, got:\n{text}"
);
assert!(
!text.contains("Q: L") && !text.contains("E: R"),
"Game Boy help overlay should not list GBA shoulders, got:\n{text}"
);
}
#[test]
fn test_help_overlay_lists_gameboy_gamepad_and_keyboard_when_gamepad_connected() {
let state = NativeAppState {
help_overlay_visible: true,
gamepad_count: 1,
..NativeAppState::default()
};
let text = state.overlay_text(&make_gameboy_console(), None).unwrap();
assert!(
text.contains("Game Boy: Gamepad + Keyboard aliases"),
"Game Boy help overlay should mention connected gamepad plus keyboard aliases, got:\n{text}"
);
assert!(
text.contains("Arrow keys: D-Pad"),
"Game Boy help overlay should still list arrow-key D-pad aliases with a gamepad connected, got:\n{text}"
);
assert!(
!text.contains("Keyboard shoulders/aliases"),
"Game Boy help overlay should not use GBA shoulder wording, got:\n{text}"
);
}
}