Added an e2e test harness with a fake R701 device
Check, format and test / build (push) Successful in 47s
Check, format and test / build (push) Successful in 47s
tests/mock/mod.rs emulates the device's TCP protocol (ping, get_name, get_total_record_count, get_record_bytes), grounded in the reverse engineering notes at https://nicolabelluti.me/posts/2024/05/studying-a-communication-protocol/, so tests/e2e.rs can exercise the real R701 client against it instead of needing a physical reader. Starts with one test (connect + ping); more scenarios (get_name, record streaming, error cases) to follow. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Generated
+12
@@ -251,9 +251,21 @@ dependencies = [
|
|||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"tokio-macros",
|
||||||
"windows-sys",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-macros"
|
||||||
|
version = "2.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-stream"
|
name = "tokio-stream"
|
||||||
version = "0.1.19"
|
version = "0.1.19"
|
||||||
|
|||||||
@@ -23,3 +23,6 @@ unwrap_used = "deny"
|
|||||||
enum_glob_use = { level = "deny", priority = 1 }
|
enum_glob_use = { level = "deny", priority = 1 }
|
||||||
pedantic = { level = "deny", priority = -1 }
|
pedantic = { level = "deny", priority = -1 }
|
||||||
nursery = { level = "deny", priority = -1 }
|
nursery = { level = "deny", priority = -1 }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { version = "1.53.1", features = ["rt", "macros", "net"] }
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
//! End-to-end tests exercising R701's public API against the emulated
|
||||||
|
//! device in `mock`.
|
||||||
|
|
||||||
|
#![allow(clippy::unwrap_used)]
|
||||||
|
|
||||||
|
mod mock;
|
||||||
|
|
||||||
|
use r701::R701;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn connects_and_pings_successfully() {
|
||||||
|
let addr = mock::spawn().await;
|
||||||
|
assert!(R701::connect(addr).await.is_ok());
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
//! Emulates an R701 device so `tests/e2e.rs` can exercise the real `R701`
|
||||||
|
//! client against it, grounded in the protocol documented at
|
||||||
|
//! <https://nicolabelluti.me/posts/2024/05/studying-a-communication-protocol/>.
|
||||||
|
|
||||||
|
use chrono::{Datelike, 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<u32, String> {
|
||||||
|
HashMap::from([(1, "mario".to_string())])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The attendance records the fake device reports
|
||||||
|
fn fake_records() -> Vec<Record> {
|
||||||
|
vec![Record {
|
||||||
|
employee_id: 1,
|
||||||
|
clock: Clock::FirstIn,
|
||||||
|
datetime: Local
|
||||||
|
.with_ymd_and_hms(1970, 1, 1, 0, 0, 0)
|
||||||
|
.single()
|
||||||
|
.unwrap(),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts a fake R701 device in the background and returns the address to
|
||||||
|
/// connect to.
|
||||||
|
pub async fn spawn() -> String {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap().to_string();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let (mut socket, _) = listener.accept().await.unwrap();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let mut request = [0u8; 16];
|
||||||
|
let Ok(bytes_read) = socket.read(&mut request).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if bytes_read == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = if bytes_read < 16 {
|
||||||
|
vec![0xaa, 0x55, 0x01, 0, 0, 0, 0, 0, 0, 0]
|
||||||
|
} else if request[..2] != [0x55, 0xaa] {
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
let payload = &request[2..14];
|
||||||
|
match (payload[0], payload[1]) {
|
||||||
|
(0x01, 0x80) => vec![0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0],
|
||||||
|
(0x01, 0xc7) => get_name_response(payload),
|
||||||
|
(0x01, 0xb4) => get_total_record_count_response(),
|
||||||
|
(0x01, 0xa4) => get_record_bytes_response(payload),
|
||||||
|
_ => vec![0xaa, 0x55, 0x01, 0, 0, 0, 0, 0, 0, 0],
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if socket.write_all(&response).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
addr
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
fn get_name_response(payload: &[u8]) -> Vec<u8> {
|
||||||
|
let employee_id = u32::from_le_bytes(payload[2..6].try_into().unwrap());
|
||||||
|
let mut response = [0u8; 34];
|
||||||
|
|
||||||
|
if let Some(name) = fake_users().get(&employee_id) {
|
||||||
|
response[..12].clone_from_slice(&[0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]);
|
||||||
|
let name_bytes = name.as_bytes();
|
||||||
|
let len = name_bytes.len().min(10);
|
||||||
|
response[12..12 + len].clone_from_slice(&name_bytes[..len]);
|
||||||
|
} else {
|
||||||
|
response[..12].clone_from_slice(&[0xaa, 0x55, 0x01, 0, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]);
|
||||||
|
}
|
||||||
|
|
||||||
|
response.to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the 10-byte get-total-record-count response.
|
||||||
|
#[allow(clippy::cast_possible_truncation)]
|
||||||
|
fn get_total_record_count_response() -> Vec<u8> {
|
||||||
|
let count = fake_records().len() as u16;
|
||||||
|
let mut response = [0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0];
|
||||||
|
response[4..6].clone_from_slice(&count.to_le_bytes());
|
||||||
|
response.to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the 1038-byte get-record-bytes response: a 12-byte header, the
|
||||||
|
/// 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.
|
||||||
|
fn get_record_bytes_response(payload: &[u8]) -> Vec<u8> {
|
||||||
|
let sequence_number = u16::from_le_bytes(payload[8..10].try_into().unwrap());
|
||||||
|
let all_bytes: Vec<u8> = fake_records().iter().flat_map(encode_record).collect();
|
||||||
|
|
||||||
|
let start = sequence_number as usize * 1024;
|
||||||
|
let mut block = [0xffu8; 1024];
|
||||||
|
let available = all_bytes.len().saturating_sub(start).min(1024);
|
||||||
|
block[..available].clone_from_slice(&all_bytes[start..start + available]);
|
||||||
|
|
||||||
|
let mut response = vec![0u8; 1038];
|
||||||
|
response[..12].clone_from_slice(&[0xaa, 0x55, 0x01, 0x01, 0, 0, 0, 0, 0, 0, 0x55, 0xaa]);
|
||||||
|
response[12..1036].clone_from_slice(&block);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes a [`Record`] back into its 12-byte wire representation (the
|
||||||
|
/// inverse of `Record`'s `TryFrom<&[u8]>`). The "unknown" bytes that
|
||||||
|
/// `Record` doesn't decode are left as 0, since they only need to
|
||||||
|
/// round-trip through our own parsing, not match a real device capture.
|
||||||
|
fn encode_record(record: &Record) -> [u8; 12] {
|
||||||
|
let mut bytes = [0u8; 12];
|
||||||
|
|
||||||
|
let clock_bits: u8 = match record.clock {
|
||||||
|
Clock::FirstIn => 0,
|
||||||
|
Clock::FirstOut => 1,
|
||||||
|
Clock::SecondIn => 2,
|
||||||
|
Clock::SecondOut => 3,
|
||||||
|
};
|
||||||
|
bytes[1] = clock_bits << 6;
|
||||||
|
|
||||||
|
bytes[4..8].copy_from_slice(&record.employee_id.to_le_bytes());
|
||||||
|
|
||||||
|
#[allow(clippy::cast_sign_loss)]
|
||||||
|
let year = record.datetime.year() as u32 & 0x0fff;
|
||||||
|
let month = record.datetime.month() & 0x0f;
|
||||||
|
let day = record.datetime.day() & 0x1f;
|
||||||
|
let hour = record.datetime.hour() & 0x1f;
|
||||||
|
let minute = record.datetime.minute() & 0x3f;
|
||||||
|
let packed = year | (month << 12) | (day << 16) | (hour << 21) | (minute << 26);
|
||||||
|
bytes[8..12].copy_from_slice(&packed.to_le_bytes());
|
||||||
|
|
||||||
|
bytes
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user