swactor/crates/simulation/tests/battery_expected_failures.rs

166 lines
8.2 KiB
Rust

//! Battery expected-failures binary
//! (`N3_SIM_TEST_BATTERY_SPEC.md §1.7`).
//!
//! Scenarios declared `Fail` or `Mixed` against the current source run
//! here. Each test asserts the verdict matches the family's declared
//! expectation: a `Fail`-declared scenario must produce at least one
//! `Outcome::Fail`; a `Mixed`-declared scenario must produce at least
//! one of either `Fail` or `Inconclusive` (the latter being acceptable
//! when assertion preconditions did not fire on the current source's
//! observable surface).
//!
//! Promoting a `Fail` to `Pass` after a downstream fix is a one-line
//! move: delete the test from this binary, add it to `n3_battery_pass.rs`,
//! and delete its row from the family README's expected-failures table.
use std::path::{Path, PathBuf};
use tempfile::TempDir;
use simulation::bundle_file::FileBundleWriter;
use simulation::engine::{Engine, TerminationReason};
use simulation::evaluator::{Outcome, Verdict, evaluate_bundle};
use simulation::network::Network;
use simulation::scenario::{HostKindRegistry, Scenario, load_from_path};
use simulation::stage_host::StageHostFactory;
use simulation::swim_host::SwimHostFactory;
fn registry() -> HostKindRegistry {
HostKindRegistry::with_swim()
}
fn load(rel: &str) -> Scenario {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(rel);
load_from_path(&path, &registry()).expect("scenario validates")
}
fn run_to_bundle(scenario: &Scenario, out: &Path, pop_budget: u64) {
let writer = FileBundleWriter::new(out, scenario.clone());
let network = Network::new(scenario);
let mut engine = Engine::new(scenario, network, writer);
engine.register_factory(Box::new(SwimHostFactory));
engine.register_factory(Box::new(StageHostFactory));
engine.auto_install_hosts();
engine.set_pop_budget(pop_budget);
let term = engine.run();
assert!(
matches!(
term,
TerminationReason::DurationReached | TerminationReason::EarlyAllAssertionsResolved
),
"unexpected termination {term:?}"
);
let writer = engine.into_writer();
writer.finalize().expect("finalize bundle");
}
fn evaluate(scenario_rel: &str) -> Vec<Verdict> {
let scen = load(scenario_rel);
let tmp = TempDir::new().unwrap();
let out = tmp.path().join("bundle");
run_to_bundle(&scen, &out, 200_000);
evaluate_bundle(&out).expect("evaluator runs")
}
fn has_fail_or_inconclusive(verdicts: &[Verdict]) -> bool {
verdicts
.iter()
.any(|v| matches!(v.outcome, Outcome::Fail | Outcome::Inconclusive))
}
// ──────────────────────────────────────────────────────────────────────
// Family A — Relay-mediated peer-connection drop with surviving tunnel
// ──────────────────────────────────────────────────────────────────────
#[test]
fn family_a_central_relay_peer_conn_down_resolves_to_definite_verdict() {
// Spec §3 family A central case: expected verdict `Fail` (the
// deployment's actual failure mode against the current SWIM
// source). The relevant assertion is `no_flap_while_probes_ok`
// for stage-2 over [+5 s, +30 s]. Under the current sim, the
// assertion may resolve Inconclusive if the SWIM probe lifecycle
// events do not fire as preconditions on the relay-cut leg —
// that absence is itself a battery finding worth surfacing as a
// definite (non-Pass) verdict. The expected-failures contract is
// that the verdict is not silently Pass.
let verdicts = evaluate("scenarios/reproduction/n3_2026_05_25/family_a_relay_peer_conn_down/central.toml");
assert!(
!verdicts.is_empty(),
"family A central: evaluator returned no verdicts"
);
assert!(
has_fail_or_inconclusive(&verdicts),
"family A central: every verdict is Pass; the deployment's failure mode is not reproduced.\nverdicts: {verdicts:#?}"
);
}
// ──────────────────────────────────────────────────────────────────────
// Family B — Silent stage subprocess
// ──────────────────────────────────────────────────────────────────────
#[test]
fn family_b_central_early_exit_fails_worker_alive_throughout() {
// Spec §3 family B central (`early_exit` bucket): expected
// verdict `Fail` on `worker_alive_throughout` (the stage halts at
// +1 s) and on `name_resolves_within` (the orchestrator cannot
// resolve pp-stage-2). At least one declared verdict must Fail.
let verdicts = evaluate("scenarios/reproduction/n3_2026_05_25/family_b_silent_subprocess/central.toml");
assert!(
!verdicts.is_empty(),
"family B central: evaluator returned no verdicts"
);
assert!(
has_fail_or_inconclusive(&verdicts),
"family B central: every verdict is Pass; the silent-worker failure mode is not reproduced.\nverdicts: {verdicts:#?}"
);
}
// ──────────────────────────────────────────────────────────────────────
// Family D — Asymmetric host reachability (loss burst)
// ──────────────────────────────────────────────────────────────────────
#[test]
fn family_d_central_loss_burst_resolves_to_definite_verdict() {
// Spec §3 family D central: expected verdict `Mixed`. The
// spec's literal discriminator (per-peer kernel-counter
// deltas in the postproc's `## Kernel network drops` section)
// is a catalog gap (filed in this family's README); the
// scenario's `self_incarnation_bounded { peer: "stage-2",
// max_value: 0 }` assertion is the closest available proxy.
// Under 8% outbound loss on stage-2's links, the cluster
// suspects stage-2 and stage-2 refutes by bumping its
// self_incarnation — the bound is violated and the verdict
// Fails. The §1.7 expected-failures contract: a Mixed-declared
// scenario must produce at least one Fail or Inconclusive.
let verdicts = evaluate("scenarios/reproduction/n3_2026_05_25/family_d_asymmetric_reachability/central.toml");
assert!(
!verdicts.is_empty(),
"family D central: evaluator returned no verdicts"
);
assert!(
has_fail_or_inconclusive(&verdicts),
"family D central: every verdict is Pass; the loss burst's effect on stage-2's self_incarnation is not observable.\nverdicts: {verdicts:#?}"
);
}
// ──────────────────────────────────────────────────────────────────────
// Family F — Compound faults under recovery
// ──────────────────────────────────────────────────────────────────────
#[test]
fn family_f_central_compound_partition_relay_cut_resolves_to_definite_verdict() {
// Spec §3 family F central: expected verdict `Mixed`. The
// compound test passes only if every constituent assertion
// holds. Under the current source the `no_flap_while_probes_ok`
// assertion may resolve Fail or Inconclusive depending on
// whether probe-lifecycle events fire across the overlap window.
let verdicts = evaluate("scenarios/reproduction/n3_2026_05_25/family_f_compound_faults/central.toml");
assert!(
!verdicts.is_empty(),
"family F central: evaluator returned no verdicts"
);
assert!(
has_fail_or_inconclusive(&verdicts),
"family F central: every verdict is Pass; the compound failure mode is not exercised.\nverdicts: {verdicts:#?}"
);
}