Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/firmware/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions apps/firmware/src/connectivity/credentials.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
}
15 changes: 0 additions & 15 deletions apps/firmware/src/connectivity/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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])");
}
}
15 changes: 15 additions & 0 deletions apps/firmware/src/connectivity/ssid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
25 changes: 25 additions & 0 deletions apps/firmware/src/events/direct.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,28 @@ impl<T> fmt::Display for DirectSendError<T> {
}

impl<T: fmt::Debug> StdError for DirectSendError<T> {}

#[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));
}
}
66 changes: 66 additions & 0 deletions apps/firmware/src/events/pubsub.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,69 @@ impl From<broadcast::error::RecvError> 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::<TestEvent>::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);
}
}
62 changes: 62 additions & 0 deletions apps/firmware/src/hardware/buttons.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,65 @@ async fn poll<R>(
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<Level>,
}

impl ReadLevel for SequenceReader {
type Error = ();

fn read_level(&mut self, _: GPIO) -> Result<Level, Self::Error> {
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);
}
}
28 changes: 28 additions & 0 deletions apps/firmware/src/hardware/events/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,31 @@ impl From<PieceEvent> 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));
}
}
}
}
31 changes: 31 additions & 0 deletions apps/firmware/src/hardware/events/piece.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Loading
Loading