diff --git a/tests/e2e.rs b/tests/e2e.rs
index e4a6ab2..2a6ae46 100644
--- a/tests/e2e.rs
+++ b/tests/e2e.rs
@@ -1,14 +1,62 @@
//! End-to-end tests exercising R701's public API against the emulated
//! device in `mock`.
-#![allow(clippy::unwrap_used)]
-
mod mock;
use r701::R701;
+use tokio_stream::StreamExt;
#[tokio::test]
async fn connects_and_pings_successfully() {
let addr = mock::spawn().await;
assert!(R701::connect(addr).await.is_ok());
}
+
+#[tokio::test]
+#[allow(clippy::unwrap_used)]
+async fn gets_employee_name() {
+ let addr = mock::spawn().await;
+ let r701 = R701::connect(addr).await.unwrap();
+ let expected_name = mock::fake_users()[&1].clone();
+
+ match r701.get_name(1).await.unwrap() {
+ Some(Ok(name)) => assert_eq!(name, expected_name),
+ other => panic!("expected Some(Ok({expected_name:?})), got {other:?}"),
+ }
+}
+
+#[tokio::test]
+#[allow(clippy::unwrap_used)]
+async fn gets_none_for_unknown_employee() {
+ let addr = mock::spawn().await;
+ let r701 = R701::connect(addr).await.unwrap();
+
+ assert_eq!(r701.get_name(999).await.unwrap(), None);
+}
+
+#[tokio::test]
+#[allow(clippy::unwrap_used, clippy::cast_possible_truncation)]
+async fn gets_total_record_count() {
+ let addr = mock::spawn().await;
+ let r701 = R701::connect(addr).await.unwrap();
+ let expected_count = mock::fake_records().len() as u16;
+
+ assert_eq!(r701.get_total_record_count().await.unwrap(), expected_count);
+}
+
+#[tokio::test]
+#[allow(clippy::unwrap_used)]
+async fn streams_records() {
+ let addr = mock::spawn().await;
+ let r701 = R701::connect(addr).await.unwrap();
+ // The device streams newest first, the reverse of fake_records' order
+ let expected_records: Vec<_> = mock::fake_records().into_iter().rev().collect();
+
+ let mut records = r701.into_record_iter().await.unwrap();
+ for expected in expected_records {
+ let record = records.next().await.unwrap().unwrap();
+ assert_eq!(record, expected);
+ }
+
+ assert!(records.next().await.is_none());
+}
diff --git a/tests/mock/mod.rs b/tests/mock/mod.rs
index af37759..b55492c 100644
--- a/tests/mock/mod.rs
+++ b/tests/mock/mod.rs
@@ -2,31 +2,59 @@
//! client against it, grounded in the protocol documented at
//! .
-use chrono::{Datelike, Local, TimeZone, Timelike};
+use chrono::{Datelike, Duration, Local, TimeZone, Timelike};
use r701::{Clock, Record};
use std::collections::HashMap;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
/// The employee names the fake device reports
-fn fake_users() -> HashMap {
- HashMap::from([(1, "mario".to_string())])
+pub fn fake_users() -> HashMap {
+ HashMap::from([(1, "alice".to_string()), (2, "bob".to_string())])
}
-/// The attendance records the fake device reports
-fn fake_records() -> Vec {
- vec![Record {
- employee_id: 1,
- clock: Clock::FirstIn,
- datetime: Local
- .with_ymd_and_hms(1970, 1, 1, 0, 0, 0)
- .single()
- .unwrap(),
- }]
+/// The attendance records the fake device reports: a full year of classic
+/// workday punches (09:00 - 13:00, 14:00 - 18:00) for the employees in
+/// `fake_users()`, staggered by punch time rather than grouped by employee.
+#[allow(clippy::unwrap_used)]
+pub fn fake_records() -> Vec {
+ let first_day = Local
+ .with_ymd_and_hms(2026, 1, 1, 9, 0, 0)
+ .single()
+ .unwrap();
+
+ let mut employee_ids: Vec = fake_users().into_keys().collect();
+ employee_ids.sort_unstable();
+
+ let mut records = Vec::new();
+ for day in 0..365 {
+ let morning_in = first_day + Duration::days(day);
+ let morning_out = morning_in + Duration::hours(4);
+ let afternoon_in = morning_out + Duration::hours(1);
+ let afternoon_out = afternoon_in + Duration::hours(4);
+
+ for (clock, datetime) in [
+ (Clock::FirstIn, morning_in),
+ (Clock::FirstOut, morning_out),
+ (Clock::SecondIn, afternoon_in),
+ (Clock::SecondOut, afternoon_out),
+ ] {
+ for &employee_id in &employee_ids {
+ records.push(Record {
+ employee_id,
+ clock,
+ datetime,
+ });
+ }
+ }
+ }
+
+ records
}
/// Starts a fake R701 device in the background and returns the address to
/// connect to.
+#[allow(clippy::unwrap_used)]
pub async fn spawn() -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap().to_string();
@@ -70,6 +98,7 @@ pub async fn spawn() -> String {
/// Builds the 34-byte get-name response: a 12-byte header (whether the
/// employee was found), followed by their name (if found) null-padded to
/// 10 bytes, followed by 12 don't-care bytes.
+#[allow(clippy::unwrap_used)]
fn get_name_response(payload: &[u8]) -> Vec {
let employee_id = u32::from_le_bytes(payload[2..6].try_into().unwrap());
let mut response = [0u8; 34];
@@ -99,6 +128,7 @@ fn get_total_record_count_response() -> Vec {
/// requested 1024-byte block of records (`ff`-padded past the last real
/// one, since records don't align to 1024-byte block boundaries), and 2
/// trailing bytes.
+#[allow(clippy::unwrap_used)]
fn get_record_bytes_response(payload: &[u8]) -> Vec {
let sequence_number = u16::from_le_bytes(payload[8..10].try_into().unwrap());
let all_bytes: Vec = fake_records().iter().flat_map(encode_record).collect();