use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use bevy_ecs::prelude::{Entity, World};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use uuid::Uuid;
use crate::rendering::capture::HeadlessCapture;
use crate::runtime::{
picking, scene_document_lenient, set_registered_component,
set_registered_component_field, ActionMap, Camera, CollisionEvent,
EventQueue, FrameTime, GlobalTransform, InputBinding, MeshRenderer,
MouseButton, Name, RandomSeed, RenderWorld, RuntimeInput, SceneId,
SceneTransform, Stick,
};
use crate::{App, AssetServer, Transform};
pub const TEST_SCENARIO_ENV: &str = "RUSTING_TEST_SCENARIO";
pub const UPDATE_GOLDEN_ENV: &str = "RUSTING_UPDATE_GOLDEN";
pub const KEEP_GOING_ENV: &str = "RUSTING_KEEP_GOING";
pub const TEST_REPORT_ENV: &str = "RUSTING_TEST_REPORT";
#[must_use]
pub fn tick_delta(app: &App, tick: u32) -> Duration {
if tick == 0 {
Duration::ZERO
} else {
app.world().resource::<FrameTime>().fixed_delta
}
}
pub const COUNTER_PREFIX: &str = "counter:";
pub const AUDIO_ENTITY: &str = "audio:";
#[derive(bevy_ecs::prelude::Resource, Clone, Default)]
struct AudioMix {
level: [f32; 2],
peak: [f32; 2],
clipped: u64,
playing: Vec<crate::audio_output::PlayingSound>,
}
fn collect_hashes(world: &World, report: &mut ScenarioReport) {
let Some(hashes) = world.get_resource::<crate::runtime::StateHashes>()
else {
return;
};
for (history, kept) in [
(&hashes.recent, &mut report.state_hashes),
(&hashes.gpu, &mut report.gpu_state_hashes),
] {
let last = kept.last().map(|entry| entry.0);
kept.extend(
history
.iter()
.filter(|entry| last.is_none_or(|last| entry.0 > last)),
);
}
}
fn mix_tick(
app: &mut App,
mixer: &mut crate::audio_output::OfflineMixer,
samples: &mut Vec<f32>,
) {
let world = app.world_mut();
let commands = world
.get_resource_mut::<crate::runtime::AudioQueue>()
.map(|mut queue| queue.drain())
.unwrap_or_default();
let assets = world
.get_resource::<crate::project_runner::ProjectFolder>()
.map(|folder| folder.0.join("assets"))
.unwrap_or_default();
let time = *world.resource::<FrameTime>();
let step = time.fixed_delta.as_secs_f64();
let delay = |tick: u64| {
Duration::from_secs_f64(
tick.saturating_sub(time.fixed_tick) as f64 * step,
)
};
for command in commands {
mixer.run(&assets, command, delay);
}
let frames =
(step * f64::from(crate::audio_output::MIX_RATE)).round() as usize;
let block = mixer.render(frames);
let rms = |channel: usize| {
let sum: f32 =
block.iter().skip(channel).step_by(2).map(|s| s * s).sum();
(sum / frames.max(1) as f32).sqrt()
};
let peak = |channel: usize| {
block
.iter()
.skip(channel)
.step_by(2)
.fold(0.0_f32, |peak, s| peak.max(s.abs()))
};
let clipped = world
.get_resource::<AudioMix>()
.map_or(0, |mix| mix.clipped)
+ block.iter().filter(|s| s.abs() >= 1.0).count() as u64;
let mix = AudioMix {
level: [rms(0), rms(1)],
peak: [peak(0), peak(1)],
clipped,
playing: mixer.playing(),
};
world.insert_resource(mix);
samples.extend_from_slice(&block);
}
const COUNTER_VALUE: &str = "/components/rusting.counter/value";
pub fn find_entity(
world: &mut World,
wanted: &str,
accept: impl Fn(&World, Entity) -> bool,
) -> Option<Entity> {
if let Some(counter) = wanted.strip_prefix(COUNTER_PREFIX) {
let mut query =
world
.query::<(Entity, &crate::runtime::Counter, Option<&SceneId>)>(
);
let mut matches: Vec<_> = query
.iter(world)
.filter(|(_, found, _)| found.name == counter)
.map(|(entity, _, scene_id)| (scene_id.map(|id| id.0), entity))
.collect();
matches.sort();
return matches
.into_iter()
.map(|(_, entity)| entity)
.find(|&entity| accept(world, entity));
}
let id = Uuid::parse_str(wanted).ok();
let mut query = world.query::<(Entity, Option<&SceneId>, Option<&Name>)>();
let mut matches: Vec<_> = query
.iter(world)
.filter(|(_, scene_id, name)| {
scene_id.is_some_and(|scene_id| Some(scene_id.0) == id)
|| name.is_some_and(|name| name.0 == wanted)
})
.map(|(entity, scene_id, _)| (scene_id.map(|id| id.0), entity))
.collect();
matches.sort();
matches
.into_iter()
.map(|(_, entity)| entity)
.find(|&entity| accept(world, entity))
}
pub fn find_camera(world: &mut World, wanted: &str) -> Option<Entity> {
find_entity(world, wanted, |world, entity| {
world.get::<Camera>(entity).is_some()
})
}
pub struct CameraView {
entity: Entity,
camera: Camera,
transform: GlobalTransform,
extent: [u32; 2],
}
impl CameraView {
#[must_use]
pub fn active(world: &World, extent: [u32; 2]) -> Option<Self> {
let active = world.resource::<RenderWorld>().active_camera?;
Some(Self {
entity: active.entity,
camera: *world.get::<Camera>(active.entity)?,
transform: active.transform,
extent,
})
}
#[must_use]
pub fn of(world: &World, entity: Entity, extent: [u32; 2]) -> Option<Self> {
let camera = *world.get::<Camera>(entity)?;
let extent = match camera.viewport {
Some(viewport) => {
crate::rendering::render_scale::viewport_pixels(
viewport, extent,
)
.extent
}
None => extent,
};
Some(Self {
entity,
camera,
transform: *world.get::<GlobalTransform>(entity)?,
extent,
})
}
#[must_use]
pub fn extent(&self) -> [u32; 2] {
self.extent
}
#[must_use]
pub fn project(&self, point: [f32; 3]) -> Option<[f32; 2]> {
picking::project_point(
nalgebra::Vector3::from(point),
self.camera,
self.transform,
[0.0, 0.0],
[self.extent[0] as f32, self.extent[1] as f32],
)
}
pub fn pick(&self, world: &mut World, pixel: [u32; 2]) -> Value {
let ray = picking::scene_ray(
[pixel[0] as f32 + 0.5, pixel[1] as f32 + 0.5],
[0.0, 0.0],
[self.extent[0] as f32, self.extent[1] as f32],
self.camera,
self.transform,
);
let mut meshes =
world.query::<(Entity, &MeshRenderer, &GlobalTransform)>();
let assets = world.resource::<AssetServer>();
let hit = ray.and_then(|ray| {
meshes
.iter(world)
.filter_map(|(entity, renderer, transform)| {
let mesh = assets.meshes.get(renderer.mesh)?;
let distance =
picking::ray_mesh_bounds(ray, *transform, mesh)?;
Some((
distance,
entity,
ray.origin + ray.direction * distance,
))
})
.min_by(|left, right| left.0.total_cmp(&right.0))
});
match hit {
Some((distance, entity, point)) => json!({
"pixel": pixel,
"id": world.get::<SceneId>(entity).map(|id| id.0),
"name": world.get::<Name>(entity).map(|name| &name.0),
"distance": distance,
"world_position": [point.x, point.y, point.z],
}),
None => json!({"pixel": pixel, "id": null}),
}
}
pub fn pick_rect(&self, world: &mut World, rect: [u32; 4]) -> Value {
let x1 = rect[2].min(self.extent[0]);
let y1 = rect[3].min(self.extent[1]);
let (x0, y0) = (rect[0].min(x1), rect[1].min(y1));
let step = |len: u32| len.div_ceil(64).max(1) as usize;
let mut counts: BTreeMap<Option<Uuid>, (u32, Value)> = BTreeMap::new();
let mut samples = 0_u32;
for y in (y0..y1).step_by(step(y1 - y0)) {
for x in (x0..x1).step_by(step(x1 - x0)) {
let hit = self.pick(world, [x, y]);
let id = hit["id"].as_str().and_then(|id| id.parse().ok());
let entry = counts.entry(id).or_insert((0, hit));
entry.0 += 1;
samples += 1;
}
}
let share = |count: u32| f64::from(count) / f64::from(samples.max(1));
let mut hits: Vec<_> = counts
.iter()
.filter(|(id, _)| id.is_some())
.map(|(_, (count, hit))| {
json!({"id": hit["id"], "name": hit["name"], "share": share(*count)})
})
.collect();
hits.sort_by(|a, b| {
b["share"]
.as_f64()
.partial_cmp(&a["share"].as_f64())
.unwrap()
});
let none = counts.get(&None).map_or(0, |(count, _)| *count);
json!({"rect": [x0, y0, x1, y1], "samples": samples, "none": share(none), "entities": hits})
}
#[must_use]
pub fn data(&self, world: &World) -> Value {
let transform = world.get::<Transform>(self.entity);
json!({
"id": world.get::<SceneId>(self.entity).map(|id| id.0),
"name": world.get::<Name>(self.entity).map(|name| &name.0),
"projection": format!("{:?}", self.camera.projection),
"position": transform.map(|transform| transform.position),
"rotation": transform.map(|transform| transform.rotation),
"world_matrix": self.transform.matrix,
})
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Scenario {
pub name: String,
#[serde(skip)]
pub update_golden: bool,
#[serde(default)]
pub seed: u64,
pub ticks: u32,
#[serde(default = "default_capture_size")]
pub capture_size: [u32; 2],
#[serde(default)]
pub steps: Vec<ScenarioStep>,
#[serde(default)]
pub keep_going: bool,
#[serde(default)]
pub annotate: bool,
#[serde(default)]
pub contact_sheet: Option<PathBuf>,
#[serde(default)]
pub audio_out: Option<PathBuf>,
#[serde(default)]
pub budgets: Option<Budgets>,
#[serde(default)]
pub invariants: Vec<Expectation>,
#[serde(default)]
pub gpu: bool,
#[serde(default)]
pub files: BTreeMap<String, PathBuf>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct Budgets {
pub max_tick_ms: Option<f64>,
pub mean_tick_ms: Option<f64>,
pub p95_tick_ms: Option<f64>,
pub max_draws: Option<u32>,
pub max_triangles: Option<u64>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq)]
pub struct PerfReport {
pub environment: Value,
pub tick_ms_mean: f64,
pub tick_ms_p95: f64,
pub tick_ms_max: f64,
pub render: Value,
}
fn default_capture_size() -> [u32; 2] {
[1280, 720]
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ScenarioStep {
pub tick: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub until: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub within: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub at: Option<f64>,
#[serde(flatten)]
pub action: StepAction,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum StepAction {
Press(String),
Release(String),
Tap(String),
Expect(Expectation),
ExpectEvents(EventExpectation),
Capture(CaptureStep),
Set(Assignment),
Click(ClickTarget),
Restart(bool),
ExpectQuit(bool),
ExpectFile(FileExpectation),
Pointer([f32; 2]),
LeftStick([f32; 2]),
RightStick([f32; 2]),
ExpectScreen(ScreenExpectation),
ExpectPixels(PixelExpectation),
Log(LoggedValue),
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(untagged)]
pub enum ClickTarget {
Label(String),
Find {
#[serde(default, skip_serializing_if = "Option::is_none")]
text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
starts_with: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
index: Option<usize>,
},
}
#[derive(Clone, Debug, Serialize)]
pub struct CaptureStep {
pub path: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub camera: Option<String>,
#[serde(default = "yes")]
pub hud: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub golden: Option<PathBuf>,
#[serde(default = "default_tolerance")]
pub tolerance: f64,
}
fn yes() -> bool {
true
}
fn default_tolerance() -> f64 {
1.0
}
impl<'de> Deserialize<'de> for CaptureStep {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Full {
path: PathBuf,
#[serde(default)]
camera: Option<String>,
#[serde(default = "yes")]
hud: bool,
#[serde(default)]
golden: Option<PathBuf>,
#[serde(default = "default_tolerance")]
tolerance: f64,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum Form {
Path(PathBuf),
Full(Full),
}
Ok(match Form::deserialize(deserializer)? {
Form::Path(path) => CaptureStep {
path,
camera: None,
hud: true,
golden: None,
tolerance: default_tolerance(),
},
Form::Full(f) => CaptureStep {
path: f.path,
camera: f.camera,
hud: f.hud,
golden: f.golden,
tolerance: f.tolerance,
},
})
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct PixelExpectation {
#[serde(default = "whole_frame")]
pub region: [f32; 4],
#[serde(default, skip_serializing_if = "Option::is_none")]
pub camera: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mean_min: Option<[f64; 3]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mean_max: Option<[f64; 3]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stddev_min: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stddev_max: Option<f64>,
}
fn whole_frame() -> [f32; 4] {
[0.0, 0.0, 1.0, 1.0]
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct FileExpectation {
pub path: String,
#[serde(default = "yes")]
pub exists: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub contains: Option<String>,
}
fn check_file(expect: &FileExpectation) -> Check {
let path = crate::project::user_data_folder().join(&expect.path);
let text = std::fs::read_to_string(&path).ok();
let shown = path.display();
match (&text, expect.exists, &expect.contains) {
(None, true, _) => {
Err((format!("{shown} does not exist"), Value::Null))
}
(Some(_), false, _) => {
Err((format!("{shown} exists"), Value::Bool(true)))
}
(None, false, _) => Ok(format!("{shown} does not exist")),
(Some(text), true, Some(wanted)) if !text.contains(wanted.as_str()) => {
Err((
format!("{shown} does not contain `{wanted}`"),
Value::String(text.chars().take(400).collect()),
))
}
(Some(_), true, _) => Ok(format!("{shown} is as expected")),
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct LoggedValue {
#[serde(default)]
pub entity: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub counter: Option<String>,
#[serde(default)]
pub path: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Assignment {
#[serde(default)]
pub entity: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub counter: Option<String>,
#[serde(default)]
pub path: String,
pub value: Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Expectation {
#[serde(default)]
pub entity: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub counter: Option<String>,
#[serde(default)]
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exists: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub equals: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub not_equals: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub greater_than: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub less_than: Option<f64>,
#[serde(default)]
pub tolerance: f64,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub finite: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ScreenExpectation {
pub entity: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_screen: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub occluded: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inside: Option<[f32; 4]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_share: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub camera: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct EventExpectation {
pub kind: EventKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entity: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub at_least: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub at_most: Option<usize>,
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum EventKind {
Collision,
}
fn resolve_counter(
entity: &mut String,
path: &mut String,
counter: Option<&String>,
) {
if let Some(counter) = counter {
*entity = format!("{COUNTER_PREFIX}{counter}");
if path.is_empty() {
COUNTER_VALUE.clone_into(path);
}
}
}
impl Scenario {
#[must_use]
pub fn with_counters_resolved(&self) -> Self {
let mut scenario = self.clone();
for step in &mut scenario.steps {
match &mut step.action {
StepAction::Expect(e) => {
resolve_counter(
&mut e.entity,
&mut e.path,
e.counter.as_ref(),
);
}
StepAction::Set(e) => {
resolve_counter(
&mut e.entity,
&mut e.path,
e.counter.as_ref(),
);
}
StepAction::Log(e) => {
resolve_counter(
&mut e.entity,
&mut e.path,
e.counter.as_ref(),
);
}
_ => {}
}
}
for e in &mut scenario.invariants {
resolve_counter(&mut e.entity, &mut e.path, e.counter.as_ref());
}
scenario
}
pub fn validate(&self) -> Result<(), String> {
for (index, step) in self.steps.iter().enumerate() {
let fail = |message: &str| Err(format!("step {index}: {message}"));
let last = step.until.or(step.within).unwrap_or(step.tick);
if last > self.ticks || last < step.tick {
return fail("tick or until is outside the scenario");
}
let repeatable = matches!(
step.action,
StepAction::Expect(_)
| StepAction::ExpectEvents(_)
| StepAction::ExpectScreen(_)
| StepAction::ExpectPixels(_)
);
if step.within.is_some() && !repeatable
|| step.until.is_some()
&& !repeatable
&& !matches!(step.action, StepAction::Log(_))
{
return fail(
"only expect and log steps accept until or within",
);
}
if step.until.is_some() && step.within.is_some() {
return fail("until and within cannot both be set");
}
let target = match &step.action {
StepAction::Expect(e) => Some((&e.entity, &e.counter)),
StepAction::Set(e) => Some((&e.entity, &e.counter)),
StepAction::Log(e) => Some((&e.entity, &e.counter)),
_ => None,
};
if let Some((entity, counter)) = target {
if entity.is_empty() == counter.is_none() {
return fail("give either entity or counter");
}
}
match &step.action {
StepAction::Expect(expect)
if expect.equals.is_none()
&& expect.not_equals.is_none()
&& expect.greater_than.is_none()
&& expect.less_than.is_none()
&& expect.exists.is_none() =>
{
return fail(
"expect needs equals, not_equals, greater_than, less_than or exists",
)
}
StepAction::ExpectScreen(expect)
if expect.on_screen.is_none()
&& expect.occluded.is_none()
&& expect.inside.is_none()
&& expect.min_share.is_none() =>
{
return fail(
"expect_screen needs on_screen, occluded, inside or min_share",
)
}
StepAction::ExpectPixels(expect)
if expect.mean_min.is_none()
&& expect.mean_max.is_none()
&& expect.stddev_min.is_none()
&& expect.stddev_max.is_none() =>
{
return fail(
"expect_pixels needs mean_min, mean_max, stddev_min or stddev_max",
)
}
StepAction::ExpectEvents(expect)
if expect.at_least.is_none()
&& expect.at_most.is_none() =>
{
return fail("expect_events needs at_least or at_most")
}
_ => {}
}
}
for (index, invariant) in self.invariants.iter().enumerate() {
if invariant.entity.is_empty() == invariant.counter.is_none() {
return Err(format!(
"invariant {index}: give either entity or counter"
));
}
if invariant.equals.is_none()
&& invariant.not_equals.is_none()
&& invariant.greater_than.is_none()
&& invariant.less_than.is_none()
&& invariant.exists.is_none()
&& !invariant.finite
{
return Err(format!(
"invariant {index}: needs equals, not_equals, \
greater_than, less_than, exists or finite"
));
}
}
if self.capture_size.contains(&0) {
return Err("capture_size must be at least 1x1".into());
}
Ok(())
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct StepResult {
pub tick: u32,
pub step: usize,
pub ok: bool,
pub message: String,
#[serde(default, skip_serializing_if = "Value::is_null")]
pub actual: Value,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct ScenarioReport {
pub name: String,
pub seed: u64,
pub passed: bool,
pub ticks_run: u64,
pub first_failure: Option<StepResult>,
pub steps: Vec<StepResult>,
pub captures: Vec<PathBuf>,
#[serde(default)]
pub trace: Vec<TraceEvent>,
#[serde(default)]
pub perf: PerfReport,
#[serde(default)]
pub gpu_state_hashes: Vec<(u64, u64)>,
#[serde(default)]
pub state_hashes: Vec<(u64, u64)>,
}
pub const TRACE_LIMIT: usize = 1000;
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct TraceEvent {
pub tick: u32,
pub kind: String,
pub detail: Value,
}
struct SeenEvent {
a: Option<Uuid>,
b: Option<Uuid>,
}
fn save_capture(
pixels: &[u8],
world: &mut World,
scenario: &Scenario,
path: &Path,
saved: &mut Vec<PathBuf>,
) -> Check {
crate::rendering::capture::save_rgba(path, pixels, scenario.capture_size)
.map_err(|error| (error, Value::Null))?;
saved.push(path.to_path_buf());
if scenario.annotate {
let extent = scenario.capture_size;
let notes = crate::annotate::annotations(world, extent);
let mut pixels = pixels.to_vec();
crate::annotate::draw(&mut pixels, extent, ¬es);
let image = path.with_extension("annotated.png");
let legend = path.with_extension("annotated.json");
let wrote = image::save_buffer(
&image,
&pixels,
extent[0],
extent[1],
image::ExtendedColorType::Rgba8,
)
.map_err(|error| error.to_string())
.and_then(|()| {
let data: Vec<_> = notes.iter().map(|n| n.data()).collect();
std::fs::write(
&legend,
serde_json::to_string_pretty(&data).unwrap(),
)
.map_err(|error| error.to_string())
});
wrote.map_err(|error| (error, Value::Null))?;
saved.extend([image, legend]);
}
Ok(format!("captured {}", path.display()))
}
fn compare_golden(
pixels: &[u8],
extent: [u32; 2],
golden: &Path,
tolerance: f64,
update: bool,
) -> Check {
if update {
crate::rendering::capture::save_rgba(golden, pixels, extent)
.map_err(|error| (error, Value::Null))?;
return Ok(format!("; wrote golden {}", golden.display()));
}
let image = image::open(golden)
.map_err(|error| {
(
format!(
"cannot read golden {}: {error}; `rusting test \
--update-golden` writes it",
golden.display()
),
Value::Null,
)
})?
.into_rgba8();
if image.dimensions() != (extent[0], extent[1]) {
return Err((
format!(
"golden {} is {:?}, the capture is {extent:?}",
golden.display(),
image.dimensions()
),
Value::Null,
));
}
let mut sums = [0.0_f64; 3];
let mut max = 0_u8;
for (a, b) in pixels.chunks_exact(4).zip(image.as_raw().chunks_exact(4)) {
for channel in 0..3 {
let difference = a[channel].abs_diff(b[channel]);
sums[channel] += f64::from(difference);
max = max.max(difference);
}
}
let count = f64::from(extent[0] * extent[1]).max(1.0);
let mean = sums.map(|sum| sum / count);
let worst = mean.iter().copied().fold(0.0, f64::max);
let actual = json!({"mean_difference": mean, "max_difference": max});
if worst > tolerance {
return Err((
format!(
"capture differs from golden {}: largest channel mean \
difference {worst:.3} (R {:.3}, G {:.3}, B {:.3}), \
tolerance {tolerance}",
golden.display(),
mean[0],
mean[1],
mean[2]
),
actual,
));
}
Ok(format!(
"; matches golden {} (mean difference {worst:.3})",
golden.display()
))
}
fn region_stats(pixels: &[u8], width: u32, rect: [u32; 4]) -> ([f64; 3], f64) {
let mut sums = [0.0_f64; 3];
let mut luma = Vec::new();
for y in rect[1]..rect[3] {
for x in rect[0]..rect[2] {
let at = ((y * width + x) * 4) as usize;
let rgb = [0, 1, 2].map(|c| f64::from(pixels[at + c]));
for c in 0..3 {
sums[c] += rgb[c];
}
luma.push(0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]);
}
}
let count = luma.len().max(1) as f64;
let mean_luma = luma.iter().sum::<f64>() / count;
let variance = luma
.iter()
.map(|value| (value - mean_luma).powi(2))
.sum::<f64>()
/ count;
(sums.map(|sum| sum / count), variance.sqrt())
}
fn check_pixels(
world: &mut World,
expect: &PixelExpectation,
pixels: &[u8],
extent: [u32; 2],
) -> Check {
let mut frame = [0.0, 0.0, 1.0, 1.0];
if let Some(name) = &expect.camera {
let camera = find_camera(world, name).ok_or_else(|| {
(
format!("no camera has the ID or name `{name}`"),
Value::Null,
)
})?;
if let Some(viewport) =
world.get::<Camera>(camera).and_then(|c| c.viewport)
{
frame = viewport;
}
}
let [l, t, r, b] = expect.region;
let pixel = |fraction: f32, start: f32, size: f32, side: u32| {
(((start + fraction * size) * side as f32).round() as u32).min(side)
};
let rect = [
pixel(l, frame[0], frame[2], extent[0]),
pixel(t, frame[1], frame[3], extent[1]),
pixel(r, frame[0], frame[2], extent[0]),
pixel(b, frame[1], frame[3], extent[1]),
];
if rect[0] >= rect[2] || rect[1] >= rect[3] {
return Err((
format!("region {:?} covers no pixels", expect.region),
Value::Null,
));
}
let (mean, stddev) = region_stats(pixels, extent[0], rect);
let actual = json!({"rect": rect, "mean": mean, "stddev": stddev});
let fail = |wanted: String| {
Err((
format!("pixels are {actual}, expected {wanted}"),
actual.clone(),
))
};
if let Some(min) = expect.mean_min {
if (0..3).any(|c| mean[c] < min[c]) {
return fail(format!("mean_min {min:?}"));
}
}
if let Some(max) = expect.mean_max {
if (0..3).any(|c| mean[c] > max[c]) {
return fail(format!("mean_max {max:?}"));
}
}
if expect.stddev_min.is_some_and(|min| stddev < min) {
return fail(format!("stddev_min {:?}", expect.stddev_min));
}
if expect.stddev_max.is_some_and(|max| stddev > max) {
return fail(format!("stddev_max {:?}", expect.stddev_max));
}
Ok(format!("pixels are {actual} as expected"))
}
pub fn run_scenario(
app: &mut App,
scenario: &Scenario,
base: &Path,
) -> ScenarioReport {
let mut report = ScenarioReport {
name: scenario.name.clone(),
seed: scenario.seed,
passed: false,
ticks_run: 0,
first_failure: None,
steps: Vec::new(),
captures: Vec::new(),
trace: Vec::new(),
perf: PerfReport::default(),
gpu_state_hashes: Vec::new(),
state_hashes: Vec::new(),
};
if let Err(message) = scenario.validate() {
report.first_failure = Some(StepResult {
tick: 0,
step: 0,
ok: false,
message,
actual: Value::Null,
});
return report;
}
let scenario = &scenario.with_counters_resolved();
app.world_mut().insert_resource(RandomSeed(scenario.seed));
app.world_mut()
.resource_mut::<RuntimeInput>()
.record_viewport_size(scenario.capture_size.map(|side| side as f32));
if let Err(message) = seed_files(scenario, base) {
report.first_failure = Some(StepResult {
tick: 0,
step: 0,
ok: false,
message,
actual: Value::Null,
});
return report;
}
let render_budget = scenario.budgets.as_ref().is_some_and(|budgets| {
budgets.max_draws.is_some() || budgets.max_triangles.is_some()
});
let wants_capture = scenario.gpu
|| render_budget
|| scenario.steps.iter().any(|step| {
matches!(
step.action,
StepAction::Capture(_) | StepAction::ExpectPixels(_)
)
});
let mut capture =
wants_capture.then(|| HeadlessCapture::new(scenario.capture_size));
let mut mixer = crate::audio_output::OfflineMixer::offline();
let mut mixed = Vec::new();
if let (true, Some(Err(error))) = (scenario.gpu, capture.as_ref()) {
report.steps.push(StepResult {
tick: 0,
step: 0,
ok: false,
message: format!("gpu: no Vulkan device: {error}"),
actual: Value::Null,
});
report.first_failure = report.steps.first().cloned();
return report;
}
let mut events = Vec::new();
let mut frames: Vec<(u32, Vec<u8>)> = Vec::new();
let mut tick_ms: Vec<f64> = Vec::new();
let mut pending: Vec<Option<StepResult>> = vec![None; scenario.steps.len()];
let mut passed = vec![false; scenario.steps.len()];
let mut broken = vec![false; scenario.invariants.len()];
'ticks: for tick in 0..=scenario.ticks {
for (index, step) in scenario.steps.iter().enumerate() {
if step.tick + 1 == tick {
if let StepAction::Tap(action) = &step.action {
let _ = press(app.world_mut(), action, false, None);
}
if let StepAction::Click(_) = &step.action {
app.world_mut()
.resource_mut::<RuntimeInput>()
.record_mouse_button(MouseButton::Left, false);
}
}
if step.tick != tick {
continue;
}
let result = match &step.action {
StepAction::Press(action) | StepAction::Tap(action) => {
match step.at {
Some(at) if !(0.0..1.0).contains(&at) => Err(format!(
"at is {at}; it must be from 0 up to 1"
)),
at => press(
app.world_mut(),
action,
true,
at.map(|at| f64::from(tick) + at),
),
}
}
StepAction::Release(action) => {
press(app.world_mut(), action, false, None)
}
StepAction::Set(assignment) => {
assign(app.world_mut(), assignment)
}
StepAction::Pointer(point) => point_at(app.world_mut(), *point),
StepAction::LeftStick(tilt) => {
tilt_stick(app.world_mut(), Stick::Left, *tilt)
}
StepAction::RightStick(tilt) => {
tilt_stick(app.world_mut(), Stick::Right, *tilt)
}
StepAction::Click(label) => click(app.world_mut(), label),
StepAction::Restart(true) => restart(app.world_mut()),
_ => continue,
};
let failed = result.is_err();
if !failed && report.trace.len() < TRACE_LIMIT {
let (kind, detail) = match &step.action {
StepAction::Press(action) => {
("input", serde_json::json!({"press": action}))
}
StepAction::Release(action) => {
("input", serde_json::json!({"release": action}))
}
StepAction::Tap(action) => {
("input", serde_json::json!({"tap": action}))
}
StepAction::Set(assignment) => {
("set", serde_json::json!(assignment))
}
StepAction::Click(label) => {
("input", serde_json::json!({"click": label}))
}
StepAction::Restart(_) => {
("restart", serde_json::json!({"restart": true}))
}
_ => ("input", Value::Null),
};
report.trace.push(TraceEvent {
tick,
kind: kind.into(),
detail,
});
}
report.steps.push(StepResult {
tick,
step: index,
ok: !failed,
message: result.unwrap_or_else(|error| error),
actual: Value::Null,
});
if failed && !scenario.keep_going {
break 'ticks;
}
}
let delta = tick_delta(app, tick);
let started = std::time::Instant::now();
let updated = match capture.as_mut() {
Some(Ok(capture)) => capture.frame(app, delta),
_ => app
.update(delta)
.map(drop)
.map_err(|error| error.to_string()),
};
crate::runtime::route_sound_events(app.world_mut());
if let Some(mixer) = &mut mixer {
mix_tick(app, mixer, &mut mixed);
}
collect_hashes(app.world(), &mut report);
if let Err(error) = updated {
report.steps.push(StepResult {
tick,
step: 0,
ok: false,
message: format!("game update failed: {error}"),
actual: Value::Null,
});
break;
}
tick_ms.push(started.elapsed().as_secs_f64() * 1000.0);
report.ticks_run = app.world().resource::<FrameTime>().fixed_tick;
let world = app.world_mut();
let collisions: Vec<_> = world
.resource::<EventQueue<CollisionEvent>>()
.iter()
.map(|event| (event.a, event.b))
.collect();
let seen: Vec<_> = collisions
.into_iter()
.map(|(a, b)| SeenEvent {
a: world.get::<SceneId>(a).map(|id| id.0),
b: world.get::<SceneId>(b).map(|id| id.0),
})
.collect();
for event in &seen {
if report.trace.len() < TRACE_LIMIT {
report.trace.push(TraceEvent {
tick,
kind: "collision".into(),
detail: serde_json::json!({"a": event.a, "b": event.b}),
});
}
}
events.extend(seen);
for (index, invariant) in scenario.invariants.iter().enumerate() {
if broken[index] {
continue;
}
let missing = if invariant.entity != AUDIO_ENTITY
&& find_entity(world, &invariant.entity, |_, _| true).is_none()
{
true
} else {
reflected(world, &invariant.entity)
.is_ok_and(|state| state.pointer(&invariant.path).is_none())
};
if missing && invariant.exists.is_none() {
continue;
}
if let Err((message, actual)) = check_value(world, invariant) {
broken[index] = true;
report.steps.push(StepResult {
tick,
step: 0,
ok: false,
message: format!("invariant {index}: {message}"),
actual,
});
if !scenario.keep_going {
break 'ticks;
}
}
}
for (index, step) in scenario.steps.iter().enumerate() {
let last = step.until.or(step.within).unwrap_or(step.tick);
if !(step.tick..=last).contains(&tick) || passed[index] {
continue;
}
let outcome = match &step.action {
StepAction::Expect(expect) => check_value(world, expect),
StepAction::ExpectEvents(expect) => {
check_events(world, expect, &events)
}
StepAction::ExpectScreen(expect) => {
check_screen(world, expect, scenario.capture_size)
}
StepAction::ExpectFile(expect) => check_file(expect),
StepAction::ExpectQuit(wanted) => {
let quit =
world.resource::<crate::runtime::ExitState>().requested;
if quit == *wanted {
Ok(format!("quit is {quit}"))
} else {
Err((
format!("quit is {quit}, expected {wanted}"),
Value::Bool(quit),
))
}
}
StepAction::Capture(step) => {
let relative = &step.path;
let path = base.join(relative);
let doubled = base.file_name() == relative.iter().next();
let warning = if doubled {
format!(
"; warning: capture paths are relative to {}, \
so this wrote {}",
base.display(),
path.display()
)
} else {
String::new()
};
match capture.as_mut() {
Some(Ok(capture)) => (|| {
let camera = match &step.camera {
Some(name) => Some(
find_camera(world, name).ok_or_else(|| {
(
format!("no camera has the ID or name `{name}`"),
Value::Null,
)
})?,
),
None => None,
};
let pixels = if camera.is_some() || !step.hud {
capture
.view_rgba(world, camera)
.map_err(|error| (error, Value::Null))?
} else {
capture.rgba()
};
if scenario.contact_sheet.is_some() {
frames.push((tick, pixels.clone()));
}
let mut message = save_capture(
&pixels,
world,
scenario,
&path,
&mut report.captures,
)?;
if let Some(golden) = &step.golden {
message += &compare_golden(
&pixels,
scenario.capture_size,
&base.join(golden),
step.tolerance,
scenario.update_golden,
)?;
}
Ok(message + &warning)
})(),
Some(Err(error)) => {
Ok(format!("capture skipped: {error}"))
}
None => unreachable!("captures open a renderer"),
}
}
StepAction::ExpectPixels(expect) => match capture.as_ref() {
Some(Ok(capture)) => check_pixels(
world,
expect,
&capture.rgba(),
scenario.capture_size,
),
Some(Err(error)) => Err((
format!("expect_pixels needs a renderer: {error}"),
Value::Null,
)),
None => unreachable!("pixel checks open a renderer"),
},
StepAction::Log(logged) => {
let subject =
format!("`{}` {}", logged.entity, logged.path);
let (actual, message) =
match reflected(world, &logged.entity) {
Err(error) => (
Value::Null,
format!("log: {subject}: {error}"),
),
Ok(state) => match state.pointer(&logged.path) {
Some(value) => (
value.clone(),
format!("log: {subject} is {value}"),
),
None => (
Value::Null,
format!(
"log: {subject} does not exist{}",
near_paths(&state, &logged.path)
),
),
},
};
report.steps.push(StepResult {
tick,
step: index,
ok: true,
message,
actual,
});
continue;
}
StepAction::Press(_)
| StepAction::Release(_)
| StepAction::Tap(_)
| StepAction::Set(_)
| StepAction::Click(_)
| StepAction::Restart(_)
| StepAction::Pointer(_)
| StepAction::LeftStick(_)
| StepAction::RightStick(_) => continue,
};
let (ok, message, actual) = match outcome {
Ok(message) => (true, message, Value::Null),
Err((message, actual)) => {
(false, message + &inputs_at(scenario, tick), actual)
}
};
let result = StepResult {
tick,
step: index,
ok,
message,
actual,
};
if step.within.is_some() && !ok && tick < last {
continue;
}
passed[index] = step.within.is_some() && ok;
if !ok || tick == last || passed[index] {
pending[index] = None;
report.steps.push(result);
if !ok {
if !scenario.keep_going {
break 'ticks;
}
passed[index] = true;
}
} else {
pending[index] = Some(result);
}
}
app.world_mut()
.resource_mut::<RuntimeInput>()
.clear_frame_edges();
if app.exit_requested() {
for (index, step) in scenario.steps.iter().enumerate() {
if step.tick <= tick {
continue;
}
if let StepAction::ExpectFile(expect) = &step.action {
let (ok, (message, actual)) = match check_file(expect) {
Ok(message) => (true, (message, Value::Null)),
Err(failed) => (false, failed),
};
report.steps.push(StepResult {
tick: step.tick,
step: index,
ok,
message,
actual,
});
} else if is_check(&step.action) {
report.steps.push(StepResult {
tick: step.tick,
step: index,
ok: false,
message: format!(
"the game quit at tick {tick}, before this step"
),
actual: Value::Null,
});
}
}
break;
}
}
if let (Some(sheet), false) = (&scenario.contact_sheet, frames.is_empty()) {
let extent = scenario.capture_size;
let (pixels, size) =
crate::annotate::contact_sheet(&frames, extent, 320);
let path = base.join(sheet);
let written = path
.parent()
.map_or(Ok(()), std::fs::create_dir_all)
.map_err(|error| error.to_string())
.and_then(|()| {
image::save_buffer(
&path,
&pixels,
size[0],
size[1],
image::ExtendedColorType::Rgba8,
)
.map_err(|error| error.to_string())
});
match written {
Ok(()) => report.captures.push(path),
Err(error) => report.steps.push(StepResult {
tick: report.ticks_run as u32,
step: 0,
ok: false,
message: format!("contact sheet: {error}"),
actual: Value::Null,
}),
}
}
if let Some(out) = &scenario.audio_out {
let path = base.join(out);
match crate::audio_output::write_wav(&path, &mixed) {
Ok(()) => report.captures.push(path),
Err(error) => report.steps.push(StepResult {
tick: report.ticks_run as u32,
step: 0,
ok: false,
message: format!("audio_out: {error}"),
actual: Value::Null,
}),
}
}
let render = match capture.as_mut() {
Some(Ok(capture)) => Some(capture.metadata(app)),
_ => None,
};
report.perf = perf_report(&tick_ms, render);
collect_hashes(app.world(), &mut report);
if let Some(budgets) = &scenario.budgets {
for message in over_budget(budgets, &report.perf) {
report.steps.push(StepResult {
tick: report.ticks_run as u32,
step: 0,
ok: false,
message: format!("budget: {message}"),
actual: serde_json::to_value(&report.perf).unwrap_or_default(),
});
}
}
report.first_failure = report.steps.iter().find(|step| !step.ok).cloned();
report.passed = report.first_failure.is_none();
report
}
fn perf_report(tick_ms: &[f64], render: Option<Value>) -> PerfReport {
let mut sorted = tick_ms.to_vec();
sorted.sort_by(f64::total_cmp);
let at = |fraction: f64| {
sorted
.get(
((sorted.len() as f64 * fraction).ceil() as usize)
.saturating_sub(1),
)
.copied()
.unwrap_or(0.0)
};
let mut environment = json!({
"engine_version": env!("CARGO_PKG_VERSION"),
"os": std::env::consts::OS,
"arch": std::env::consts::ARCH,
"profile": if cfg!(debug_assertions) { "debug" } else { "release" },
});
let mut counters = Value::Null;
if let Some(meta) = render {
environment["device"] = meta["device"].clone();
environment["driver"] = meta["driver"].clone();
counters = json!({
"draws": meta["draws"],
"triangles": meta["triangles"],
"visible_instances": meta["visible_instances"],
"gpu_ms": meta["gpu_ms"],
"cameras": meta["cameras"],
});
}
PerfReport {
environment,
tick_ms_mean: sorted.iter().sum::<f64>() / sorted.len().max(1) as f64,
tick_ms_p95: at(0.95),
tick_ms_max: at(1.0),
render: counters,
}
}
fn over_budget(budgets: &Budgets, perf: &PerfReport) -> Vec<String> {
let mut over = Vec::new();
let mut ms = |name: &str, limit: Option<f64>, value: f64| {
if let Some(limit) = limit.filter(|limit| value > *limit) {
over.push(format!(
"{name} is {value:.3} ms, over the {limit} ms limit"
));
}
};
ms("max_tick_ms", budgets.max_tick_ms, perf.tick_ms_max);
ms("mean_tick_ms", budgets.mean_tick_ms, perf.tick_ms_mean);
ms("p95_tick_ms", budgets.p95_tick_ms, perf.tick_ms_p95);
if let (Some(limit), Some(draws)) =
(budgets.max_draws, perf.render["draws"].as_u64())
{
if draws > u64::from(limit) {
over.push(format!(
"max_draws: {draws} draws, over the limit of {limit}"
));
}
}
if let (Some(limit), Some(triangles)) =
(budgets.max_triangles, perf.render["triangles"].as_u64())
{
if triangles > limit {
over.push(format!("max_triangles: {triangles} triangles, over the limit of {limit}"));
}
}
over
}
pub(crate) fn press(
world: &mut World,
action: &str,
pressed: bool,
press_tick: Option<f64>,
) -> Result<String, String> {
crate::runtime::bind_input_actions(world);
let bindings = world.resource::<ActionMap>().bindings(action).to_vec();
if bindings.is_empty() {
return Err(format!("action `{action}` has no bindings"));
}
let mut input = world.resource_mut::<RuntimeInput>();
for binding in bindings {
if let Some(tick) = press_tick {
input.record_press_tick(binding, tick);
}
match binding {
InputBinding::Key(key) => input.record_key(key, pressed),
InputBinding::Pad(button) => {
input.record_pad_button(button, pressed);
}
InputBinding::Mouse(button) => {
input.record_mouse_button(button, pressed);
}
}
}
let verb = if pressed { "pressed" } else { "released" };
Ok(format!("{verb} `{action}`"))
}
fn tilt_stick(
world: &mut World,
stick: Stick,
tilt: [f32; 2],
) -> Result<String, String> {
world
.resource_mut::<RuntimeInput>()
.record_stick(stick, tilt);
Ok(format!("{stick:?} stick at {tilt:?}"))
}
fn point_at(world: &mut World, point: [f32; 2]) -> Result<String, String> {
let mut input = world.resource_mut::<RuntimeInput>();
let size = input.viewport_size();
input.record_cursor_position([point[0] * size[0], point[1] * size[1]]);
Ok(format!("pointer at {point:?}"))
}
fn click(world: &mut World, target: &ClickTarget) -> Result<String, String> {
#[cfg(feature = "ui")]
{
let (label, prefix, index) = match target {
ClickTarget::Label(label) => (label.as_str(), false, None),
ClickTarget::Find {
text: Some(text),
starts_with: None,
index,
} => (text.as_str(), false, *index),
ClickTarget::Find {
text: None,
starts_with: Some(prefix),
index,
} => (prefix.as_str(), true, *index),
ClickTarget::Find { .. } => {
return Err(
"click needs exactly one of `text` and `starts_with`"
.into(),
)
}
};
let at = world
.get_resource::<crate::runtime::RuntimeUi>()
.ok_or("this build has no runtime UI")?
.find_text_where(label, prefix, index)?;
let mut input = world.resource_mut::<RuntimeInput>();
input.record_cursor_position(at);
input.record_mouse_button(MouseButton::Left, true);
Ok(format!("clicked `{label}` at {at:?}"))
}
#[cfg(not(feature = "ui"))]
{
let _ = (world, target);
Err("click needs the engine's `ui` feature".into())
}
}
fn restart(world: &mut World) -> Result<String, String> {
#[cfg(feature = "window")]
if crate::project_runner::restart_scene(world) {
return Ok("restarted the scene".into());
}
let _ = world;
Err("restart needs a game started from a scene file".into())
}
fn seed_files(scenario: &Scenario, base: &Path) -> Result<(), String> {
let folder = crate::project::user_data_folder();
for (key, source) in &scenario.files {
let relative = Path::new(key);
if !relative
.components()
.all(|part| matches!(part, std::path::Component::Normal(_)))
{
return Err(format!("files: `{key}` must be a relative path"));
}
let target = folder.join(relative);
let source = base.join(source);
target
.parent()
.map_or(Ok(()), std::fs::create_dir_all)
.and_then(|()| std::fs::copy(&source, &target))
.map_err(|error| {
format!(
"files: copying {} to {}: {error}",
source.display(),
target.display()
)
})?;
}
Ok(())
}
fn is_check(action: &StepAction) -> bool {
matches!(
action,
StepAction::Expect(_)
| StepAction::ExpectEvents(_)
| StepAction::ExpectScreen(_)
| StepAction::ExpectQuit(_)
| StepAction::ExpectFile(_)
| StepAction::ExpectPixels(_)
| StepAction::Capture(_)
)
}
fn inputs_at(scenario: &Scenario, tick: u32) -> String {
let inputs: Vec<_> = scenario
.steps
.iter()
.filter(|step| step.tick == tick && !is_check(&step.action))
.filter(|step| !matches!(step.action, StepAction::Log(_)))
.filter_map(|step| serde_json::to_value(&step.action).ok())
.map(|action| action.to_string())
.collect();
if inputs.is_empty() {
String::new()
} else {
format!(
" (inputs at this tick ran before the check: {})",
inputs.join(", ")
)
}
}
type Check = Result<String, (String, Value)>;
pub(crate) fn reflected(
world: &mut World,
wanted: &str,
) -> Result<Value, String> {
if wanted == AUDIO_ENTITY {
let queue = world
.get_resource::<crate::runtime::AudioQueue>()
.cloned()
.unwrap_or_default();
let mix = world
.get_resource::<AudioMix>()
.cloned()
.unwrap_or_default();
return Ok(serde_json::json!({
"requested": queue.requested(),
"clips": queue.requested_clips(),
"level": mix.level,
"peak": mix.peak,
"clipped": mix.clipped,
"playing": mix.playing,
}));
}
let entity = find_entity(world, wanted, |_, _| true)
.ok_or_else(|| format!("no entity has the ID or name `{wanted}`"))?;
let id = world
.get::<SceneId>(entity)
.ok_or_else(|| format!("`{wanted}` has no persistent ID"))?
.0;
let gpu_state =
world.get::<crate::runtime::GpuStateMirror>(entity).copied();
let document =
scene_document_lenient(world, "").map_err(|error| error.to_string())?;
let entity = document
.entities
.into_iter()
.find(|entity| entity.id == id)
.ok_or_else(|| format!("`{wanted}` is not part of the scene"))?;
let mut value =
serde_json::to_value(entity).map_err(|error| error.to_string())?;
if let Some(state) = gpu_state {
value["gpu_state"] = serde_json::json!({
"tick": state.tick,
"position": state.transform.position,
"rotation": state.transform.rotation,
"linear_velocity": state.linear_velocity,
"angular_velocity": state.angular_velocity,
});
}
if let Some(Value::Object(components)) = value.get_mut("components") {
for component in components.values_mut() {
if let Some(parsed) = component
.as_str()
.and_then(|text| serde_json::from_str(text).ok())
{
*component = parsed;
}
}
}
Ok(value)
}
pub(crate) fn assign(
world: &mut World,
set: &Assignment,
) -> Result<String, String> {
let counter = set.entity.strip_prefix(COUNTER_PREFIX);
if let (Some(name), COUNTER_VALUE) = (counter, set.path.as_str()) {
let value = set.value.as_i64().ok_or_else(|| {
format!("counter `{name}`: expected a whole number")
})?;
crate::project_runner::GameScene { world }
.set_counter(name, value as i32);
return Ok(format!("`{}` {} set to {value}", set.entity, set.path));
}
let entity =
find_entity(world, &set.entity, |_, _| true).ok_or_else(|| {
match counter {
Some(name) => format!("no counter is named `{name}`"),
None => {
format!("no entity has the ID or name `{}`", set.entity)
}
}
})?;
let subject = format!("`{}` {}", set.entity, set.path);
if let Some(rest) = set.path.strip_prefix("/components/") {
let (name, field) = rest.split_once('/').unwrap_or((rest, ""));
if field.is_empty() {
set_registered_component(
world,
entity,
name,
&set.value.to_string(),
)
} else {
set_registered_component_field(
world,
entity,
name,
&format!("/{field}"),
set.value.clone(),
)
}
.map_err(|error| format!("{subject}: {error}"))?;
} else if let Some(field) = set.path.strip_prefix("/transform") {
let transform = world
.get::<Transform>(entity)
.ok_or_else(|| format!("`{}` has no transform", set.entity))?;
let mut value = serde_json::to_value(SceneTransform::from(*transform))
.expect("transforms serialize");
let slot = value
.pointer_mut(field)
.ok_or_else(|| format!("{subject} does not exist"))?;
*slot = set.value.clone();
let transform: SceneTransform = serde_json::from_value(value)
.map_err(|error| format!("{subject}: {error}"))?;
world.entity_mut(entity).insert(Transform::from(transform));
} else if let Some(field) = set.path.strip_prefix("/visible") {
if !field.is_empty() {
return Err(format!("{subject} does not exist"));
}
let visible = set
.value
.as_bool()
.ok_or_else(|| format!("{subject}: expected true or false"))?;
world
.entity_mut(entity)
.insert(crate::runtime::Visibility { visible });
} else {
use crate::runtime::{
Collider, CollisionLayers, DirectionalLight, PhysicsBody,
PointLight, RigidBody, SpotLight,
};
let (key, field) = set.path[1..]
.split_once('/')
.map_or((&set.path[1..], ""), |(key, field)| (key, field));
let field = if field.is_empty() {
String::new()
} else {
format!("/{field}")
};
let edit = |world: &mut World| -> Result<(), String> {
match key {
"rigid_body" => {
set_part::<RigidBody>(world, entity, &field, set)
}
"collider" => set_part::<Collider>(world, entity, &field, set),
"physics_body" => {
set_part::<PhysicsBody>(world, entity, &field, set)
}
"collision_layers" => {
set_part::<CollisionLayers>(world, entity, &field, set)
}
"point_light" => {
set_part::<PointLight>(world, entity, &field, set)
}
"spot_light" => {
set_part::<SpotLight>(world, entity, &field, set)
}
"directional_light" => {
set_part::<DirectionalLight>(world, entity, &field, set)
}
_ => Err("set reaches /transform, /visible, /rigid_body, \
/collider, /physics_body, /collision_layers, the light \
fields and /components/... only"
.to_owned()),
}
};
edit(world).map_err(|error| format!("{subject}: {error}"))?;
}
Ok(format!("{subject} set to {}", set.value))
}
fn set_part<T>(
world: &mut World,
entity: Entity,
pointer: &str,
set: &Assignment,
) -> Result<(), String>
where
T: bevy_ecs::component::Component<
Mutability = bevy_ecs::component::Mutable,
> + Serialize
+ serde::de::DeserializeOwned,
{
let mut part = world
.get_mut::<T>(entity)
.ok_or_else(|| "the entity does not have that component".to_owned())?;
let mut value =
serde_json::to_value(&*part).map_err(|error| error.to_string())?;
let slot = value
.pointer_mut(pointer)
.ok_or_else(|| "that field does not exist".to_owned())?;
*slot = set.value.clone();
*part = serde_json::from_value(value).map_err(|error| error.to_string())?;
Ok(())
}
fn check_screen(
world: &mut World,
expect: &ScreenExpectation,
extent: [u32; 2],
) -> Check {
let subject = format!("`{}`", expect.entity);
let entity =
find_entity(world, &expect.entity, |_, _| true).ok_or_else(|| {
(
format!("no entity has the ID or name `{}`", expect.entity),
Value::Null,
)
})?;
let id = world.get::<SceneId>(entity).map(|id| id.0);
let view = match &expect.camera {
Some(name) => find_camera(world, name)
.and_then(|camera| CameraView::of(world, camera, extent))
.ok_or_else(|| {
(
format!("no camera has the ID or name `{name}`"),
Value::Null,
)
})?,
None => CameraView::active(world, extent).ok_or_else(|| {
("the scene has no active camera".to_owned(), Value::Null)
})?,
};
let extent = view.extent();
let note = crate::annotate::annotations_from(world, &view)
.into_iter()
.find(|note| Some(note.id) == id);
let share_of = |world: &mut World, rect: [u32; 4]| {
view.pick_rect(world, rect)["entities"]
.as_array()
.and_then(|hits| hits.iter().find(|hit| hit["id"] == json!(id)))
.map_or(0.0, |hit| hit["share"].as_f64().unwrap_or(0.0))
};
let (box_share, frame_share) = match ¬e {
Some(note) => {
let [l, t, r, b] = note.rect.map(|v| v.max(0) as u32);
(
share_of(world, [l, t, r + 1, b + 1]),
share_of(world, [0, 0, extent[0], extent[1]]),
)
}
None => (0.0, 0.0),
};
let visible = box_share > 0.0;
let actual = json!({
"rect": note.as_ref().map(|note| note.rect),
"visible": visible,
"box_share": box_share,
"frame_share": frame_share,
});
let fail = |wanted: String| {
Err((
format!("{subject} is {actual}, expected {wanted}"),
actual.clone(),
))
};
if let Some(wanted) = expect.on_screen {
if visible != wanted {
return fail(format!("on_screen {wanted}"));
}
}
if let Some(wanted) = expect.occluded {
if (note.is_some() && !visible) != wanted {
return fail(format!("occluded {wanted}"));
}
}
if let Some([left, top, right, bottom]) = expect.inside {
let (w, h) = (extent[0] as f32, extent[1] as f32);
let inside = note.as_ref().is_some_and(|note| {
let [l, t, r, b] = note.rect.map(|v| v as f32);
l >= left * w && t >= top * h && r <= right * w && b <= bottom * h
});
if !inside {
return fail(format!("inside {:?}", expect.inside));
}
}
if let Some(min) = expect.min_share {
if frame_share < min {
return fail(format!("frame share of at least {min}"));
}
}
Ok(format!("{subject} is on the screen as expected"))
}
fn near_paths(state: &Value, path: &str) -> String {
if path.starts_with("/gpu_state") && state.get("gpu_state").is_none() {
return "; /gpu_state exists only on a GPU body with \
`sync: PhysicsSyncMode::SelectedState` or `FullState`, \
after its first readback"
.into();
}
fn walk(value: &Value, at: String, out: &mut Vec<String>) {
let children: Vec<(String, &Value)> = match value {
Value::Object(map) => map
.iter()
.map(|(key, child)| {
(key.replace('~', "~0").replace('/', "~1"), child)
})
.collect(),
Value::Array(items) => items
.iter()
.take(4)
.enumerate()
.map(|(index, child)| (index.to_string(), child))
.collect(),
_ => Vec::new(),
};
for (key, child) in children {
let child_path = format!("{at}/{key}");
out.push(child_path.clone());
walk(child, child_path, out);
}
}
let mut paths = Vec::new();
walk(state, String::new(), &mut paths);
let last = |path: &str| path.rsplit('/').next().unwrap_or("").to_owned();
paths.sort_by_key(|candidate| {
(
last(candidate) != last(path),
crate::scene_patch::edit_distance(candidate, path),
)
});
if paths.is_empty() {
return String::new();
}
format!("; did you mean {}", paths[..paths.len().min(3)].join(", "))
}
fn check_value(world: &mut World, expect: &Expectation) -> Check {
let subject = format!("`{}` {}", expect.entity, expect.path);
let state = reflected(world, &expect.entity);
if let Some(wanted) = expect.exists {
let found = state
.as_ref()
.is_ok_and(|state| state.pointer(&expect.path).is_some());
if found != wanted {
let missing = if wanted {
"does not exist"
} else {
"still exists"
};
return Err((format!("{subject} {missing}"), Value::Bool(found)));
}
if !found {
return Ok(format!("{subject} does not exist"));
}
}
let state = state.map_err(|error| (error, Value::Null))?;
let actual = state.pointer(&expect.path).cloned().ok_or_else(|| {
(
format!(
"{subject} does not exist{}",
near_paths(&state, &expect.path)
),
Value::Null,
)
})?;
let number = actual.as_f64();
if expect.finite && has_null_number(&actual) {
return Err((
format!("{subject} is {actual}, expected finite numbers"),
actual,
));
}
let fail = |wanted: String| {
Err((
format!("{subject} is {actual}, expected {wanted}"),
actual.clone(),
))
};
if let Some(equals) = &expect.equals {
if !close(&actual, equals, expect.tolerance) {
return fail(format!("{equals} ± {}", expect.tolerance));
}
}
if let Some(differs) = &expect.not_equals {
if close(&actual, differs, expect.tolerance) {
return fail(format!("not {differs} ± {}", expect.tolerance));
}
}
if let Some(bound) = expect.greater_than {
if number.is_none_or(|number| number <= bound) {
return fail(format!("greater than {bound}"));
}
}
if let Some(bound) = expect.less_than {
if number.is_none_or(|number| number >= bound) {
return fail(format!("less than {bound}"));
}
}
Ok(format!("{subject} is {actual}"))
}
fn has_null_number(value: &Value) -> bool {
match value {
Value::Null => true,
Value::Array(items) => items.iter().any(has_null_number),
Value::Object(fields) => fields.values().any(has_null_number),
_ => false,
}
}
fn close(actual: &Value, wanted: &Value, tolerance: f64) -> bool {
match (actual, wanted) {
(Value::Number(a), Value::Number(b)) => {
let whole = |n: &serde_json::Number| n.is_i64() || n.is_u64();
a.as_f64().zip(b.as_f64()).is_some_and(|(x, y)| {
(x - y).abs() <= tolerance
|| (!(whole(a) && whole(b))
&& f64::from(x as f32) == x
&& x as f32 == y as f32)
})
}
(Value::Array(a), Value::Array(b)) => {
a.len() == b.len()
&& a.iter().zip(b).all(|(a, b)| close(a, b, tolerance))
}
(Value::Object(a), Value::Object(b)) => {
a.len() == b.len()
&& a.iter().all(|(key, a)| {
b.get(key).is_some_and(|b| close(a, b, tolerance))
})
}
_ => actual == wanted,
}
}
fn check_events(
world: &mut World,
expect: &EventExpectation,
events: &[SeenEvent],
) -> Check {
let involved = match &expect.entity {
Some(wanted) => Some(
find_entity(world, wanted, |_, _| true)
.and_then(|entity| world.get::<SceneId>(entity))
.map(|id| id.0)
.ok_or_else(|| {
(
format!("no entity has the ID or name `{wanted}`"),
Value::Null,
)
})?,
),
None => None,
};
let count = events
.iter()
.filter(|event| {
involved.is_none_or(|id| event.a == Some(id) || event.b == Some(id))
})
.count();
let subject = match &expect.entity {
Some(entity) => {
format!("{:?} events involving `{entity}`", expect.kind)
}
None => format!("{:?} events", expect.kind),
};
if expect.at_least.is_some_and(|least| count < least)
|| expect.at_most.is_some_and(|most| count > most)
{
return Err((
format!(
"{subject}: {count}, expected {}..={}",
expect.at_least.unwrap_or(0),
expect.at_most.map_or("any".into(), |most| most.to_string())
),
json!(count),
));
}
Ok(format!("{subject}: {count}"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::{
Collider, ColliderShape, HybridPhysicsPlugin, InputBinding, KeyCode,
PhysicsBody, RenderExtractPlugin, RigidBody, RigidBodyKind,
ScheduleStage,
};
#[test]
fn set_reaches_built_in_components() {
let mut world = World::new();
let entity = world
.spawn((
Name("Crate".into()),
RigidBody::default(),
Collider::default(),
))
.id();
let set = |path: &str, value: Value| Assignment {
entity: "Crate".into(),
counter: None,
path: path.into(),
value,
};
assign(&mut world, &set("/collider/friction", json!(0.0))).unwrap();
assign(&mut world, &set("/rigid_body/mass", json!(0.2))).unwrap();
assign(&mut world, &set("/visible", json!(false))).unwrap();
assert_eq!(world.get::<Collider>(entity).unwrap().friction, 0.0);
assert_eq!(world.get::<RigidBody>(entity).unwrap().mass, 0.2);
assert!(
!world
.get::<crate::runtime::Visibility>(entity)
.unwrap()
.visible
);
let missing =
assign(&mut world, &set("/point_light/range", json!(3.0)));
assert!(missing.unwrap_err().contains("does not have"));
let typo = assign(&mut world, &set("/collider/frction", json!(1.0)));
assert!(typo.unwrap_err().contains("does not exist"));
}
#[test]
fn a_press_before_the_first_update_leaves_no_stale_binding() {
use crate::runtime::InputAction;
let mut app = App::new();
let owner = app
.world_mut()
.spawn(InputAction {
action: "fire".into(),
inputs: vec!["KeyF".into()],
})
.id();
press(app.world_mut(), "fire", true, None).unwrap();
app.update(std::time::Duration::from_millis(16)).unwrap();
app.world_mut().despawn(owner);
app.update(std::time::Duration::from_millis(16)).unwrap();
assert!(app
.world()
.resource::<ActionMap>()
.bindings("fire")
.is_empty());
}
#[test]
fn a_press_at_a_fraction_records_that_press_tick() {
use crate::runtime::InputAction;
let mut app = App::new();
app.world_mut().spawn(InputAction {
action: "fire".into(),
inputs: vec!["KeyF".into()],
});
press(app.world_mut(), "fire", true, Some(121.4)).unwrap();
let world = app.world();
let tick = world
.resource::<ActionMap>()
.press_tick(world.resource::<RuntimeInput>(), "fire");
assert_eq!(tick, Some(121.4));
}
#[test]
fn whole_numbers_above_the_f32_range_match_exactly() {
assert!(!close(
&serde_json::json!(16_777_216_u64),
&serde_json::json!(16_777_217_u64),
0.0
));
assert!(close(
&serde_json::json!(16_777_217_u64),
&serde_json::json!(16_777_217_u64),
0.0
));
}
#[test]
fn a_printed_f32_matches_itself_with_tolerance_zero() {
let actual = serde_json::json!(-1.733_868_f32);
let printed: Value = serde_json::from_str(&actual.to_string()).unwrap();
assert!(close(&actual, &printed, 0.0));
let nearby: Value =
serde_json::from_str("-1.7338680028915403").unwrap();
assert!(close(&actual, &nearby, 0.0));
assert!(!close(&actual, &serde_json::json!(-1.7338), 0.0));
}
#[test]
fn tolerance_applies_to_every_number_in_a_vector_or_object() {
let position = serde_json::json!([1.5, -2.0, 0.100_000_001_5]);
assert!(close(&position, &serde_json::json!([1.5, -2, 0.1]), 1e-3));
assert!(!close(&position, &serde_json::json!([1.5, -2, 0.1]), 0.0));
assert!(!close(&position, &serde_json::json!([1.5, -2.0]), 1.0));
let color = serde_json::json!({"r": 0.5, "name": "red"});
assert!(close(
&color,
&serde_json::json!({"r": 0.51, "name": "red"}),
0.02
));
assert!(!close(
&color,
&serde_json::json!({"r": 0.5, "name": "blue"}),
1.0
));
}
#[test]
fn pad_presses_and_stick_steps_reach_runtime_input() {
let mut world = World::new();
world.insert_resource(RuntimeInput::default());
world.insert_resource(ActionMap::default());
let pad = crate::runtime::parse_input("PadSouth").unwrap();
assert!(crate::runtime::parse_input("PadSouthh")
.unwrap_err()
.contains("PadDpadUp"));
world.resource_mut::<ActionMap>().bind("jump", pad);
press(&mut world, "jump", true, None).unwrap();
tilt_stick(&mut world, Stick::Left, [0.0, 1.0]).unwrap();
let input = world.resource::<RuntimeInput>();
assert!(input.pad_held(crate::runtime::PadButton::South));
assert!(input.pad_held(crate::runtime::PadButton::LeftStickUp));
assert_eq!(input.stick(Stick::Left), [0.0, 1.0]);
}
fn game() -> App {
let mut app = App::new();
app.add_plugin(crate::AssetPlugin).unwrap();
app.add_plugin(HybridPhysicsPlugin).unwrap();
app.add_plugin(RenderExtractPlugin).unwrap();
app.world_mut()
.resource_mut::<ActionMap>()
.bind("jump", InputBinding::Key(KeyCode::Space));
app.add_system(ScheduleStage::Update, |world: &mut World| {
let held = world
.resource::<ActionMap>()
.held(world.resource::<RuntimeInput>(), "jump");
let tick = world.resource::<FrameTime>().fixed_tick;
let x = world.resource::<RandomSeed>().unit(tick, 0);
let mut query = world.query::<(&Name, &mut Transform)>();
for (name, mut transform) in query.iter_mut(world) {
if name.0 == "Ground" {
transform.position =
[x, if held { 1.0 } else { -0.5 }, 0.0];
}
}
});
for (name, y, kind, half) in [
("Cube", 3.0, RigidBodyKind::Dynamic, [0.5; 3]),
("Ground", -0.5, RigidBodyKind::Fixed, [5.0, 0.5, 5.0]),
] {
app.world_mut().spawn((
SceneId(Uuid::new_v4()),
Name(name.into()),
Transform::new([0.0, y, 0.0]),
PhysicsBody::default(),
RigidBody {
kind,
..RigidBody::default()
},
Collider {
shape: ColliderShape::Box { half_extents: half },
..Collider::default()
},
));
}
app
}
fn scenario(ticks: u32, steps: Value) -> Scenario {
serde_json::from_value(
json!({"name": "test", "seed": 7, "ticks": ticks, "steps": steps}),
)
.unwrap()
}
fn run(scenario: &Scenario) -> ScenarioReport {
run_scenario(&mut game(), scenario, Path::new("."))
}
#[test]
fn keep_going_reports_every_failed_check() {
let mut scenario = scenario(
5,
json!([
{"tick": 1, "until": 3, "expect": {"entity": "Cube",
"path": "/transform/position/1", "equals": 99.0}},
{"tick": 4, "expect": {"entity": "Cube",
"path": "/transform/position/1", "equals": -99.0}},
]),
);
assert_eq!(run(&scenario).steps.len(), 1);
scenario.keep_going = true;
let report = run(&scenario);
assert!(!report.passed);
let failed: Vec<_> = report.steps.iter().filter(|s| !s.ok).collect();
assert_eq!(
failed.iter().map(|s| (s.tick, s.step)).collect::<Vec<_>>(),
[(1, 0), (4, 1)]
);
assert_eq!(report.ticks_run, 5);
}
#[test]
fn screen_checks_see_a_mesh_in_front_of_a_camera_and_behind_a_wall() {
use crate::runtime::{Camera, MeshRenderer, Projection};
let mut app = App::new();
app.add_plugin(crate::AssetPlugin).unwrap();
app.add_plugin(RenderExtractPlugin).unwrap();
let (mesh, material) = {
let assets = app.world().resource::<AssetServer>();
(assets.fallback_mesh, assets.fallback_material)
};
app.spawn((
Transform::new([0.0, 0.0, 5.0]),
Camera {
projection: Projection::Perspective {
vertical_fov_radians: 1.0,
near: 0.1,
far: 100.0,
},
active: true,
priority: 0,
viewport: None,
},
));
for (name, at, scale) in [
("Near", [0.0, 0.0, 0.0], 1.0),
("Wall", [0.0, 0.0, 2.0], 6.0),
("Far", [0.0, 0.0, -3.0], 1.0),
("Side", [40.0, 0.0, 0.0], 1.0),
] {
app.spawn((
SceneId(Uuid::new_v4()),
Name(name.into()),
Transform::new(at).with_scale(scale, scale, scale),
MeshRenderer {
mesh,
material,
cast_shadows: true,
receive_shadows: true,
},
));
}
let mut scenario = scenario(
1,
json!([
{"tick": 1, "expect_screen": {"entity": "Wall", "on_screen": true,
"inside": [0.0, 0.0, 1.0, 1.0], "min_share": 0.2}},
{"tick": 1, "expect_screen": {"entity": "Far", "occluded": true}},
{"tick": 1, "expect_screen": {"entity": "Side", "on_screen": false}},
{"tick": 1, "expect_screen": {"entity": "Far", "on_screen": true}},
{"tick": 1, "expect_screen": {"entity": "Near", "min_share": 0.01}},
]),
);
scenario.keep_going = true;
let report = run_scenario(&mut app, &scenario, Path::new("."));
let failed: Vec<_> = report
.steps
.iter()
.filter(|s| !s.ok)
.map(|s| s.step)
.collect();
assert_eq!(failed, [3, 4], "{:?}", report.steps);
}
#[test]
fn invariants_are_checked_every_tick_and_report_the_first_bad_tick() {
let mut holds = scenario(4, json!([]));
holds.invariants = serde_json::from_value(json!([
{"entity": "Cube", "path": "/transform/position", "finite": true},
{"entity": "Cube", "path": "/transform/position/1", "greater_than": -1000.0},
{"entity": "Gone", "path": "/transform", "finite": true},
]))
.unwrap();
let report = run(&holds);
assert!(report.passed, "{:?}", report.steps);
let mut broken = scenario(4, json!([]));
broken.invariants = serde_json::from_value(json!([
{"entity": "Cube", "path": "/transform/position/1", "less_than": -1000.0},
]))
.unwrap();
let failure = run(&broken).first_failure.unwrap();
assert_eq!(failure.tick, 0, "checked after the first tick");
assert!(failure.message.starts_with("invariant 0:"), "{failure:?}");
assert!(has_null_number(&json!([0.0, null, 1.0])));
assert!(!has_null_number(&json!([0.0, 2.0])));
let mut empty = scenario(1, json!([]));
empty.invariants =
serde_json::from_value(json!([{"entity": "Cube"}])).unwrap();
assert!(run(&empty)
.first_failure
.unwrap()
.message
.contains("invariant 0"));
}
#[test]
#[cfg_attr(
not(feature = "gpu-tests"),
ignore = "run with `--features gpu-tests` on a machine with a Vulkan driver"
)]
fn the_gpu_flag_opens_the_device_without_a_capture_step() {
let mut scenario = scenario(2, json!([]));
scenario.gpu = true;
let report = run(&scenario);
assert!(report.passed, "{:?}", report.steps);
assert!(!report.perf.render.is_null(), "a renderer was opened");
}
#[test]
fn perf_is_reported_and_budgets_fail_the_run() {
let mut passing = scenario(3, json!([]));
passing.budgets = Some(Budgets {
max_tick_ms: Some(60_000.0),
..Budgets::default()
});
let report = run(&passing);
assert!(report.passed, "{:?}", report.steps);
assert!(report.perf.tick_ms_max >= report.perf.tick_ms_mean);
assert!(report.perf.tick_ms_p95 > 0.0);
assert_eq!(report.perf.environment["os"], std::env::consts::OS);
assert!(report.perf.render.is_null());
let mut failing = scenario(3, json!([]));
failing.budgets = Some(Budgets {
max_tick_ms: Some(0.0),
..Budgets::default()
});
let report = run(&failing);
assert!(!report.passed);
assert!(report
.first_failure
.unwrap()
.message
.starts_with("budget: max_tick_ms"));
}
#[test]
#[cfg_attr(
not(feature = "gpu-tests"),
ignore = "run with `--features gpu-tests` on a machine with a Vulkan driver"
)]
fn a_render_budget_reports_render_counters_without_a_capture() {
use crate::runtime::{Camera, MeshRenderer, Projection};
let mut app = App::new();
app.add_plugin(crate::AssetPlugin).unwrap();
app.add_plugin(RenderExtractPlugin).unwrap();
let (mesh, material) = {
let assets = app.world().resource::<AssetServer>();
(assets.fallback_mesh, assets.fallback_material)
};
app.spawn((
Transform::new([0.0, 0.0, 5.0]),
Camera {
projection: Projection::Perspective {
vertical_fov_radians: 1.0,
near: 0.1,
far: 100.0,
},
active: true,
priority: 0,
viewport: None,
},
));
app.spawn((
SceneId(Uuid::new_v4()),
Transform::default(),
MeshRenderer {
mesh,
material,
cast_shadows: true,
receive_shadows: true,
},
));
let mut scenario = scenario(2, json!([]));
scenario.capture_size = [160, 90];
scenario.budgets = Some(Budgets {
max_draws: Some(0),
..Budgets::default()
});
let report = run_scenario(&mut app, &scenario, &std::env::temp_dir());
assert!(report.perf.render["draws"].as_u64().unwrap() > 0);
assert!(report.perf.render["gpu_ms"].is_number());
assert!(report.perf.environment["device"].is_string());
assert!(!report.passed);
assert!(report
.first_failure
.unwrap()
.message
.starts_with("budget: max_draws"));
}
#[test]
#[cfg_attr(
not(feature = "gpu-tests"),
ignore = "run with `--features gpu-tests` on a machine with a Vulkan driver"
)]
fn pixel_checks_goldens_and_named_cameras() {
use crate::runtime::{Camera, MeshRenderer, Projection};
let mut app = App::new();
app.add_plugin(crate::AssetPlugin).unwrap();
app.add_plugin(RenderExtractPlugin).unwrap();
let (mesh, material) = {
let assets = app.world().resource::<AssetServer>();
(assets.fallback_mesh, assets.fallback_material)
};
let camera = |active| Camera {
projection: Projection::Perspective {
vertical_fov_radians: 1.0,
near: 0.1,
far: 100.0,
},
active,
priority: 0,
viewport: None,
};
app.spawn((
Name("Main".into()),
Transform::new([0.0, 0.0, 3.0]),
camera(true),
));
let mut away = Transform::new([0.0, 0.0, 3.0]);
away.rotation = [0.0, std::f32::consts::PI, 0.0];
app.spawn((Name("Away".into()), away, camera(false)));
app.spawn((
SceneId(Uuid::new_v4()),
Name("Cube".into()),
Transform::default(),
MeshRenderer {
mesh,
material,
cast_shadows: true,
receive_shadows: true,
},
));
let directory = std::env::temp_dir()
.join(format!("rusting-golden-{}", std::process::id()));
let mut run = scenario(
1,
json!([
{"tick": 1, "expect_pixels": {"stddev_min": 1.0}},
{"tick": 1, "expect_screen": {"entity": "Cube", "on_screen": true}},
{"tick": 1, "expect_screen": {"entity": "Cube", "camera": "Away",
"on_screen": false}},
{"tick": 1, "capture": {"path": "main.png", "golden": "main.golden.png"}},
{"tick": 1, "capture": {"path": "away.png", "camera": "Away",
"golden": "away.golden.png"}},
]),
);
run.capture_size = [64, 48];
run.update_golden = true;
let report = run_scenario(&mut app, &run, &directory);
assert!(report.passed, "{:?}", report.first_failure);
assert!(directory.join("away.golden.png").is_file());
run.update_golden = false;
let report = run_scenario(&mut app, &run, &directory);
assert!(report.passed, "{:?}", report.first_failure);
std::fs::copy(
directory.join("main.golden.png"),
directory.join("away.golden.png"),
)
.unwrap();
let report = run_scenario(&mut app, &run, &directory);
let failure = report.first_failure.unwrap();
assert!(
failure.message.contains("differs from golden"),
"{failure:?}"
);
let _ = std::fs::remove_dir_all(directory);
}
#[test]
fn capture_steps_take_a_path_or_options() {
let run = scenario(
1,
json!([
{"tick": 1, "capture": "a.png"},
{"tick": 1, "capture": {"path": "b.png", "hud": false,
"golden": "g.png", "tolerance": 2.0}},
]),
);
let StepAction::Capture(plain) = &run.steps[0].action else {
panic!()
};
assert!(plain.hud && plain.golden.is_none() && plain.tolerance == 1.0);
let StepAction::Capture(full) = &run.steps[1].action else {
panic!()
};
assert!(!full.hud && full.tolerance == 2.0);
assert_eq!(full.golden.as_deref(), Some(Path::new("g.png")));
}
#[test]
fn region_stats_and_golden_differences() {
let pixels = [0, 0, 0, 255, 255, 255, 255, 255];
let (mean, stddev) = region_stats(&pixels, 2, [0, 0, 2, 1]);
assert_eq!(mean, [127.5; 3]);
assert!((stddev - 127.5).abs() < 1e-6);
let golden = std::env::temp_dir()
.join(format!("rusting-golden-unit-{}.png", std::process::id()));
assert!(compare_golden(&pixels, [2, 1], &golden, 0.0, true).is_ok());
assert!(compare_golden(&pixels, [2, 1], &golden, 0.0, false).is_ok());
let shifted = [4, 4, 4, 255, 255, 255, 255, 255];
let (message, actual) =
compare_golden(&shifted, [2, 1], &golden, 1.0, false).unwrap_err();
assert!(message.contains("mean difference 2.000"), "{message}");
assert_eq!(actual["max_difference"], 4);
let _ = std::fs::remove_file(golden);
}
#[test]
fn the_trace_lists_inputs_sets_and_collisions_by_tick() {
let report = run(&scenario(
90,
json!([
{"tick": 5, "press": "jump"},
{"tick": 6, "release": "jump"},
{"tick": 7, "set": {"entity": "Ground",
"path": "/transform/position", "value": [0.0, -0.5, 0.0]}},
]),
));
let kinds: Vec<_> = report
.trace
.iter()
.map(|event| (event.tick, event.kind.as_str()))
.collect();
assert_eq!(&kinds[..3], [(5, "input"), (6, "input"), (7, "set")]);
assert!(kinds.iter().any(|(_, kind)| *kind == "collision"));
assert!(kinds.windows(2).all(|pair| pair[0].0 <= pair[1].0));
}
#[test]
fn set_steps_move_entities_before_the_tick_runs() {
let report = run(&scenario(
20,
json!([
{"tick": 10, "set": {"entity": "Cube",
"path": "/transform/position", "value": [0.0, 20.0, 0.0]}},
{"tick": 10, "expect": {"entity": "Cube",
"path": "/transform/position/1", "greater_than": 19.0}},
{"tick": 11, "set": {"entity": "Cube",
"path": "/mesh", "value": 1}},
]),
));
assert!(report.steps[0].ok && report.steps[1].ok, "{report:#?}");
assert!(!report.passed);
assert!(report.steps[2].message.contains("set reaches /transform"));
}
#[test]
fn counter_shorthand_sets_checks_and_logs_a_counter_by_name() {
let mut app = game();
app.world_mut().spawn((
SceneId(Uuid::new_v4()),
Name("Score Counter".into()),
crate::runtime::Counter {
name: "score".into(),
value: 0,
target: None,
},
));
let scenario = scenario(
4,
json!([
{"tick": 2, "set": {"counter": "score", "value": 5}},
{"tick": 2, "expect": {"counter": "score", "equals": 5}},
{"tick": 3, "expect": {"counter": "score", "not_equals": 4}},
{"tick": 3, "log": {"counter": "score"}},
{"tick": 3, "set": {"counter": "fresh", "value": 2}},
{"tick": 3, "expect": {"counter": "fresh", "equals": 2}},
{"tick": 4, "expect": {"counter": "score", "not_equals": 5}},
]),
);
let report = run_scenario(&mut app, &scenario, Path::new("."));
assert!(report.steps[..6].iter().all(|s| s.ok), "{report:#?}");
let log = report.steps.iter().find(|s| s.step == 3).unwrap();
assert_eq!(log.actual, json!(5));
let last = report.first_failure.expect("not_equals 5 fails");
assert!(last.message.contains("expected not 5"), "{}", last.message);
let both = scenario_from(json!({"tick": 1, "expect": {
"counter": "score", "entity": "Cube", "equals": 1}}));
assert!(both.validate().unwrap_err().contains("either entity"));
let neither =
scenario_from(json!({"tick": 1, "expect": {"equals": 1}}));
assert!(neither.validate().is_err());
}
#[test]
fn the_audio_entity_counts_sound_cue_requests_per_clip() {
let mut app = game();
let mut cue = crate::runtime::SoundCue {
clip: "sfx/hit.wav".into(),
..Default::default()
};
cue.trigger();
app.world_mut().spawn(cue);
let report = run_scenario(
&mut app,
&scenario(
4,
json!([
{"tick": 0, "expect": {"entity": "audio:",
"path": "/requested", "equals": 0}},
{"tick": 3, "expect": {"entity": "audio:",
"path": "/clips/sfx~1hit.wav", "equals": 1}},
{"tick": 4, "expect": {"entity": "audio:",
"path": "/requested", "equals": 1}},
]),
),
Path::new("."),
);
assert!(report.passed, "{report:#?}");
}
#[cfg(feature = "audio")]
#[test]
fn the_offline_mix_reports_levels_pan_buses_and_scheduled_starts() {
use crate::runtime::{AudioQueue, Sound};
let directory = std::env::temp_dir()
.join(format!("rusting-mix-{}", Uuid::new_v4()));
let tone: Vec<f32> = (0..48_000)
.flat_map(|i| {
let s = (i as f32 * 440.0 * std::f32::consts::TAU / 48_000.0)
.sin()
* 0.5;
[s, s]
})
.collect();
crate::audio_output::write_wav(
&directory.join("assets/tone.wav"),
&tone,
)
.unwrap();
let mut app = game();
app.world_mut()
.insert_resource(crate::project_runner::ProjectFolder(
directory.clone(),
));
app.add_system(ScheduleStage::Update, |world: &mut World| {
let tick = world.resource::<FrameTime>().fixed_tick;
let mut queue =
world.get_resource_or_insert_with(AudioQueue::default);
match tick {
2 => {
let left = Sound {
pan: -1.0,
bus: "music".into(),
..Sound::default()
};
queue.play("tone.wav", &left, tick);
}
10 => queue.set_bus_volume("music", 0.0, 0.0),
14 => {
let later = Sound {
at_tick: Some(20),
..Sound::default()
};
queue.play("tone.wav", &later, tick);
}
_ => {}
}
});
let mut run = scenario(
20,
json!([
{"tick": 1, "expect": {"entity": "audio:", "path": "/level/0",
"equals": 0.0}},
{"tick": 3, "expect": {"entity": "audio:", "path": "/level/0",
"greater_than": 0.1}},
{"tick": 3, "expect": {"entity": "audio:", "path": "/level/1",
"less_than": 0.001}},
{"tick": 3, "expect": {"entity": "audio:", "path": "/peak/0",
"greater_than": 0.1}},
{"tick": 3, "expect": {"entity": "audio:", "path": "/peak/0",
"less_than": 1.0}},
{"tick": 3, "expect": {"entity": "audio:", "path": "/clipped",
"equals": 0}},
{"tick": 3, "expect": {"entity": "audio:",
"path": "/playing/0/bus", "equals": "music"}},
{"tick": 12, "expect": {"entity": "audio:", "path": "/level/0",
"less_than": 0.001}},
{"tick": 18, "expect": {"entity": "audio:", "path": "/level/1",
"less_than": 0.001}},
{"tick": 20, "expect": {"entity": "audio:", "path": "/level/1",
"greater_than": 0.1}},
]),
);
run.audio_out = Some("mix.wav".into());
let report = run_scenario(&mut app, &run, &directory);
assert!(report.passed, "{report:#?}");
let written = std::fs::metadata(directory.join("mix.wav")).unwrap();
assert_eq!(written.len(), 44 + 21 * 800 * 4);
std::fs::remove_dir_all(&directory).unwrap();
}
fn scenario_from(step: Value) -> Scenario {
scenario(2, json!([step]))
}
#[test]
fn inputs_state_and_events_pass_at_fixed_ticks() {
let report = run(&scenario(
90,
json!([
{"tick": 0, "expect": {"entity": "Cube",
"path": "/transform/position/1", "equals": 3.0}},
{"tick": 10, "expect": {"entity": "Cube",
"path": "/transform/position/1", "less_than": 3.0}},
{"tick": 90, "expect_events": {"kind": "collision",
"entity": "Cube", "at_least": 1}},
{"tick": 91, "press": "jump"},
]),
));
assert!(!report.passed);
assert!(report.first_failure.unwrap().message.contains("outside"));
let report = run(&scenario(
90,
json!([
{"tick": 0, "expect": {"entity": "Cube",
"path": "/transform/position/1", "equals": 3.0}},
{"tick": 10, "expect": {"entity": "Cube",
"path": "/transform/position/1", "less_than": 3.0}},
{"tick": 90, "expect_events": {"kind": "collision",
"entity": "Cube", "at_least": 1}},
{"tick": 5, "press": "jump"},
{"tick": 5, "expect": {"entity": "Ground",
"path": "/transform/position/1", "equals": 1.0}},
{"tick": 6, "release": "jump"},
{"tick": 6, "expect": {"entity": "Ground",
"path": "/transform/position/1", "equals": -0.5}},
]),
));
assert!(report.passed, "{report:#?}");
assert_eq!(report.ticks_run, 90);
let report = run(&scenario(
10,
json!([
{"tick": 5, "tap": "jump"},
{"tick": 5, "expect": {"entity": "Ground",
"path": "/transform/position/1", "equals": 1.0}},
{"tick": 6, "expect": {"entity": "Ground",
"path": "/transform/position/1", "equals": -0.5}},
]),
));
assert!(report.passed, "{report:#?}");
}
#[test]
fn pointer_steps_place_the_cursor_in_the_capture_sized_view() {
let mut app = game();
let mut run = scenario(3, json!([{"tick": 2, "pointer": [0.25, 0.5]}]));
run.capture_size = [800, 600];
assert!(run_scenario(&mut app, &run, Path::new(".")).passed);
let input = app.world().resource::<RuntimeInput>();
assert_eq!(input.viewport_size(), [800.0, 600.0]);
assert_eq!(input.cursor_position(), Some([200.0, 300.0]));
let step: ScenarioStep =
serde_json::from_value(json!({"tick": 3, "pointer": [0.5, 0.5]}))
.unwrap();
assert!(matches!(step.action, StepAction::Pointer([0.5, 0.5])));
}
#[cfg(feature = "ui")]
#[test]
fn click_steps_press_egui_buttons_by_label_and_quit_ends_the_run() {
let mut app = game();
app.add_system(ScheduleStage::Update, |world: &mut World| {
let context = world
.resource::<crate::runtime::RuntimeUi>()
.context()
.clone();
egui::CentralPanel::default().show(&context, |ui| {
if ui.button("Settings").clicked() {
world.spawn((
Name("opened".into()),
SceneId(Uuid::new_v4()),
));
}
if ui.button("Quit").clicked() {
world
.resource_mut::<crate::runtime::ExitState>()
.requested = true;
}
});
});
let report = run_scenario(
&mut app,
&scenario(
30,
json!([
{"tick": 1, "click": "Settings"},
{"tick": 3, "expect": {"entity": "opened", "path": "/name", "equals": "opened"}},
{"tick": 3, "expect_quit": false},
{"tick": 4, "click": "Quit"},
{"tick": 5, "expect_quit": true},
{"tick": 6, "expect_file": {"path": "no-such-save.cfg", "exists": false}},
{"tick": 20, "expect_quit": true},
]),
),
Path::new("."),
);
let failure =
report.first_failure.as_ref().expect("tick 20 never runs");
assert_eq!(failure.tick, 20, "{report:#?}");
assert!(failure.message.contains("quit at tick 5"), "{failure:?}");
assert!(report.steps.iter().filter(|step| step.ok).count() >= 5);
assert!(
report.steps.iter().any(|step| step.tick == 6 && step.ok),
"expect_file runs after the quit: {report:#?}"
);
let missing = run(&scenario(3, json!([{"tick": 1, "click": "Load"}])));
let message = &missing.first_failure.unwrap().message;
assert!(message.contains("no UI text `Load`"), "{message}");
}
#[cfg(feature = "ui")]
#[test]
fn hud_text_shows_the_counters_game_code_set_on_the_same_tick() {
let mut app = game();
app.world_mut().spawn((
crate::runtime::Counter {
name: "beat".into(),
..Default::default()
},
crate::runtime::HudElement {
text: "BEAT {beat}".into(),
..Default::default()
},
));
app.add_system(ScheduleStage::Update, |world: &mut World| {
let tick = world.resource::<FrameTime>().fixed_tick as i32;
let mut counters = world.query::<&mut crate::runtime::Counter>();
for mut counter in counters.iter_mut(world) {
counter.value = tick;
}
});
let report = run_scenario(
&mut app,
&scenario(6, json!([{"tick": 6, "click": "BEAT 5"}])),
Path::new("."),
);
assert!(report.passed, "{report:#?}");
}
#[test]
fn a_missing_path_suggests_the_nearest_real_ones() {
let state = json!({"components": {"rusting.fog": {"density": 0.1, "color": [0, 0, 0]}},
"mesh_renderer": {"material": {"Inline": {"transmission": 0.5}}}});
let hint = near_paths(&state, "/scene/fog/density");
assert!(
hint.starts_with("; did you mean /components/rusting.fog/density"),
"{hint}"
);
let hint = near_paths(&state, "/mesh_renderer/material/transmission");
assert!(
hint.contains("/mesh_renderer/material/Inline/transmission"),
"{hint}"
);
}
#[test]
fn hashes_are_kept_past_the_world_history() {
let mut world = World::new();
world.insert_resource(crate::runtime::StateHashes::default());
let mut report = ScenarioReport {
name: String::new(),
seed: 0,
passed: false,
ticks_run: 0,
first_failure: None,
steps: Vec::new(),
captures: Vec::new(),
trace: Vec::new(),
perf: PerfReport::default(),
gpu_state_hashes: Vec::new(),
state_hashes: Vec::new(),
};
for tick in 0..3000_u64 {
let mut hashes =
world.resource_mut::<crate::runtime::StateHashes>();
hashes.recent.push_back((tick, tick * 7));
if hashes.recent.len() > 1024 {
hashes.recent.pop_front();
}
collect_hashes(&world, &mut report);
collect_hashes(&world, &mut report);
}
assert_eq!(report.state_hashes.len(), 3000);
assert_eq!(report.state_hashes[2999], (2999, 2999 * 7));
}
#[test]
fn gpu_bodies_report_the_gpu_pose_under_gpu_state() {
let mut app = game();
let world = app.world_mut();
let ground = find_entity(world, "Ground", |_, _| true).unwrap();
world
.entity_mut(ground)
.insert(crate::runtime::GpuStateMirror {
tick: 4,
transform: Transform::new([1.0, 2.0, 3.0]),
linear_velocity: [0.0; 3],
angular_velocity: [0.0; 3],
custom_values: None,
});
let state = reflected(world, "Ground").unwrap();
assert_eq!(state["gpu_state"]["position"], json!([1.0, 2.0, 3.0]));
assert_eq!(state["gpu_state"]["tick"], 4);
}
#[test]
fn a_failed_check_names_inputs_that_ran_on_its_tick() {
let report = run(&scenario(
5,
json!([
{"tick": 2, "press": "jump"},
{"tick": 2, "expect": {"entity": "Ground",
"path": "/transform/position/1", "equals": -0.5}},
]),
));
let message = report.first_failure.unwrap().message;
assert!(
message.contains(
r#"inputs at this tick ran before the check: {"press":"jump"}"#
),
"{message}"
);
}
#[test]
fn failure_trace_names_the_first_failing_tick() {
let report = run(&scenario(
60,
json!([
{"tick": 0, "until": 60, "expect": {"entity": "Cube",
"path": "/transform/position/1", "greater_than": 2.5}},
]),
));
assert!(!report.passed);
let failure = report.first_failure.unwrap();
assert!(failure.tick > 5 && failure.tick < 60, "{failure:?}");
assert!(failure.actual.as_f64().unwrap() <= 2.5);
assert_eq!(report.steps.len(), 1);
assert_eq!(report.ticks_run, u64::from(failure.tick));
let unbound = run(&scenario(1, json!([{"tick": 1, "press": "fly"}])));
assert!(unbound.first_failure.unwrap().message.contains("fly"));
}
#[test]
fn within_passes_on_the_first_tick_the_check_holds() {
let falls = |within: u32| {
run(&scenario(
60,
json!([{"tick": 0, "within": within, "expect": {"entity": "Cube",
"path": "/transform/position/1", "less_than": 2.5}}]),
))
};
let report = falls(60);
assert!(report.passed, "{report:#?}");
let pass = &report.steps[0];
assert!(pass.tick > 5 && pass.tick < 60, "{pass:?}");
let report = falls(3);
assert!(!report.passed);
assert_eq!(report.first_failure.unwrap().tick, 3);
}
#[test]
fn log_steps_record_a_value_every_tick_and_never_fail() {
let report = run(&scenario(
10,
json!([
{"tick": 1, "until": 4, "log": {"entity": "Cube",
"path": "/transform/position/1"}},
{"tick": 5, "log": {"entity": "Ghost", "path": ""}},
]),
));
assert!(report.passed, "{report:#?}");
let ticks: Vec<_> = report.steps.iter().map(|step| step.tick).collect();
assert_eq!(ticks, [1, 2, 3, 4, 5]);
assert!(report.steps[3].actual.as_f64().unwrap() < 3.0, "falling");
assert_eq!(report.steps[4].actual, Value::Null);
assert!(report.steps[4].message.contains("Ghost"), "{report:#?}");
assert!(!report.steps[4].message.contains(" is null"));
}
#[test]
fn exists_checks_entities_and_paths() {
let check = |entity: &str, path: &str, exists: bool| {
run(&scenario(
1,
json!([{"tick": 1, "expect": {"entity": entity, "path": path, "exists": exists}}]),
))
.passed
};
assert!(check("Cube", "", true));
assert!(check("Cube", "/transform", true));
assert!(check("Ghost", "", false));
assert!(check("Cube", "/components/nope", false));
assert!(!check("Cube", "", false));
assert!(!check("Ghost", "", true));
}
#[test]
fn seed_reaches_game_code_and_repeats() {
let ground_x = |seed: u64| {
let mut scenario = scenario(3, json!([]));
scenario.seed = seed;
let mut app = game();
assert!(run_scenario(&mut app, &scenario, Path::new(".")).passed);
let mut query = app.world_mut().query::<(&Name, &Transform)>();
query
.iter(app.world())
.find(|(name, _)| name.0 == "Ground")
.unwrap()
.1
.position[0]
};
assert_eq!(ground_x(1), ground_x(1));
assert_ne!(ground_x(1), ground_x(2));
}
}