Add durable NUnit parity harness
Some checks failed
Rust API gates / api-gates (push) Has been cancelled

This commit is contained in:
2026-08-08 13:28:42 +02:00
parent c8a953996e
commit 39ee61525a
10 changed files with 20967 additions and 4061 deletions

View File

@@ -17,6 +17,8 @@ jobs:
python3 tools/generate_rust_mapping.py --check python3 tools/generate_rust_mapping.py --check
python3 tools/generate_api_shims.py --check python3 tools/generate_api_shims.py --check
python3 tools/check_api_coverage.py python3 tools/check_api_coverage.py
python3 tools/check_test_parity.py
python3 -m unittest discover -s tools -p 'test_*.py'
- name: Formatting - name: Formatting
run: cargo fmt --all -- --check run: cargo fmt --all -- --check
- name: Workspace check - name: Workspace check

View File

@@ -32,6 +32,13 @@ cargo build --workspace
cargo test --workspace --no-run cargo test --workspace --no-run
python3 tools/generate_rust_mapping.py --check python3 tools/generate_rust_mapping.py --check
python3 tools/generate_api_shims.py --check python3 tools/generate_api_shims.py --check
python3 tools/check_test_parity.py
``` ```
`tests/upstream-tests.json` is the machine-readable NUnit parity catalog.
Translated tests live in hand-written Rust files with the `parity-case` marker
documented in `tests/PARITY.md`; `python3 tools/generate_surface.py --check`
verifies the catalog against the pinned adjacent LibreMetaverse checkout without
overwriting those files.
Running `cargo test --workspace` is intentionally red during the shim stage. Running `cargo test --workspace` is intentionally red during the shim stage.

View File

@@ -322,6 +322,14 @@ observations, assertions, tolerances and expected error/event behavior. Shared
C# test helpers become shared Rust test helpers; embedded fixtures and literal C# test helpers become shared Rust test helpers; embedded fixtures and literal
payloads are copied with license/source attribution and byte hashes. payloads are copied with license/source attribution and byte hashes.
The parity harness is in place: every invocation has a stable source/case ID,
parameter identity, body hash, category, fixture dependency list, Rust location,
and semantic-review status. Reviewed tests are identified by `parity-case`
markers in hand-written Rust files, so regeneration writes only unresolved
placeholders and fails on body drift. The checked-in audit reports pending,
translated, ignored-live, benchmark, drifted, missing, duplicate, stale, and
unreviewed cases; the initial handover contains 1,295 unreviewed cases.
The Rust tests must call the public APIs rather than internal replacements. The Rust tests must call the public APIs rather than internal replacements.
Where the C# tests call internal members through friend-assembly access, record Where the C# tests call internal members through friend-assembly access, record
that fact and place equivalent Rust unit tests inside the owning crate without that fact and place equivalent Rust unit tests inside the owning crate without

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,10 @@
//! Shared failure marker used by generated compatibility tests. //! Shared deterministic fixtures used by translated compatibility tests.
use std::collections::BTreeMap;
use std::io;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// Fails a not-yet-translated upstream test while retaining parity metadata. /// Fails a not-yet-translated upstream test while retaining parity metadata.
/// ///
@@ -6,8 +12,283 @@
/// ///
/// Always, with the retained upstream test identity. /// Always, with the retained upstream test identity.
#[track_caller] #[track_caller]
pub fn pending(source: &str, line: u64, test: &str, attribute: &str, body_sha256: &str) -> ! { pub fn pending(
case_id: &str,
source: &str,
line: u64,
test: &str,
attribute: &str,
body_sha256: &str,
) -> ! {
panic!( panic!(
"pending LibreMetaverse parity test {test} ({attribute}) from {source}:{line}; C# body sha256={body_sha256}" "pending LibreMetaverse parity case {case_id}: {test} ({attribute}) from {source}:{line}; C# body sha256={body_sha256}"
) )
} }
/// Asserts equality within the exact absolute tolerance carried by an upstream test.
///
/// # Panics
///
/// Panics when the tolerance is invalid or the values differ beyond it.
#[track_caller]
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

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env python3
"""Audit the checked-in NUnit-to-Rust parity catalog and source markers."""
from __future__ import annotations
import argparse
from pathlib import Path
from generate_surface import check_test_parity
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--root", type=Path, default=Path("."))
parser.add_argument("--require-reviewed", action="store_true")
args = parser.parse_args()
report = check_test_parity(args.root.resolve(), args.require_reviewed)
print(
"test parity: "
+ ", ".join(f"{name}={count}" for name, count in report.items())
)
if __name__ == "__main__":
main()

View File

@@ -13,7 +13,9 @@ import hashlib
import json import json
import keyword import keyword
import re import re
from collections import defaultdict import subprocess
import tempfile
from collections import Counter, defaultdict
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@@ -51,13 +53,29 @@ TYPE_RE = re.compile(
) )
NAMESPACE_RE = re.compile(r"^\s*namespace\s+([A-Za-z_][A-Za-z0-9_.]*)", re.MULTILINE) NAMESPACE_RE = re.compile(r"^\s*namespace\s+([A-Za-z_][A-Za-z0-9_.]*)", re.MULTILINE)
TEST_ATTR_RE = re.compile(r"\[(Test|TestCase)(?:\((.*?)\))?\]", re.DOTALL) TEST_ATTR_RE = re.compile(r"\[(Test|TestCase)(?:\((.*?)\))?\]", re.DOTALL)
CATEGORY_RE = re.compile(r'\[Category\("([^"]+)"\)\]')
METHOD_RE = re.compile( METHOD_RE = re.compile(
r"\b(?:public|internal)\s+(?:static\s+)?(?:async\s+)?" r"\b(?:public|internal)\s+(?:static\s+)?(?:async\s+)?"
r"(?:void|Task(?:\s*<[^>]+>)?|ValueTask(?:\s*<[^>]+>)?)\s+" r"(?:void|Task(?:\s*<[^>]+>)?|ValueTask(?:\s*<[^>]+>)?)\s+"
r"([A-Za-z_][A-Za-z0-9_]*)\s*\(", r"([A-Za-z_][A-Za-z0-9_]*)\s*\(",
re.DOTALL, re.DOTALL,
) )
CLASS_RE = re.compile(r"\b(?:public|internal)\s+(?:sealed\s+|partial\s+)?class\s+([A-Za-z_][A-Za-z0-9_]*)") CLASS_RE = re.compile(
r"\b(?:(?:public|internal|private|protected|sealed|partial|abstract|static)\s+)*"
r"class\s+([A-Za-z_][A-Za-z0-9_]*)"
)
PARITY_MARKER_RE = re.compile(
r"^// parity-case: (?P<id>\S+) (?P<body_sha256>[0-9a-f]{64}) "
r"(?P<status>pending|translated|ignored-live|benchmark)$",
re.MULTILINE,
)
RUST_TEST_RE = re.compile(r"(?:#\[[^\]]+\]\s*)*fn\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(", re.MULTILINE)
EXPECTED_TESTS = 1295
PARITY_FILES = (
Path("tests/compat/tests/generated_parity.rs"),
Path("tests/PARITY.md"),
Path("tests/upstream-tests.json"),
)
def snake(name: str) -> str: def snake(name: str) -> str:
@@ -200,9 +218,28 @@ def generate_apis(upstream: Path, output: Path) -> tuple[int, int]:
return total_types, total_members return total_types, total_members
def enclosing_class(text: str, position: int) -> str: def enclosing_class_match(text: str, position: int) -> re.Match[str] | None:
matches = [m for m in CLASS_RE.finditer(text) if m.start() <= position] containing: list[re.Match[str]] = []
return matches[-1].group(1) if matches else "UnknownFixture" for match in CLASS_RE.finditer(text, 0, position):
brace = text.find("{", match.end())
if brace == -1 or brace >= position:
continue
depth = 0
for index in range(brace, len(text)):
if text[index] == "{":
depth += 1
elif text[index] == "}":
depth -= 1
if depth == 0:
if position < index:
containing.append(match)
break
return containing[-1] if containing else None
def attributes_before(text: str, position: int) -> str:
match = re.search(r"((?:\s*\[[^\]]+\]\s*)+)$", text[:position], re.DOTALL)
return match.group(1) if match else ""
def method_body_hash(text: str, method_start: int) -> str: def method_body_hash(text: str, method_start: int) -> str:
@@ -227,13 +264,41 @@ def method_body_hash(text: str, method_start: int) -> str:
return hashlib.sha256(body.encode()).hexdigest() return hashlib.sha256(body.encode()).hexdigest()
def generate_tests(upstream: Path, output: Path) -> int: def stable_case_id(source: str, fixture: str, method: str, attribute: str, parameters: str | None) -> str:
test_id = f"{source}::{fixture}.{method}"
if attribute == "Test":
return f"{test_id}::test"
normalized = " ".join((parameters or "").split())
digest = hashlib.sha256(normalized.encode()).hexdigest()[:16]
return f"{test_id}::case:{digest}"
def helper_types(roots: tuple[Path, ...], upstream: Path) -> dict[str, str]:
helpers: dict[str, str] = {}
for root in roots:
for path in source_files(root):
text = path.read_text(encoding="utf-8-sig")
if TEST_ATTR_RE.search(text):
continue
relative = path.relative_to(upstream).as_posix()
for match in CLASS_RE.finditer(text):
helpers.setdefault(match.group(1), relative)
return helpers
def extract_tests(upstream: Path) -> list[dict[str, object]]:
roots = (upstream / "LibreMetaverse.Tests", upstream / "LibreMetaverse.Rendering.Tests") roots = (upstream / "LibreMetaverse.Tests", upstream / "LibreMetaverse.Rendering.Tests")
helpers = helper_types(roots, upstream)
tests: list[dict[str, object]] = [] tests: list[dict[str, object]] = []
for root in roots: for root in roots:
for path in source_files(root): for path in source_files(root):
text = path.read_text(encoding="utf-8-sig") text = path.read_text(encoding="utf-8-sig")
relative = path.relative_to(upstream).as_posix() relative = path.relative_to(upstream).as_posix()
dependencies = sorted(
helper_path
for helper, helper_path in helpers.items()
if helper_path != relative and re.search(rf"\b{re.escape(helper)}\b", text)
)
attrs = list(TEST_ATTR_RE.finditer(text)) attrs = list(TEST_ATTR_RE.finditer(text))
for attr_index, attr in enumerate(attrs): for attr_index, attr in enumerate(attrs):
next_attr = attrs[attr_index + 1].start() if attr_index + 1 < len(attrs) else len(text) next_attr = attrs[attr_index + 1].start() if attr_index + 1 < len(attrs) else len(text)
@@ -243,60 +308,279 @@ def generate_tests(upstream: Path, output: Path) -> int:
if method is None: if method is None:
raise RuntimeError(f"No test method after {relative}:{text.count(chr(10), 0, attr.start()) + 1}") raise RuntimeError(f"No test method after {relative}:{text.count(chr(10), 0, attr.start()) + 1}")
name = method.group(1) name = method.group(1)
fixture = enclosing_class(text, method.start()) class_match = enclosing_class_match(text, method.start())
fixture = class_match.group(1) if class_match else "UnknownFixture"
attr_text = " ".join(attr.group(0).split()) attr_text = " ".join(attr.group(0).split())
parameters = " ".join((attr.group(2) or "").split()) or None
line = text.count("\n", 0, attr.start()) + 1 line = text.count("\n", 0, attr.start()) + 1
category_text = text[attr.start() : method.start()]
if class_match:
category_text += attributes_before(text, class_match.start())
categories = sorted(set(CATEGORY_RE.findall(category_text)))
tests.append( tests.append(
{ {
"id": stable_case_id(relative, fixture, name, attr.group(1), parameters),
"csharp_test_id": f"{relative}::{fixture}.{name}",
"parameter_case": parameters,
"source": relative, "source": relative,
"line": line, "line": line,
"fixture": fixture, "fixture": fixture,
"method": name, "method": name,
"attribute": attr_text, "attribute": attr_text,
"body_sha256": method_body_hash(text, method.start()), "body_sha256": method_body_hash(text, method.start()),
"categories": categories,
"fixture_dependencies": dependencies,
} }
) )
seen: defaultdict[str, int] = defaultdict(int) ids = [str(test["id"]) for test in tests]
duplicates = sorted(case_id for case_id, count in Counter(ids).items() if count > 1)
if duplicates:
raise RuntimeError("Duplicate stable NUnit case IDs: " + ", ".join(duplicates))
return tests
def reviewed_tests(root: Path) -> dict[str, dict[str, object]]:
reviews: dict[str, dict[str, object]] = {}
tests_root = root / "tests" / "compat" / "tests"
for path in sorted(tests_root.rglob("*.rs")):
if path.name == "generated_parity.rs":
continue
text = path.read_text()
for marker in PARITY_MARKER_RE.finditer(text):
status = marker.group("status")
if status == "pending":
raise RuntimeError(f"Pending parity marker must stay generated: {path}:{text.count(chr(10), 0, marker.start()) + 1}")
next_marker = PARITY_MARKER_RE.search(text, marker.end())
rust_test = RUST_TEST_RE.search(text, marker.end(), next_marker.start() if next_marker else len(text))
if rust_test is None:
raise RuntimeError(f"Parity marker has no following Rust test: {path}:{text.count(chr(10), 0, marker.start()) + 1}")
case_id = marker.group("id")
if case_id in reviews:
raise RuntimeError(f"Duplicate reviewed parity case: {case_id}")
reviews[case_id] = {
"body_sha256": marker.group("body_sha256"),
"status": status,
"rust_file": path.relative_to(root).as_posix(),
"rust_line": text.count("\n", 0, marker.start()) + 1,
"rust_test": rust_test.group(1),
}
return reviews
def render_parity_report(tests: list[dict[str, object]]) -> str:
status_counts = {status: sum(test["status"] == status for test in tests) for status in ("pending", "translated", "ignored-live", "benchmark", "drifted")}
live_candidates = sum("RequiresLiveServer" in test["categories"] for test in tests)
benchmark_candidates = sum("Benchmark" in test["categories"] for test in tests)
lines = [
"# NUnit to Rust parity ledger",
"",
f"Generated from LibreMetaverse `{UPSTREAM_COMMIT}`. Stable IDs identify one NUnit `[Test]` or `[TestCase]` invocation; the body hash covers the original C# method declaration and body.",
"",
"## Status",
"",
f"- Total: **{len(tests):,}**",
f"- Pending/unreviewed: **{status_counts['pending']:,}**",
f"- Translated/reviewed: **{status_counts['translated']:,}**",
f"- Ignored live/reviewed: **{status_counts['ignored-live']:,}** ({live_candidates:,} upstream live candidates)",
f"- Benchmarks/reviewed: **{status_counts['benchmark']:,}** ({benchmark_candidates:,} upstream benchmark candidates)",
f"- Drifted: **{status_counts['drifted']:,}**",
"",
"Reviewed Rust tests live outside `generated_parity.rs` and carry a `parity-case` marker. Regeneration preserves those files and fails if their source body hash drifts.",
"",
"| Stable case ID | C# test | Parameter case | Source | Categories | Fixtures | Rust location | Status | Body SHA-256 |",
"|---|---|---|---|---|---|---|---|---|",
]
for test in tests:
parameter = str(test["parameter_case"] or "").replace("|", "&#124;")
categories = ", ".join(test["categories"])
fixtures = ", ".join(test["fixture_dependencies"])
rust_location = f"{test['rust_file']}:{test['rust_line']} (`{test['rust_test']}`)"
lines.append(
f"| `{test['id']}` | `{test['fixture']}.{test['method']}` | `{parameter}` | `{test['source']}:{test['line']}` | `{categories}` | `{fixtures}` | `{rust_location}` | `{test['status']}` | `{test['body_sha256']}` |"
)
return "\n".join(lines) + "\n"
def generate_tests(upstream: Path, output: Path, review_root: Path | None = None) -> int:
tests = extract_tests(upstream)
reviews = reviewed_tests(review_root or output)
tests_by_id = {str(test["id"]): test for test in tests}
stale = sorted(set(reviews) - set(tests_by_id))
if stale:
raise RuntimeError("Reviewed cases missing from pinned upstream: " + ", ".join(stale))
drifted = sorted(
case_id
for case_id, review in reviews.items()
if review["body_sha256"] != tests_by_id[case_id]["body_sha256"]
)
if drifted:
raise RuntimeError("Reviewed cases have drifted C# bodies: " + ", ".join(drifted))
rust_lines = [ rust_lines = [
"// @generated by tools/generate_surface.py; do not edit by hand.", "// @generated by tools/generate_surface.py; do not edit by hand.",
f"// Source: LibreMetaverse {UPSTREAM_COMMIT}", f"// Source: LibreMetaverse {UPSTREAM_COMMIT}",
"use libremetaverse_compat_tests::pending;", "use libremetaverse_compat_tests::pending;",
"", "",
] ]
parity_lines = [
"# NUnit to Rust parity ledger",
"",
f"Generated from LibreMetaverse `{UPSTREAM_COMMIT}`. Each row is one NUnit `[Test]` or `[TestCase]` invocation. The body hash covers the original C# method declaration and body.",
"",
"| Rust test | C# test | Source | Attribute | Body SHA-256 |",
"|---|---|---|---|---|",
]
for test in tests: for test in tests:
review = reviews.get(str(test["id"]))
if review:
test.update(review)
test["semantic_review"] = "reviewed"
continue
base = snake(f"{test['fixture']}_{test['method']}") base = snake(f"{test['fixture']}_{test['method']}")
seen[base] += 1 suffix = hashlib.sha256(str(test["id"]).encode()).hexdigest()[:12]
rust_name = base if seen[base] == 1 else f"{base}_case_{seen[base]}" rust_name = f"{base[:64].rstrip('_')}_{suffix}"
test["rust_test"] = rust_name test.update(
{
"rust_file": "tests/compat/tests/generated_parity.rs",
"rust_line": len(rust_lines) + 1,
"rust_test": rust_name,
"status": "pending",
"semantic_review": "unreviewed",
}
)
identity = f"{test['fixture']}.{test['method']}" identity = f"{test['fixture']}.{test['method']}"
rust_lines.extend( rust_lines.extend(
[ [
f"// parity-case: {test['id']} {test['body_sha256']} pending",
"#[test]", "#[test]",
f"fn {rust_name}() {{", f"fn {rust_name}() {{",
f" pending({json.dumps(test['source'])}, {test['line']}, {json.dumps(identity)}, {json.dumps(test['attribute'])}, {json.dumps(test['body_sha256'])});", " pending(",
f" {json.dumps(test['id'])},",
f" {json.dumps(test['source'])},",
f" {test['line']},",
f" {json.dumps(identity)},",
f" {json.dumps(test['attribute'])},",
f" {json.dumps(test['body_sha256'])},",
" );",
"}", "}",
"", "",
] ]
) )
parity_lines.append(
f"| `{rust_name}` | `{identity}` | `{test['source']}:{test['line']}` | `{str(test['attribute']).replace('|', '&#124;')}` | `{test['body_sha256']}` |"
)
generated_dir = output / "tests" / "compat" / "tests" generated_dir = output / "tests" / "compat" / "tests"
generated_dir.mkdir(parents=True, exist_ok=True) generated_dir.mkdir(parents=True, exist_ok=True)
(generated_dir / "generated_parity.rs").write_text("\n".join(rust_lines)) (generated_dir / "generated_parity.rs").write_text("\n".join(rust_lines))
(output / "tests" / "PARITY.md").write_text("\n".join(parity_lines) + "\n") (output / "tests" / "PARITY.md").write_text(render_parity_report(tests))
(output / "tests" / "upstream-tests.json").write_text(json.dumps({"upstream_commit": UPSTREAM_COMMIT, "tests": tests}, indent=2) + "\n") report = {
"pending": sum(test["status"] == "pending" for test in tests),
"translated": sum(test["status"] == "translated" for test in tests),
"ignored_live": sum(test["status"] == "ignored-live" for test in tests),
"benchmark": sum(test["status"] == "benchmark" for test in tests),
"drifted": 0,
"unreviewed": sum(test["semantic_review"] == "unreviewed" for test in tests),
}
catalog = {
"schema_version": 1,
"upstream_commit": UPSTREAM_COMMIT,
"expected_cases": EXPECTED_TESTS,
"report": report,
"tests": tests,
}
(output / "tests" / "upstream-tests.json").write_text(json.dumps(catalog, indent=2) + "\n")
return len(tests) return len(tests)
def scan_parity_markers(root: Path) -> dict[str, list[dict[str, object]]]:
markers: defaultdict[str, list[dict[str, object]]] = defaultdict(list)
for path in sorted((root / "tests" / "compat" / "tests").rglob("*.rs")):
text = path.read_text()
for marker in PARITY_MARKER_RE.finditer(text):
next_marker = PARITY_MARKER_RE.search(text, marker.end())
rust_test = RUST_TEST_RE.search(text, marker.end(), next_marker.start() if next_marker else len(text))
if rust_test is None:
raise RuntimeError(f"Parity marker has no following Rust test: {path}:{text.count(chr(10), 0, marker.start()) + 1}")
markers[marker.group("id")].append(
{
"body_sha256": marker.group("body_sha256"),
"status": marker.group("status"),
"rust_file": path.relative_to(root).as_posix(),
"rust_line": text.count("\n", 0, marker.start()) + 1,
"rust_test": rust_test.group(1),
}
)
return dict(markers)
def check_test_parity(root: Path, require_reviewed: bool = False) -> dict[str, int]:
catalog_path = root / "tests" / "upstream-tests.json"
catalog = json.loads(catalog_path.read_text())
if catalog.get("schema_version") != 1:
raise RuntimeError("Unsupported test parity catalog schema")
tests = catalog.get("tests", [])
expected = catalog.get("expected_cases")
if expected != EXPECTED_TESTS or len(tests) != EXPECTED_TESTS:
raise RuntimeError(f"Expected {EXPECTED_TESTS} cataloged NUnit invocations, found {len(tests)}")
if catalog.get("upstream_commit") != UPSTREAM_COMMIT:
raise RuntimeError("Test parity catalog targets the wrong upstream commit")
ids = [test["id"] for test in tests]
duplicate_ids = sorted(case_id for case_id, count in Counter(ids).items() if count > 1)
if duplicate_ids:
raise RuntimeError("Duplicate catalog case IDs: " + ", ".join(duplicate_ids))
by_id = {test["id"]: test for test in tests}
markers = scan_parity_markers(root)
missing = sorted(set(by_id) - set(markers))
stale = sorted(set(markers) - set(by_id))
duplicates = sorted(case_id for case_id, entries in markers.items() if len(entries) != 1)
if missing:
raise RuntimeError("Missing Rust parity cases: " + ", ".join(missing))
if stale:
raise RuntimeError("Stale Rust parity cases: " + ", ".join(stale))
if duplicates:
raise RuntimeError("Duplicate Rust parity cases: " + ", ".join(duplicates))
drifted: list[str] = []
mismatched: list[str] = []
for case_id, test in by_id.items():
marker = markers[case_id][0]
if marker["body_sha256"] != test["body_sha256"]:
drifted.append(case_id)
for key in ("status", "rust_file", "rust_line", "rust_test"):
if marker[key] != test[key]:
mismatched.append(f"{case_id}:{key}")
if drifted:
raise RuntimeError("Drifted reviewed C# test bodies: " + ", ".join(drifted))
if mismatched:
raise RuntimeError("Stale parity catalog metadata: " + ", ".join(mismatched))
report = {
"pending": sum(test["status"] == "pending" for test in tests),
"translated": sum(test["status"] == "translated" for test in tests),
"ignored_live": sum(test["status"] == "ignored-live" for test in tests),
"benchmark": sum(test["status"] == "benchmark" for test in tests),
"drifted": 0,
"unreviewed": sum(test["semantic_review"] != "reviewed" for test in tests),
}
if catalog.get("report") != report:
raise RuntimeError("Stale parity summary report")
if require_reviewed and report["unreviewed"]:
raise RuntimeError(f"{report['unreviewed']} NUnit invocations remain semantically unreviewed")
return report
def verify_upstream(upstream: Path) -> None:
commit = subprocess.run(
["git", "-C", str(upstream), "rev-parse", "HEAD"],
check=True,
capture_output=True,
text=True,
).stdout.strip()
if commit != UPSTREAM_COMMIT:
raise RuntimeError(f"Expected LibreMetaverse {UPSTREAM_COMMIT}, found {commit}")
def check_test_regeneration(upstream: Path, output: Path) -> None:
with tempfile.TemporaryDirectory() as temporary:
generated = Path(temporary)
count = generate_tests(upstream, generated, review_root=output)
if count != EXPECTED_TESTS:
raise RuntimeError(f"Expected {EXPECTED_TESTS} NUnit invocations at {UPSTREAM_COMMIT}, found {count}")
stale = [str(path) for path in PARITY_FILES if not (output / path).exists() or (output / path).read_bytes() != (generated / path).read_bytes()]
if stale:
raise RuntimeError("Stale generated test parity files: " + ", ".join(stale))
def generate_program_manifest(upstream: Path, output: Path) -> int: def generate_program_manifest(upstream: Path, output: Path) -> int:
roots = [ roots = [
upstream / "Programs" / "VivoxTest", upstream / "Programs" / "VivoxTest",
@@ -331,14 +615,28 @@ def main() -> None:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--upstream", type=Path, default=Path("../libremetaverse")) parser.add_argument("--upstream", type=Path, default=Path("../libremetaverse"))
parser.add_argument("--output", type=Path, default=Path(".")) parser.add_argument("--output", type=Path, default=Path("."))
parser.add_argument("--tests-only", action="store_true")
parser.add_argument("--check", action="store_true")
args = parser.parse_args() args = parser.parse_args()
upstream = args.upstream.resolve() upstream = args.upstream.resolve()
output = args.output.resolve() output = args.output.resolve()
verify_upstream(upstream)
if args.check:
check_test_regeneration(upstream, output)
report = check_test_parity(output)
print(f"test parity is current: {report}")
return
if args.tests_only:
tests = generate_tests(upstream, output)
if tests != EXPECTED_TESTS:
raise RuntimeError(f"Expected {EXPECTED_TESTS} NUnit invocations at {UPSTREAM_COMMIT}, found {tests}")
print(f"generated {tests} test parity cases")
return
types, members = generate_apis(upstream, output) types, members = generate_apis(upstream, output)
tests = generate_tests(upstream, output) tests = generate_tests(upstream, output)
programs = generate_program_manifest(upstream, output) programs = generate_program_manifest(upstream, output)
if tests != 1295: if tests != EXPECTED_TESTS:
raise RuntimeError(f"Expected 1295 NUnit invocations at {UPSTREAM_COMMIT}, found {tests}") raise RuntimeError(f"Expected {EXPECTED_TESTS} NUnit invocations at {UPSTREAM_COMMIT}, found {tests}")
print(f"generated {types} type declarations, {members} public declaration lines, {tests} tests, and {programs} program manifests") print(f"generated {types} type declarations, {members} public declaration lines, {tests} tests, and {programs} program manifests")

View File

@@ -0,0 +1,71 @@
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from generate_surface import check_test_parity, extract_tests, generate_tests
class ParityGenerationTests(unittest.TestCase):
def test_reviewed_rust_test_is_preserved_and_excluded_from_placeholders(self) -> None:
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
upstream = root / "upstream"
source = upstream / "LibreMetaverse.Tests" / "ExampleTests.cs"
source.parent.mkdir(parents=True)
(upstream / "LibreMetaverse.Rendering.Tests").mkdir()
source.write_text(
"""namespace LibreMetaverse.Tests;
[Category("Example")]
public class ExampleTests
{
[TestCase(1, Description = "one")]
public void KeepsIdentity(int value) { Assert.That(value, Is.EqualTo(1)); }
[Test]
public void RemainsPending() { Assert.Pass(); }
}
"""
)
reviewed, _pending = extract_tests(upstream)
output = root / "output"
rust_file = output / "tests" / "compat" / "tests" / "example.rs"
rust_file.parent.mkdir(parents=True)
rust_file.write_text(
f"// parity-case: {reviewed['id']} {reviewed['body_sha256']} translated\n"
"#[test]\nfn translated_case() {}\n"
)
original = rust_file.read_bytes()
with patch("generate_surface.EXPECTED_TESTS", 2):
self.assertEqual(generate_tests(upstream, output), 2)
self.assertEqual(rust_file.read_bytes(), original)
generated = output / "tests/compat/tests/generated_parity.rs"
self.assertNotIn(reviewed["id"], generated.read_text())
catalog = json.loads((output / "tests/upstream-tests.json").read_text())
self.assertEqual(catalog["tests"][0]["status"], "translated")
self.assertEqual(catalog["tests"][0]["rust_test"], "translated_case")
self.assertEqual(check_test_parity(output)["unreviewed"], 1)
generated_text = generated.read_text()
generated.write_text(generated_text.replace("// parity-case:", "// missing-case:", 1))
with self.assertRaisesRegex(RuntimeError, "Missing Rust parity cases"):
check_test_parity(output)
generated.write_text(generated_text)
pending = catalog["tests"][1]
duplicate = (
f"// parity-case: {pending['id']} {pending['body_sha256']} pending\n"
"#[test]\nfn duplicate_case() {}\n"
)
generated.write_text(duplicate + generated_text)
with self.assertRaisesRegex(RuntimeError, "Duplicate Rust parity cases"):
check_test_parity(output)
generated.write_text(generated_text.replace(pending["body_sha256"], "0" * 64, 1))
with self.assertRaisesRegex(RuntimeError, "Drifted reviewed C# test bodies"):
check_test_parity(output)
if __name__ == "__main__":
unittest.main()