From a9d95f18f1364020e48bc483bfca4fe80be8b144 Mon Sep 17 00:00:00 2001 From: Nicholas Santi Date: Thu, 24 Sep 2026 17:20:27 +0000 Subject: [PATCH] refactor(firmware): colocate unit tests with implementations --- apps/firmware/README.md | 4 +- apps/firmware/src/connectivity/credentials.rs | 19 ++ apps/firmware/src/connectivity/mod.rs | 15 -- apps/firmware/src/connectivity/ssid.rs | 15 ++ apps/firmware/src/events/direct.rs | 25 +++ apps/firmware/src/events/pubsub.rs | 66 ++++++ apps/firmware/src/hardware/buttons.rs | 62 ++++++ apps/firmware/src/hardware/events/mod.rs | 28 +++ apps/firmware/src/hardware/events/piece.rs | 31 +++ apps/firmware/src/hardware/pins.rs | 111 ++++++++++ apps/firmware/src/harness.rs | 10 + apps/firmware/src/menu/definition.rs | 124 +++++++++++ apps/firmware/tests/e2e/connectivity.rs | 24 --- apps/firmware/tests/e2e/main.rs | 5 - apps/firmware/tests/events/api.rs | 22 -- apps/firmware/tests/events/broadcast.rs | 46 ----- apps/firmware/tests/events/direct.rs | 21 -- apps/firmware/tests/events/main.rs | 3 - apps/firmware/tests/hardware/events.rs | 51 ----- apps/firmware/tests/hardware/main.rs | 1 - apps/firmware/tests/menu.rs | 125 ----------- apps/firmware/tests/pins.rs | 194 ------------------ 22 files changed, 494 insertions(+), 508 deletions(-) delete mode 100644 apps/firmware/tests/events/api.rs delete mode 100644 apps/firmware/tests/events/broadcast.rs delete mode 100644 apps/firmware/tests/events/direct.rs delete mode 100644 apps/firmware/tests/events/main.rs delete mode 100644 apps/firmware/tests/hardware/events.rs delete mode 100644 apps/firmware/tests/hardware/main.rs delete mode 100644 apps/firmware/tests/menu.rs delete mode 100644 apps/firmware/tests/pins.rs diff --git a/apps/firmware/README.md b/apps/firmware/README.md index af9c11da..d43e4461 100644 --- a/apps/firmware/README.md +++ b/apps/firmware/README.md @@ -27,7 +27,9 @@ behavior. cargo test -p firmware --test e2e ``` -The E2E cases live in `tests/e2e/`. The default E2E run requires Docker: Rust +Unit tests live beside their implementations in `src/`; `tests/` is reserved +for cross-module integration and E2E tests. The E2E cases live in `tests/e2e/`. +The default E2E run requires Docker: Rust `testcontainers` starts real NetworkManager in an Ubuntu container and a pinned Ubuntu QEMU VM. The VM loads two guest-kernel `mac80211_hwsim` radios; the Rust probe uses the production `Connectivity` API to discover and join open/WPA diff --git a/apps/firmware/src/connectivity/credentials.rs b/apps/firmware/src/connectivity/credentials.rs index 2690c657..237956ff 100644 --- a/apps/firmware/src/connectivity/credentials.rs +++ b/apps/firmware/src/connectivity/credentials.rs @@ -50,3 +50,22 @@ impl fmt::Display for InvalidPassphrase { } impl StdError for InvalidPassphrase {} + +#[cfg(test)] +mod tests { + use super::Passphrase; + + #[test] + fn passphrase_validation_and_debug_redaction() { + assert!(Passphrase::new("short").is_err()); + assert!(Passphrase::new("abcdefgh\n").is_err()); // Control character. + assert!(Passphrase::new("a".repeat(65)).is_err()); + assert!(Passphrase::new("g".repeat(64)).is_err()); // Not hexadecimal. + + for valid in ["12345678".to_owned(), "a".repeat(63), "f".repeat(64)] { + let passphrase = Passphrase::new(valid.clone()).unwrap(); + assert_eq!(format!("{passphrase:?}"), "Passphrase([REDACTED])"); + assert!(!format!("{passphrase:?}").contains(&valid)); + } + } +} diff --git a/apps/firmware/src/connectivity/mod.rs b/apps/firmware/src/connectivity/mod.rs index 93e5ac59..47904e35 100644 --- a/apps/firmware/src/connectivity/mod.rs +++ b/apps/firmware/src/connectivity/mod.rs @@ -16,18 +16,3 @@ pub use hotspot::Hotspot; pub use network_manager::{Connectivity, Error}; pub use ssid::{InvalidSsid, Ssid}; pub use status::Status; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn domain_values_validate_boundaries_and_redact_secrets() { - assert!(Ssid::new("").is_err()); - assert!(Ssid::new("x".repeat(33)).is_err()); - assert!(Ssid::new("Home").is_ok()); - assert!(Passphrase::new("short").is_err()); - let passphrase = Passphrase::new("correct horse").unwrap(); - assert_eq!(format!("{passphrase:?}"), "Passphrase([REDACTED])"); - } -} diff --git a/apps/firmware/src/connectivity/ssid.rs b/apps/firmware/src/connectivity/ssid.rs index 704801e0..d8230df0 100644 --- a/apps/firmware/src/connectivity/ssid.rs +++ b/apps/firmware/src/connectivity/ssid.rs @@ -33,3 +33,18 @@ impl fmt::Display for InvalidSsid { } impl StdError for InvalidSsid {} + +#[cfg(test)] +mod tests { + use super::Ssid; + + #[test] + fn ssid_validation_uses_utf8_bytes_and_rejects_nul() { + assert!(Ssid::new("").is_err()); + assert!(Ssid::new("a\0b").is_err()); + assert!(Ssid::new("é".repeat(17)).is_err()); // 34 bytes, not 17. + assert!(Ssid::new("é".repeat(16)).is_ok()); // Exactly 32 bytes. + assert!(Ssid::new("a".repeat(32)).is_ok()); + assert_eq!(Ssid::new("Home").unwrap().as_str(), "Home"); + } +} diff --git a/apps/firmware/src/events/direct.rs b/apps/firmware/src/events/direct.rs index 44a4965f..53280c80 100644 --- a/apps/firmware/src/events/direct.rs +++ b/apps/firmware/src/events/direct.rs @@ -63,3 +63,28 @@ impl fmt::Display for DirectSendError { } impl StdError for DirectSendError {} + +#[cfg(test)] +mod tests { + use super::direct_channel; + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + enum TestEvent { + First, + Second, + } + + #[tokio::test] + async fn delivers_once_and_applies_backpressure() { + let (sender, mut receiver) = direct_channel(1); + sender.send(TestEvent::First).await.unwrap(); + + let blocked_send = tokio::spawn(async move { sender.send(TestEvent::Second).await }); + tokio::task::yield_now().await; + assert!(!blocked_send.is_finished()); + + assert_eq!(receiver.recv().await, Some(TestEvent::First)); + blocked_send.await.unwrap().unwrap(); + assert_eq!(receiver.recv().await, Some(TestEvent::Second)); + } +} diff --git a/apps/firmware/src/events/pubsub.rs b/apps/firmware/src/events/pubsub.rs index c0b6485f..a5d85de5 100644 --- a/apps/firmware/src/events/pubsub.rs +++ b/apps/firmware/src/events/pubsub.rs @@ -106,3 +106,69 @@ impl From for ReceiveError { } } } + +#[cfg(test)] +mod tests { + use super::{Bus, ReceiveError}; + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + enum TestEvent { + SensorReading(u8), + Stopped, + } + + #[test] + fn callers_can_define_their_own_typed_events() { + let bus = Bus::::with_capacity(4); + let mut subscription = bus.subscribe(); + + bus.emit(TestEvent::SensorReading(42)).unwrap(); + bus.emit(TestEvent::Stopped).unwrap(); + + assert_eq!( + subscription.try_recv(), + Ok(Some(TestEvent::SensorReading(42))) + ); + assert_eq!(subscription.try_recv(), Ok(Some(TestEvent::Stopped))); + } + + #[test] + fn every_subscription_receives_events_from_every_emitter_in_order() { + let emitter = Bus::new(); + let other_emitter = emitter.clone(); + let mut first = emitter.subscribe(); + let mut second = emitter.subscribe(); + + emitter.emit(TestEvent::SensorReading(1)).unwrap(); + other_emitter.emit(TestEvent::Stopped).unwrap(); + + for subscription in [&mut first, &mut second] { + assert_eq!( + subscription.try_recv(), + Ok(Some(TestEvent::SensorReading(1))) + ); + assert_eq!(subscription.try_recv(), Ok(Some(TestEvent::Stopped))); + assert_eq!(subscription.try_recv(), Ok(None)); + } + } + + #[test] + fn subscriptions_report_lag_without_exposing_channel_errors() { + let emitter = Bus::new(); + let mut subscription = emitter.subscribe(); + + for _ in 0..65 { + emitter.emit(TestEvent::Stopped).unwrap(); + } + + assert_eq!(subscription.try_recv(), Err(ReceiveError::Lagged(1))); + } + + #[test] + fn failed_emission_returns_ownership_of_the_event() { + let emitter = Bus::new(); + let returned = emitter.emit(TestEvent::Stopped).unwrap_err().into_event(); + assert_eq!(returned, TestEvent::Stopped); + assert_eq!(emitter.subscriber_count(), 0); + } +} diff --git a/apps/firmware/src/hardware/buttons.rs b/apps/firmware/src/hardware/buttons.rs index e391f7d1..23b03364 100644 --- a/apps/firmware/src/hardware/buttons.rs +++ b/apps/firmware/src/hardware/buttons.rs @@ -191,3 +191,65 @@ async fn poll( let _ = hardware_events.emit(event.into()); } } + +#[cfg(test)] +mod tests { + use std::{collections::VecDeque, time::Duration}; + + use super::*; + use crate::hardware::pins::BoardPins; + + #[test] + fn starting_a_button_subscription_without_a_runtime_returns_an_error() { + let events = HardwareEventBus::new(); + let reader = SequenceReader { + levels: VecDeque::from([Level::High]), + }; + let error = BoardPins::get() + .gpio + .down_button + .start_subscription(reader, &events) + .unwrap_err(); + assert_eq!( + error.to_string(), + "button subscriptions require an active Tokio runtime" + ); + } + + struct SequenceReader { + levels: VecDeque, + } + + impl ReadLevel for SequenceReader { + type Error = (); + + fn read_level(&mut self, _: GPIO) -> Result { + self.levels.pop_front().ok_or(()) + } + } + + #[tokio::test(start_paused = true)] + async fn button_pins_poll_debounce_and_deliver_physical_transitions() { + let events = HardwareEventBus::new(); + let reader = SequenceReader { + levels: VecDeque::from([ + Level::High, + Level::Low, + Level::Low, + Level::Low, + Level::Low, + Level::Low, + ]), + }; + let mut next = BoardPins::get() + .gpio + .down_button + .start_subscription(reader, &events) + .unwrap(); + let action = tokio::time::timeout(Duration::from_millis(100), next.on_message()) + .await + .expect("button poller should produce an action") + .unwrap(); + assert_eq!(action, ButtonAction::Pressed); + } +} diff --git a/apps/firmware/src/hardware/events/mod.rs b/apps/firmware/src/hardware/events/mod.rs index ccfbd36e..8e72cad2 100644 --- a/apps/firmware/src/hardware/events/mod.rs +++ b/apps/firmware/src/hardware/events/mod.rs @@ -35,3 +35,31 @@ impl From for HardwareEvent { Self::Piece(event) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::hardware::pins::Button; + + #[test] + fn every_button_and_transition_converts_to_a_hardware_event() { + for button in [ + Button::Up, + Button::Down, + Button::Left, + Button::Right, + Button::Ok, + Button::Reset, + Button::Pass, + Button::F1, + Button::F2, + Button::F3, + Button::F4, + Button::F5, + ] { + for event in [ButtonEvent::Pressed(button), ButtonEvent::Released(button)] { + assert_eq!(HardwareEvent::from(event), HardwareEvent::Button(event)); + } + } + } +} diff --git a/apps/firmware/src/hardware/events/piece.rs b/apps/firmware/src/hardware/events/piece.rs index 378df0a3..923aa4b9 100644 --- a/apps/firmware/src/hardware/events/piece.rs +++ b/apps/firmware/src/hardware/events/piece.rs @@ -45,3 +45,34 @@ impl PieceEvent { } } } + +#[cfg(test)] +mod tests { + use super::{BoardPosition, PieceEvent}; + use crate::hardware::HardwareEvent; + + #[test] + fn every_board_position_round_trips_through_piece_events() { + for row in 0..BoardPosition::SIZE { + for column in 0..BoardPosition::SIZE { + let position = BoardPosition::new(row, column).unwrap(); + assert_eq!(position.row(), row); + assert_eq!(position.column(), column); + + for event in [PieceEvent::Placed(position), PieceEvent::Removed(position)] { + assert_eq!(event.position(), position); + assert_eq!(HardwareEvent::from(event), HardwareEvent::Piece(event)); + } + } + } + } + + #[test] + fn positions_outside_either_grid_axis_are_rejected() { + for coordinate in BoardPosition::SIZE..=u8::MAX { + assert_eq!(BoardPosition::new(coordinate, 0), None); + assert_eq!(BoardPosition::new(0, coordinate), None); + assert_eq!(BoardPosition::new(coordinate, coordinate), None); + } + } +} diff --git a/apps/firmware/src/hardware/pins.rs b/apps/firmware/src/hardware/pins.rs index 14216a44..597f1fe0 100644 --- a/apps/firmware/src/hardware/pins.rs +++ b/apps/firmware/src/hardware/pins.rs @@ -168,3 +168,114 @@ impl BoardPins { } } } + +#[cfg(test)] +mod tests { + use super::*; + + struct Lines { + level: Level, + } + + impl ReadLevel for Lines { + type Error = (); + + fn read_level(&mut self, _: GPIO) -> Result { + Ok(self.level) + } + } + + impl WriteLevel for Lines { + type Error = (); + + fn write_level(&mut self, _: GPIO, level: Level) -> Result<(), Self::Error> { + self.level = level; + Ok(()) + } + } + + #[test] + fn board_interfaces_use_the_hardware_bcm_numbers() { + let pins = BoardPins::get(); + assert_eq!( + [pins.i2c.data.bcm_number(), pins.i2c.clock.bcm_number()], + [2, 3] + ); + assert_eq!( + [pins.spi.data.bcm_number(), pins.spi.clock.bcm_number()], + [10, 11] + ); + let gpio = pins.gpio; + assert_eq!( + [ + (gpio.up_button.bcm_number(), gpio.up_button.button()), + (gpio.down_button.bcm_number(), gpio.down_button.button()), + (gpio.left_button.bcm_number(), gpio.left_button.button()), + (gpio.right_button.bcm_number(), gpio.right_button.button()), + (gpio.ok_button.bcm_number(), gpio.ok_button.button()), + (gpio.reset_button.bcm_number(), gpio.reset_button.button()), + (gpio.pass_button.bcm_number(), gpio.pass_button.button()), + ( + gpio.function_one_button.bcm_number(), + gpio.function_one_button.button() + ), + ( + gpio.function_two_button.bcm_number(), + gpio.function_two_button.button() + ), + ( + gpio.function_three_button.bcm_number(), + gpio.function_three_button.button() + ), + ( + gpio.function_four_button.bcm_number(), + gpio.function_four_button.button() + ), + ( + gpio.function_five_button.bcm_number(), + gpio.function_five_button.button() + ), + ], + [ + (5, Button::Up), + (6, Button::Down), + (12, Button::Left), + (13, Button::Right), + (16, Button::Ok), + (17, Button::Reset), + (19, Button::Pass), + (20, Button::F1), + (21, Button::F2), + (22, Button::F3), + (23, Button::F4), + (24, Button::F5), + ] + ); + } + + #[test] + fn capabilities_are_enforced_by_pin_types() { + fn read(_: &Pin) {} + fn write(_: &Pin) {} + fn button(_: &ButtonPin<5>) {} + fn i2c(_: &Pin<2, InputOutput>) {} + fn spi(_: &Pin<10, Output>) {} + + let pins = BoardPins::get(); + read(&pins.i2c.data); + write(&pins.spi.data); + write(&pins.i2c.data); + button(&pins.gpio.up_button); + i2c(&pins.i2c.data); + spi(&pins.spi.data); + } + + #[test] + fn pins_read_and_write_levels() { + let pins = BoardPins::get(); + let mut lines = Lines { level: Level::Low }; + assert_eq!(pins.gpio.up_button.read_level(&mut lines), Ok(Level::Low)); + pins.spi.data.set_level(&mut lines, Level::High).unwrap(); + assert_eq!(lines.level, Level::High); + } +} diff --git a/apps/firmware/src/harness.rs b/apps/firmware/src/harness.rs index 604812d6..cb7373e1 100644 --- a/apps/firmware/src/harness.rs +++ b/apps/firmware/src/harness.rs @@ -50,3 +50,13 @@ impl FirmwareHarness { self.firmware.shutdown().await } } + +#[cfg(test)] +mod tests { + use super::FirmwareHarness; + + #[test] + fn starting_firmware_without_runtime_is_an_error() { + assert!(FirmwareHarness::start().is_err()); + } +} diff --git a/apps/firmware/src/menu/definition.rs b/apps/firmware/src/menu/definition.rs index a354d078..65f80aea 100644 --- a/apps/firmware/src/menu/definition.rs +++ b/apps/firmware/src/menu/definition.rs @@ -112,3 +112,127 @@ pub static MAIN_MENU: Menu<'static, Request> = Menu::new( MenuItem::submenu("Settings", &SETTINGS_MENU), ], ); + +#[cfg(test)] +mod tests { + use super::*; + use ::menu::{Event, Input, MenuState}; + + #[test] + fn main_menu_composes_every_product_submenu() { + let expected: [(&str, &Menu<'static, Request>); 5] = [ + ("Play", &PLAY_MENU), + ("Connection", &CONNECTION_MENU), + ("Pairing", &PAIRING_MENU), + ("Update", &UPDATE_MENU), + ("Settings", &SETTINGS_MENU), + ]; + + assert_eq!(MAIN_MENU.title(), "Main Menu"); + assert_eq!(MAIN_MENU.items().len(), expected.len()); + for (item, (label, submenu)) in MAIN_MENU.items().iter().zip(expected) { + assert_eq!(item.label(), label); + assert_eq!(item.as_submenu(), Some(submenu)); + assert_eq!(item.as_action(), None); + } + } + + #[test] + fn submenus_expose_image_derived_placeholder_requests() { + assert_menu( + &PLAY_MENU, + &[ + ("Local Game", Request::SelectLocalGame), + ("Online Game", Request::SelectOnlineGame), + ], + ); + assert_menu( + &CONNECTION_MENU, + &[ + ("Status", Request::ShowConnectionStatus), + ("Scan Wi-Fi", Request::ScanWifi), + ("Network Settings", Request::OpenNetworkSettings), + ], + ); + assert_menu( + &PAIRING_MENU, + &[ + ("Start Pairing", Request::StartPairing), + ("Session Status", Request::ShowSessionStatus), + ], + ); + assert_menu( + &UPDATE_MENU, + &[ + ("Check for Updates", Request::CheckForUpdates), + ("Install Update", Request::InstallUpdate), + ], + ); + assert!(SETTINGS_MENU.is_empty()); + } + + #[test] + fn every_submenu_is_navigable_without_requesting_external_work() { + let submenus = [ + &PLAY_MENU, + &CONNECTION_MENU, + &PAIRING_MENU, + &UPDATE_MENU, + &SETTINGS_MENU, + ]; + + for (index, submenu) in submenus.into_iter().enumerate() { + let mut state = MenuState::new(&MAIN_MENU); + for selected in 0..index { + assert_eq!( + state.handle(Input::Down), + Event::SelectionChanged { + selected: selected + 1 + } + ); + } + assert_eq!(state.handle(Input::Right), Event::Opened { depth: 1 }); + assert_eq!(state.current_menu(), submenu); + assert_eq!(state.handle(Input::Left), Event::Closed { depth: 0 }); + assert_eq!(state.selected_index(), index); + } + } + + #[test] + fn placeholder_requests_are_immediate_and_non_blocking() { + let mut state = MenuState::new(&MAIN_MENU); + assert_eq!(state.handle(Input::Ok), Event::Opened { depth: 1 }); + assert_eq!( + state.handle(Input::Ok), + Event::Activated(&Request::SelectLocalGame) + ); + assert!(!state.is_blocked()); + } + + #[test] + fn status_bar_policy_covers_every_screen() { + let visible = [ + Screen::MainMenu, + Screen::ConnectionStatus, + Screen::GameSetup, + Screen::Pairing, + Screen::InGameHud, + Screen::Update, + Screen::Settings, + Screen::Result, + Screen::ErrorRecovery, + ]; + assert!(!Screen::Splash.shows_status_bar()); + assert!(visible.into_iter().all(Screen::shows_status_bar)); + } + + fn assert_menu(menu: &Menu<'static, Request>, expected: &[(&str, Request)]) { + assert_eq!(menu.items().len(), expected.len()); + for (item, (label, request)) in menu.items().iter().zip(expected) { + assert_eq!(item.label(), *label); + assert_eq!(item.as_action(), Some(request)); + assert!(!item.is_blocking()); + assert_eq!(item.escape_action(), None); + } + } +} diff --git a/apps/firmware/tests/e2e/connectivity.rs b/apps/firmware/tests/e2e/connectivity.rs index 7a478f4f..36e1200d 100644 --- a/apps/firmware/tests/e2e/connectivity.rs +++ b/apps/firmware/tests/e2e/connectivity.rs @@ -1,7 +1,5 @@ //! Black-box checks against real NetworkManager and system D-Bus in Linux. -use firmware::connectivity::{Passphrase, Ssid}; - use super::{ linux::{LinuxFixture, LinuxHarness}, probe_case::ProbeCase, @@ -28,25 +26,3 @@ async fn connectivity_uses_real_linux_network_manager() -> Result<(), Box::with_capacity(4); - let mut subscription = bus.subscribe(); - - bus.emit(TestEvent::SensorReading(42)).unwrap(); - bus.emit(TestEvent::Stopped).unwrap(); - - assert_eq!( - subscription.try_recv(), - Ok(Some(TestEvent::SensorReading(42))) - ); - assert_eq!(subscription.try_recv(), Ok(Some(TestEvent::Stopped))); -} diff --git a/apps/firmware/tests/events/broadcast.rs b/apps/firmware/tests/events/broadcast.rs deleted file mode 100644 index 8a7cb1a5..00000000 --- a/apps/firmware/tests/events/broadcast.rs +++ /dev/null @@ -1,46 +0,0 @@ -use firmware::events::{Bus, ReceiveError}; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum TestEvent { - First, - Second, -} - -#[test] -fn every_subscription_receives_events_from_every_emitter_in_order() { - let emitter = Bus::new(); - let other_emitter = emitter.clone(); - let mut first = emitter.subscribe(); - let mut second = emitter.subscribe(); - - emitter.emit(TestEvent::First).unwrap(); - other_emitter.emit(TestEvent::Second).unwrap(); - - for subscription in [&mut first, &mut second] { - assert_eq!(subscription.try_recv(), Ok(Some(TestEvent::First))); - assert_eq!(subscription.try_recv(), Ok(Some(TestEvent::Second))); - assert_eq!(subscription.try_recv(), Ok(None)); - } -} - -#[test] -fn subscriptions_report_lag_without_exposing_channel_errors() { - let emitter = Bus::new(); - let mut subscription = emitter.subscribe(); - - for _ in 0..65 { - emitter.emit(TestEvent::First).unwrap(); - } - - assert_eq!(subscription.try_recv(), Err(ReceiveError::Lagged(1))); -} - -#[test] -fn failed_emission_returns_ownership_of_the_event() { - let emitter = Bus::new(); - - let returned = emitter.emit(TestEvent::First).unwrap_err().into_event(); - - assert_eq!(returned, TestEvent::First); - assert_eq!(emitter.subscriber_count(), 0); -} diff --git a/apps/firmware/tests/events/direct.rs b/apps/firmware/tests/events/direct.rs deleted file mode 100644 index c7215531..00000000 --- a/apps/firmware/tests/events/direct.rs +++ /dev/null @@ -1,21 +0,0 @@ -use firmware::events::direct_channel; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum TestEvent { - First, - Second, -} - -#[tokio::test] -async fn delivers_once_and_applies_backpressure() { - let (sender, mut receiver) = direct_channel(1); - sender.send(TestEvent::First).await.unwrap(); - - let blocked_send = tokio::spawn(async move { sender.send(TestEvent::Second).await }); - tokio::task::yield_now().await; - assert!(!blocked_send.is_finished()); - - assert_eq!(receiver.recv().await, Some(TestEvent::First)); - blocked_send.await.unwrap().unwrap(); - assert_eq!(receiver.recv().await, Some(TestEvent::Second)); -} diff --git a/apps/firmware/tests/events/main.rs b/apps/firmware/tests/events/main.rs deleted file mode 100644 index d7a9b4be..00000000 --- a/apps/firmware/tests/events/main.rs +++ /dev/null @@ -1,3 +0,0 @@ -mod api; -mod broadcast; -mod direct; diff --git a/apps/firmware/tests/hardware/events.rs b/apps/firmware/tests/hardware/events.rs deleted file mode 100644 index 72f86a6e..00000000 --- a/apps/firmware/tests/hardware/events.rs +++ /dev/null @@ -1,51 +0,0 @@ -use firmware::hardware::pins::{Button, ButtonEvent}; -use firmware::hardware::{BoardPosition, HardwareEvent, PieceEvent}; - -const BUTTONS: [Button; 12] = [ - Button::Up, - Button::Down, - Button::Left, - Button::Right, - Button::Ok, - Button::Reset, - Button::Pass, - Button::F1, - Button::F2, - Button::F3, - Button::F4, - Button::F5, -]; - -#[test] -fn every_button_and_transition_converts_to_a_hardware_event() { - for button in BUTTONS { - for event in [ButtonEvent::Pressed(button), ButtonEvent::Released(button)] { - assert_eq!(HardwareEvent::from(event), HardwareEvent::Button(event)); - } - } -} - -#[test] -fn every_board_position_round_trips_through_piece_events() { - for row in 0..BoardPosition::SIZE { - for column in 0..BoardPosition::SIZE { - let position = BoardPosition::new(row, column).unwrap(); - assert_eq!(position.row(), row); - assert_eq!(position.column(), column); - - for event in [PieceEvent::Placed(position), PieceEvent::Removed(position)] { - assert_eq!(event.position(), position); - assert_eq!(HardwareEvent::from(event), HardwareEvent::Piece(event)); - } - } - } -} - -#[test] -fn positions_outside_either_grid_axis_are_rejected() { - for coordinate in BoardPosition::SIZE..=u8::MAX { - assert_eq!(BoardPosition::new(coordinate, 0), None); - assert_eq!(BoardPosition::new(0, coordinate), None); - assert_eq!(BoardPosition::new(coordinate, coordinate), None); - } -} diff --git a/apps/firmware/tests/hardware/main.rs b/apps/firmware/tests/hardware/main.rs deleted file mode 100644 index 2363f963..00000000 --- a/apps/firmware/tests/hardware/main.rs +++ /dev/null @@ -1 +0,0 @@ -mod events; diff --git a/apps/firmware/tests/menu.rs b/apps/firmware/tests/menu.rs deleted file mode 100644 index 355ccf60..00000000 --- a/apps/firmware/tests/menu.rs +++ /dev/null @@ -1,125 +0,0 @@ -use firmware::menu::{ - CONNECTION_MENU, MAIN_MENU, PAIRING_MENU, PLAY_MENU, Request, SETTINGS_MENU, Screen, - UPDATE_MENU, -}; -use menu::{Event, Input, Menu, MenuState}; - -#[test] -fn main_menu_composes_every_product_submenu() { - let expected: [(&str, &Menu<'static, Request>); 5] = [ - ("Play", &PLAY_MENU), - ("Connection", &CONNECTION_MENU), - ("Pairing", &PAIRING_MENU), - ("Update", &UPDATE_MENU), - ("Settings", &SETTINGS_MENU), - ]; - - assert_eq!(MAIN_MENU.title(), "Main Menu"); - assert_eq!(MAIN_MENU.items().len(), expected.len()); - for (item, (label, submenu)) in MAIN_MENU.items().iter().zip(expected) { - assert_eq!(item.label(), label); - assert_eq!(item.as_submenu(), Some(submenu)); - assert_eq!(item.as_action(), None); - } -} - -#[test] -fn submenus_expose_image_derived_placeholder_requests() { - assert_menu( - &PLAY_MENU, - &[ - ("Local Game", Request::SelectLocalGame), - ("Online Game", Request::SelectOnlineGame), - ], - ); - assert_menu( - &CONNECTION_MENU, - &[ - ("Status", Request::ShowConnectionStatus), - ("Scan Wi-Fi", Request::ScanWifi), - ("Network Settings", Request::OpenNetworkSettings), - ], - ); - assert_menu( - &PAIRING_MENU, - &[ - ("Start Pairing", Request::StartPairing), - ("Session Status", Request::ShowSessionStatus), - ], - ); - assert_menu( - &UPDATE_MENU, - &[ - ("Check for Updates", Request::CheckForUpdates), - ("Install Update", Request::InstallUpdate), - ], - ); - assert!(SETTINGS_MENU.is_empty()); -} - -#[test] -fn every_submenu_is_navigable_without_requesting_external_work() { - let submenus = [ - &PLAY_MENU, - &CONNECTION_MENU, - &PAIRING_MENU, - &UPDATE_MENU, - &SETTINGS_MENU, - ]; - - for (index, submenu) in submenus.into_iter().enumerate() { - let mut state = MenuState::new(&MAIN_MENU); - for selected in 0..index { - assert_eq!( - state.handle(Input::Down), - Event::SelectionChanged { - selected: selected + 1, - } - ); - } - - assert_eq!(state.handle(Input::Right), Event::Opened { depth: 1 }); - assert_eq!(state.current_menu(), submenu); - assert_eq!(state.handle(Input::Left), Event::Closed { depth: 0 }); - assert_eq!(state.selected_index(), index); - } -} - -#[test] -fn placeholder_requests_are_immediate_and_non_blocking() { - let mut state = MenuState::new(&MAIN_MENU); - assert_eq!(state.handle(Input::Ok), Event::Opened { depth: 1 }); - assert_eq!( - state.handle(Input::Ok), - Event::Activated(&Request::SelectLocalGame) - ); - assert!(!state.is_blocked()); -} - -#[test] -fn status_bar_policy_covers_every_screen() { - let visible = [ - Screen::MainMenu, - Screen::ConnectionStatus, - Screen::GameSetup, - Screen::Pairing, - Screen::InGameHud, - Screen::Update, - Screen::Settings, - Screen::Result, - Screen::ErrorRecovery, - ]; - - assert!(!Screen::Splash.shows_status_bar()); - assert!(visible.into_iter().all(Screen::shows_status_bar)); -} - -fn assert_menu(menu: &Menu<'static, Request>, expected: &[(&str, Request)]) { - assert_eq!(menu.items().len(), expected.len()); - for (item, (label, request)) in menu.items().iter().zip(expected) { - assert_eq!(item.label(), *label); - assert_eq!(item.as_action(), Some(request)); - assert!(!item.is_blocking()); - assert_eq!(item.escape_action(), None); - } -} diff --git a/apps/firmware/tests/pins.rs b/apps/firmware/tests/pins.rs deleted file mode 100644 index cfcaab8e..00000000 --- a/apps/firmware/tests/pins.rs +++ /dev/null @@ -1,194 +0,0 @@ -use std::{collections::VecDeque, time::Duration}; - -use firmware::hardware::HardwareEventBus; -use firmware::hardware::pins::{ - BoardPins, Button, ButtonAction, ButtonPin, GPIO, InputOutput, Level, Output, Pin, ReadLevel, - Readable, Writable, WriteLevel, -}; - -struct Lines { - level: Level, -} - -impl ReadLevel for Lines { - type Error = (); - - fn read_level(&mut self, _: GPIO) -> Result { - Ok(self.level) - } -} - -impl WriteLevel for Lines { - type Error = (); - - fn write_level(&mut self, _: GPIO, level: Level) -> Result<(), Self::Error> { - self.level = level; - Ok(()) - } -} - -#[test] -fn board_interfaces_use_the_hardware_bcm_numbers() { - let pins = BoardPins::get(); - - assert_eq!( - [pins.i2c.data.bcm_number(), pins.i2c.clock.bcm_number()], - [2, 3] - ); - assert_eq!( - [pins.spi.data.bcm_number(), pins.spi.clock.bcm_number()], - [10, 11] - ); - assert_eq!( - [ - ( - pins.gpio.up_button.bcm_number(), - pins.gpio.up_button.button(), - ), - ( - pins.gpio.down_button.bcm_number(), - pins.gpio.down_button.button(), - ), - ( - pins.gpio.left_button.bcm_number(), - pins.gpio.left_button.button(), - ), - ( - pins.gpio.right_button.bcm_number(), - pins.gpio.right_button.button(), - ), - ( - pins.gpio.ok_button.bcm_number(), - pins.gpio.ok_button.button(), - ), - ( - pins.gpio.reset_button.bcm_number(), - pins.gpio.reset_button.button(), - ), - ( - pins.gpio.pass_button.bcm_number(), - pins.gpio.pass_button.button(), - ), - ( - pins.gpio.function_one_button.bcm_number(), - pins.gpio.function_one_button.button(), - ), - ( - pins.gpio.function_two_button.bcm_number(), - pins.gpio.function_two_button.button(), - ), - ( - pins.gpio.function_three_button.bcm_number(), - pins.gpio.function_three_button.button(), - ), - ( - pins.gpio.function_four_button.bcm_number(), - pins.gpio.function_four_button.button(), - ), - ( - pins.gpio.function_five_button.bcm_number(), - pins.gpio.function_five_button.button(), - ), - ], - [ - (5, Button::Up), - (6, Button::Down), - (12, Button::Left), - (13, Button::Right), - (16, Button::Ok), - (17, Button::Reset), - (19, Button::Pass), - (20, Button::F1), - (21, Button::F2), - (22, Button::F3), - (23, Button::F4), - (24, Button::F5), - ] - ); -} - -#[test] -fn capabilities_are_enforced_by_pin_types() { - fn read(_: &Pin) {} - fn write(_: &Pin) {} - fn gpio(_: &ButtonPin<5>) {} - fn i2c(_: &Pin<2, InputOutput>) {} - fn spi(_: &Pin<10, Output>) {} - - let pins = BoardPins::get(); - - read(&pins.i2c.data); - write(&pins.spi.data); - write(&pins.i2c.data); - gpio(&pins.gpio.up_button); - i2c(&pins.i2c.data); - spi(&pins.spi.data); -} - -#[test] -fn pins_read_and_write_levels() { - let pins = BoardPins::get(); - let mut lines = Lines { level: Level::Low }; - - assert_eq!(pins.gpio.up_button.read_level(&mut lines), Ok(Level::Low)); - pins.spi.data.set_level(&mut lines, Level::High).unwrap(); - assert_eq!(lines.level, Level::High); -} - -struct SequenceReader { - levels: VecDeque, -} - -impl ReadLevel for SequenceReader { - type Error = (); - - fn read_level(&mut self, _: GPIO) -> Result { - self.levels.pop_front().ok_or(()) - } -} - -#[test] -fn starting_a_button_subscription_without_a_runtime_returns_an_error() { - let pins = BoardPins::get(); - let events = HardwareEventBus::new(); - let reader = Lines { level: Level::High }; - - let error = pins - .gpio - .down_button - .start_subscription(reader, &events) - .unwrap_err(); - - assert_eq!( - error.to_string(), - "button subscriptions require an active Tokio runtime" - ); -} - -#[tokio::test(start_paused = true)] -async fn button_pins_poll_debounce_and_deliver_physical_transitions() { - let pins = BoardPins::get(); - let events = HardwareEventBus::new(); - let reader = SequenceReader { - levels: VecDeque::from([ - Level::High, - Level::Low, - Level::Low, - Level::Low, - Level::Low, - Level::Low, - ]), - }; - let mut next = pins - .gpio - .down_button - .start_subscription(reader, &events) - .unwrap(); - - let action = tokio::time::timeout(Duration::from_millis(100), next.on_message()) - .await - .expect("button poller should produce an action") - .unwrap(); - - assert_eq!(action, ButtonAction::Pressed); -}