Build strict NUnit parity harness
Some checks failed
Rust API gates / api-gates (push) Has been cancelled
Some checks failed
Rust API gates / api-gates (push) Has been cancelled
This commit is contained in:
2601
tests/PARITY.md
2601
tests/PARITY.md
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,275 @@
|
||||
//! Shared failure marker used by generated compatibility tests.
|
||||
//! Shared deterministic fixtures used by translated compatibility tests.
|
||||
|
||||
/// Fails a not-yet-translated upstream test while retaining parity metadata.
|
||||
use std::collections::BTreeMap;
|
||||
use std::io;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Asserts equality within the exact absolute tolerance carried by an upstream test.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Always, with the retained upstream test identity.
|
||||
/// Panics when the tolerance is invalid or the values differ beyond it.
|
||||
#[track_caller]
|
||||
pub fn pending(source: &str, line: u64, test: &str, attribute: &str, body_sha256: &str) -> ! {
|
||||
panic!(
|
||||
"pending LibreMetaverse parity test {test} ({attribute}) from {source}:{line}; C# body sha256={body_sha256}"
|
||||
)
|
||||
pub fn assert_close(actual: f64, expected: f64, tolerance: f64) {
|
||||
assert!(
|
||||
tolerance.is_finite() && tolerance >= 0.0,
|
||||
"tolerance must be finite and non-negative"
|
||||
);
|
||||
assert!(
|
||||
(actual - expected).abs() <= tolerance,
|
||||
"expected {expected:?} +/- {tolerance:?}, got {actual:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Asserts byte equality and reports the first differing offset.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics when the byte slices differ.
|
||||
#[track_caller]
|
||||
pub fn assert_bytes_eq(actual: &[u8], expected: &[u8]) {
|
||||
if actual == expected {
|
||||
return;
|
||||
}
|
||||
let offset = actual
|
||||
.iter()
|
||||
.zip(expected)
|
||||
.position(|(actual, expected)| actual != expected)
|
||||
.unwrap_or_else(|| actual.len().min(expected.len()));
|
||||
panic!(
|
||||
"byte fixtures differ at offset {offset}: expected {} bytes, got {} bytes",
|
||||
expected.len(),
|
||||
actual.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// Decodes a whitespace-separated or contiguous hexadecimal byte fixture.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error for an odd digit count or a non-hexadecimal digit.
|
||||
pub fn decode_hex(input: &str) -> Result<Vec<u8>, String> {
|
||||
let digits: Vec<_> = input
|
||||
.bytes()
|
||||
.filter(|byte| !byte.is_ascii_whitespace())
|
||||
.collect();
|
||||
if digits.len() % 2 != 0 {
|
||||
return Err("hex fixture has an odd number of digits".into());
|
||||
}
|
||||
digits
|
||||
.chunks_exact(2)
|
||||
.enumerate()
|
||||
.map(|(index, pair)| {
|
||||
let high = hex_digit(pair[0]);
|
||||
let low = hex_digit(pair[1]);
|
||||
high.zip(low)
|
||||
.map(|(high, low)| high << 4 | low)
|
||||
.ok_or_else(|| format!("invalid hex byte at digit {}", index * 2))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn hex_digit(byte: u8) -> Option<u8> {
|
||||
match byte {
|
||||
b'0'..=b'9' => Some(byte - b'0'),
|
||||
b'a'..=b'f' => Some(byte - b'a' + 10),
|
||||
b'A'..=b'F' => Some(byte - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves test data below a fixture root without permitting path traversal.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`io::ErrorKind::InvalidInput`] for absolute or parent-relative paths.
|
||||
pub fn test_data_path(root: &Path, relative: &Path) -> io::Result<PathBuf> {
|
||||
if relative.components().any(|component| {
|
||||
matches!(
|
||||
component,
|
||||
Component::ParentDir | Component::RootDir | Component::Prefix(_)
|
||||
)
|
||||
}) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"test data path must stay below its fixture root",
|
||||
));
|
||||
}
|
||||
Ok(root.join(relative))
|
||||
}
|
||||
|
||||
/// Loads a test-data file through [`test_data_path`].
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns path validation and file-read errors.
|
||||
pub fn load_test_data(root: &Path, relative: &Path) -> io::Result<Vec<u8>> {
|
||||
std::fs::read(test_data_path(root, relative)?)
|
||||
}
|
||||
|
||||
/// A cloneable clock advanced only by the test.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ManualClock(Arc<Mutex<Duration>>);
|
||||
|
||||
impl ManualClock {
|
||||
/// Returns the deterministic elapsed time.
|
||||
#[must_use]
|
||||
pub fn now(&self) -> Duration {
|
||||
*self
|
||||
.0
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// Advances the clock and returns its new value.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the resulting duration exceeds [`Duration::MAX`].
|
||||
#[must_use]
|
||||
pub fn advance(&self, duration: Duration) -> Duration {
|
||||
let mut now = self
|
||||
.0
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*now += duration;
|
||||
*now
|
||||
}
|
||||
}
|
||||
|
||||
/// A recorded deterministic HTTP-like request.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RecordedRequest {
|
||||
pub method: String,
|
||||
pub uri: String,
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
/// A canned deterministic HTTP-like response.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FakeResponse {
|
||||
pub status: u16,
|
||||
pub content_type: String,
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
/// An in-memory exact-URI/path fake with ordered request capture.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct FakeNetwork {
|
||||
exact: Mutex<BTreeMap<(String, String), FakeResponse>>,
|
||||
paths: Mutex<BTreeMap<(String, String), FakeResponse>>,
|
||||
requests: Mutex<Vec<RecordedRequest>>,
|
||||
}
|
||||
|
||||
impl FakeNetwork {
|
||||
/// Adds an exact method/URI response.
|
||||
pub fn add_response(&self, method: &str, uri: &str, response: FakeResponse) {
|
||||
self.exact
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.insert((method.to_owned(), uri.to_owned()), response);
|
||||
}
|
||||
|
||||
/// Adds a response matched after removing the request query string.
|
||||
pub fn add_path_response(&self, method: &str, uri_without_query: &str, response: FakeResponse) {
|
||||
self.paths
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.insert((method.to_owned(), uri_without_query.to_owned()), response);
|
||||
}
|
||||
|
||||
/// Records a request and returns its configured response, or a deterministic 404.
|
||||
pub fn send(&self, method: &str, uri: &str, body: impl Into<Vec<u8>>) -> FakeResponse {
|
||||
self.requests
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.push(RecordedRequest {
|
||||
method: method.to_owned(),
|
||||
uri: uri.to_owned(),
|
||||
body: body.into(),
|
||||
});
|
||||
let key = (method.to_owned(), uri.to_owned());
|
||||
if let Some(response) = self
|
||||
.exact
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.get(&key)
|
||||
{
|
||||
return response.clone();
|
||||
}
|
||||
let path_key = (
|
||||
method.to_owned(),
|
||||
uri.split('?').next().unwrap_or(uri).to_owned(),
|
||||
);
|
||||
self.paths
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.get(&path_key)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| FakeResponse {
|
||||
status: 404,
|
||||
content_type: "application/octet-stream".into(),
|
||||
body: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns all captured requests in send order.
|
||||
#[must_use]
|
||||
pub fn requests(&self) -> Vec<RecordedRequest> {
|
||||
self.requests
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deterministic_harness_covers_clock_network_and_bytes() {
|
||||
assert_close(1.001, 1.0, 0.01);
|
||||
assert_bytes_eq(&decode_hex("00 ff 2A").unwrap(), &[0, 255, 42]);
|
||||
assert!(decode_hex("é").is_err());
|
||||
|
||||
let clock = ManualClock::default();
|
||||
assert_eq!(
|
||||
clock.advance(Duration::from_millis(25)),
|
||||
Duration::from_millis(25)
|
||||
);
|
||||
assert_eq!(clock.now(), Duration::from_millis(25));
|
||||
|
||||
let network = FakeNetwork::default();
|
||||
network.add_path_response(
|
||||
"GET",
|
||||
"https://example.test/cap",
|
||||
FakeResponse {
|
||||
status: 200,
|
||||
content_type: "application/llsd+xml".into(),
|
||||
body: b"fixture".to_vec(),
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
network.send("GET", "https://example.test/cap?tid=1", []),
|
||||
FakeResponse {
|
||||
status: 200,
|
||||
content_type: "application/llsd+xml".into(),
|
||||
body: b"fixture".to_vec(),
|
||||
}
|
||||
);
|
||||
assert_eq!(network.requests()[0].uri, "https://example.test/cap?tid=1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_data_rejects_parent_traversal() {
|
||||
assert_eq!(
|
||||
test_data_path(Path::new("fixtures"), Path::new("../secret"))
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
io::ErrorKind::InvalidInput
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user