use concinnity_core::animation::skeleton;
use concinnity_core::components::ProceduralMesh;
use concinnity_core::components::SkeletonJoint;
use concinnity_core::components::Story;
use concinnity_core::components::StoryReload;
use concinnity_core::ecs::EventCursor;
use concinnity_core::ecs::World;
use concinnity_core::gfx::mesh_payload;
use concinnity_core::gfx::render_types;
use concinnity_core::gfx::render_types::{DrawIndex, SkinnedIndex};
use concinnity_core::input::snapshot::InputSnapshot;
use concinnity_core::render::backend;
use concinnity_core::render::draw_slot;
use concinnity_core::render::error;
use concinnity_core::render::scene_flow;
use concinnity_core::render::scene_state;
use concinnity_core::render::volumetric_fog;
use concinnity_engine::live_edit;
use concinnity_engine::live_edit::hot_reload_sources::*;
use notify::{Event, EventKind};
use std::path::PathBuf;
use super::decode::*;
use super::passes::*;
use super::state::*;
use super::watcher::*;
#[test]
fn empty_map_round_trips() {
let m = TextureSourceMap::new();
assert!(m.is_empty());
assert_eq!(m.len(), 0);
assert!(m.watch_dirs().is_empty());
}
#[test]
fn pushes_and_collects_unique_parent_dirs() {
let mut m = TextureSourceMap::new();
m.push_texture("assets/a.png".to_string(), 0, 0);
m.push_texture("assets/b.png".to_string(), 0, 1);
m.push_texture("textures/nm.png".to_string(), 0, 1);
assert_eq!(m.entries.len(), 3);
let dirs = m.watch_dirs();
assert_eq!(dirs.len(), 2);
assert!(dirs.iter().any(|p| p.ends_with("assets")));
assert!(dirs.iter().any(|p| p.ends_with("textures")));
}
#[test]
fn bare_filenames_skip_watch_dir() {
let mut m = TextureSourceMap::new();
m.push_texture("standalone.png".to_string(), 0, 0);
assert!(m.watch_dirs().is_empty());
}
#[test]
fn glb_extension_is_an_asset_event() {
let evt = Event::new(EventKind::Modify(notify::event::ModifyKind::Any))
.add_path(PathBuf::from("/tmp/model.glb"));
assert!(is_asset_event(&evt));
}
#[test]
fn cube_extension_is_an_asset_event() {
let evt = Event::new(EventKind::Modify(notify::event::ModifyKind::Any))
.add_path(PathBuf::from("/tmp/grade.cube"));
assert!(is_asset_event(&evt));
}
#[test]
fn hdr_extension_is_an_asset_event() {
let evt = Event::new(EventKind::Modify(notify::event::ModifyKind::Any))
.add_path(PathBuf::from("/tmp/studio.hdr"));
assert!(is_asset_event(&evt));
}
#[test]
fn hdr_extension_matches_case_insensitively() {
let evt = Event::new(EventKind::Modify(notify::event::ModifyKind::Any))
.add_path(PathBuf::from("/tmp/STUDIO.HDR"));
assert!(is_asset_event(&evt));
}
#[test]
fn unrelated_extension_is_not_an_asset_event() {
let evt = Event::new(EventKind::Modify(notify::event::ModifyKind::Any))
.add_path(PathBuf::from("/tmp/world.txt"));
assert!(!is_asset_event(&evt));
}
#[test]
fn state_with_only_environment_map_still_spawns_a_watcher() {
let env_map = EnvironmentMapSource {
resolved_path: concinnity_host::scratch::path("asset_hot_reload_envmap_only.hdr")
.to_string_lossy()
.into_owned(),
prefilter_face_size: 64,
irradiance_face_size: 16,
prefilter_samples: 64,
prefilter_clamp: 12.0,
};
let state = AssetHotReloadState::from_sources(
HotReloadSources {
environment_map: Some(env_map.clone()),
..Default::default()
},
None,
Default::default(),
);
assert!(state.environment_map.is_some());
let captured = state.environment_map.as_ref().unwrap();
assert_eq!(captured.prefilter_face_size, env_map.prefilter_face_size);
assert_eq!(captured.irradiance_face_size, env_map.irradiance_face_size);
assert_eq!(captured.prefilter_samples, env_map.prefilter_samples);
}
#[test]
fn fully_empty_state_skips_watcher_creation() {
let state =
AssetHotReloadState::from_sources(HotReloadSources::default(), None, Default::default());
assert!(state.environment_map.is_none());
assert!(state.color_lut.is_none());
assert!(state.map.is_empty());
assert!(state.meshes.is_empty());
assert!(state.skinned_meshes.is_empty());
}
#[test]
fn mesh_source_map_collects_unique_parent_dirs() {
let mut m = MeshSourceMap::new();
m.entries.push(MeshSourceEntry {
source: "assets/models/a.glb".to_string(),
primitive_index: 0,
lod_levels: 1,
lod_distances: Vec::new(),
draw_indices: vec![DrawIndex(0), DrawIndex(1)],
});
m.entries.push(MeshSourceEntry {
source: "assets/models/a.glb".to_string(),
primitive_index: 1,
lod_levels: 1,
lod_distances: Vec::new(),
draw_indices: vec![DrawIndex(2)],
});
m.entries.push(MeshSourceEntry {
source: "assets/hdri/b.glb".to_string(),
primitive_index: 0,
lod_levels: 1,
lod_distances: Vec::new(),
draw_indices: vec![DrawIndex(3)],
});
let dirs = m.watch_dirs();
assert_eq!(dirs.len(), 2);
assert!(dirs.iter().any(|p| p.ends_with("assets/models")));
assert!(dirs.iter().any(|p| p.ends_with("assets/hdri")));
}
#[test]
fn mesh_source_map_skips_bare_filenames_in_watch_dirs() {
let mut m = MeshSourceMap::new();
m.entries.push(MeshSourceEntry {
source: "standalone.glb".to_string(),
primitive_index: 0,
lod_levels: 1,
lod_distances: Vec::new(),
draw_indices: vec![DrawIndex(0)],
});
assert!(m.watch_dirs().is_empty());
}
#[test]
fn state_with_only_meshes_still_spawns_a_watcher() {
let mut meshes = MeshSourceMap::new();
meshes.entries.push(MeshSourceEntry {
source: concinnity_host::scratch::path("dummy.glb")
.to_string_lossy()
.into_owned(),
primitive_index: 0,
lod_levels: 1,
lod_distances: Vec::new(),
draw_indices: vec![DrawIndex(0)],
});
let state = AssetHotReloadState::from_sources(
HotReloadSources {
meshes,
..Default::default()
},
None,
Default::default(),
);
assert_eq!(state.meshes.len(), 1);
assert_eq!(state.meshes.entries[0].draw_indices, vec![DrawIndex(0)]);
}
#[test]
fn skinned_mesh_source_map_collects_unique_parent_dirs() {
let mut m = SkinnedMeshSourceMap::new();
m.entries.push(SkinnedMeshSourceEntry {
source: "assets/models/a.glb".to_string(),
skin_index: 0,
skinned_index: SkinnedIndex(0),
vertex_base: 0,
vertex_count: 100,
index_count: 300,
joint_count: 24,
});
m.entries.push(SkinnedMeshSourceEntry {
source: "assets/models/b.glb".to_string(),
skin_index: 0,
skinned_index: SkinnedIndex(1),
vertex_base: 100,
vertex_count: 50,
index_count: 150,
joint_count: 5,
});
let dirs = m.watch_dirs();
assert_eq!(dirs.len(), 1);
assert!(dirs[0].ends_with("assets/models"));
}
#[test]
fn state_with_only_skinned_still_spawns_a_watcher() {
let mut skinned = SkinnedMeshSourceMap::new();
skinned.entries.push(SkinnedMeshSourceEntry {
source: concinnity_host::scratch::path("skinned.glb")
.to_string_lossy()
.into_owned(),
skin_index: 0,
skinned_index: SkinnedIndex(0),
vertex_base: 0,
vertex_count: 8,
index_count: 24,
joint_count: 2,
});
let state = AssetHotReloadState::from_sources(
HotReloadSources {
skinned_meshes: skinned,
..Default::default()
},
None,
Default::default(),
);
assert_eq!(state.skinned_meshes.len(), 1);
assert_eq!(state.skinned_meshes.entries[0].joint_count, 2);
}
#[test]
fn state_with_only_world_jsonl_still_spawns_a_watcher() {
let world_path = concinnity_host::scratch::path("world_only.jsonl")
.to_string_lossy()
.into_owned();
let state = AssetHotReloadState::from_sources(
HotReloadSources::default(),
Some(world_path.clone()),
Default::default(),
);
assert_eq!(state.world_jsonl_path.as_deref(), Some(world_path.as_str()));
}
#[test]
fn jsonl_extension_is_an_asset_event() {
let evt = Event::new(EventKind::Modify(notify::event::ModifyKind::Any))
.add_path(PathBuf::from("/tmp/world.jsonl"));
assert!(is_asset_event(&evt));
}
#[test]
fn md_extension_is_an_asset_event() {
let evt = Event::new(EventKind::Modify(notify::event::ModifyKind::Any))
.add_path(PathBuf::from("/tmp/story.md"));
assert!(is_asset_event(&evt));
}
#[test]
fn reload_stories_re_expands_and_dedupes_by_snapshot() {
let tree = concinnity_testing::TempTree::new();
let dir = tree.path();
let md = tree.write(
"tale.md",
"---\ntitle: Tale\ncharacters:\n a: Ana\n---\n\n# start\n\nHello there.\n",
);
let world = dir.join("world.jsonl");
std::fs::write(
&world,
format!(
"{}\n",
serde_json::json!(["StoryImport", {"$id": "tale", "source": md.to_str().unwrap()}])
),
)
.unwrap();
let mut snapshots = std::collections::HashMap::new();
let stories = reload_stories(world.to_str().unwrap(), &mut snapshots);
assert_eq!(stories.len(), 1);
assert_eq!(stories[0].title, "Tale");
assert_eq!(stories[0].nodes[0].pages[0].text, "Hello there.");
assert!(stories[0].scaffold.screen.is_some());
let unchanged = reload_stories(world.to_str().unwrap(), &mut snapshots);
assert!(unchanged.is_empty());
std::fs::write(
&md,
"---\ntitle: Tale\ncharacters:\n a: Ana\n---\n\n# start\n\nHello again.\n",
)
.unwrap();
let edited = reload_stories(world.to_str().unwrap(), &mut snapshots);
assert_eq!(edited.len(), 1);
assert_eq!(edited[0].nodes[0].pages[0].text, "Hello again.");
std::fs::write(&md, "---\ntitle: Broken\n").unwrap();
let broken = reload_stories(world.to_str().unwrap(), &mut snapshots);
assert!(broken.is_empty());
}
#[test]
fn fresh_state_has_no_envmap_in_flight() {
let state =
AssetHotReloadState::from_sources(HotReloadSources::default(), None, Default::default());
let slot = state.env_map_inflight.lock().expect("lock");
assert!(slot.is_none());
}
#[test]
fn fresh_state_has_no_asset_batch_in_flight() {
let state =
AssetHotReloadState::from_sources(HotReloadSources::default(), None, Default::default());
let slot = state.asset_batch_inflight.lock().expect("lock");
assert!(slot.is_none());
}
#[test]
fn decode_asset_batch_with_empty_inputs_returns_empty_batch() {
let batch = decode_asset_batch(Vec::new(), None, Vec::new(), Vec::new());
assert!(batch.textures.is_empty());
assert!(batch.color_lut.is_none());
assert!(batch.meshes.is_empty());
assert!(batch.skinned_meshes.is_empty());
assert_eq!(batch.decode_failures, 0);
}
#[test]
fn apply_skinned_layouts_refreshes_every_matching_entry() {
let mut entries = vec![
SkinnedMeshSourceEntry {
source: "a.glb".to_string(),
skin_index: 0,
skinned_index: SkinnedIndex(0),
vertex_base: 0,
vertex_count: 10,
index_count: 30,
joint_count: 4,
},
SkinnedMeshSourceEntry {
source: "b.glb".to_string(),
skin_index: 0,
skinned_index: SkinnedIndex(1),
vertex_base: 10,
vertex_count: 20,
index_count: 60,
joint_count: 6,
},
];
let layouts = vec![
backend::SkinnedSlotLayout {
skinned_index: SkinnedIndex(0),
vertex_base: 0,
vertex_count: 15,
index_count: 45,
},
backend::SkinnedSlotLayout {
skinned_index: SkinnedIndex(1),
vertex_base: 15,
vertex_count: 20,
index_count: 60,
},
];
apply_skinned_layouts_to_entries(&mut entries, &layouts);
assert_eq!(entries[0].vertex_base, 0);
assert_eq!(entries[0].vertex_count, 15);
assert_eq!(entries[0].index_count, 45);
assert_eq!(entries[1].vertex_base, 15);
assert_eq!(entries[1].vertex_count, 20);
assert_eq!(entries[1].index_count, 60);
assert_eq!(entries[0].joint_count, 4);
assert_eq!(entries[1].joint_count, 6);
}
#[test]
fn drain_pending_skeleton_updates_clears_the_queue() {
let mut state =
AssetHotReloadState::from_sources(HotReloadSources::default(), None, Default::default());
state.pending_skeleton_updates.push(PendingSkeletonUpdate {
skinned_index: SkinnedIndex(0),
new_skeleton: skeleton::Skeleton::new(Vec::new()),
});
state.pending_skeleton_updates.push(PendingSkeletonUpdate {
skinned_index: SkinnedIndex(3),
new_skeleton: skeleton::Skeleton::new(Vec::new()),
});
let drained = state.drain_pending_skeleton_updates();
assert_eq!(drained.len(), 2);
assert_eq!(drained[0].skinned_index, SkinnedIndex(0));
assert_eq!(drained[1].skinned_index, SkinnedIndex(3));
assert!(state.drain_pending_skeleton_updates().is_empty());
}
#[test]
fn procedural_mesh_source_map_round_trips_empty() {
let m = ProceduralMeshSourceMap::new();
assert!(m.is_empty());
assert_eq!(m.len(), 0);
}
#[test]
fn procedural_mesh_args_normalize_via_round_trip() {
let user_args = serde_json::json!({
"generator": "box",
"half_extents": [0.5, 0.5, 0.5],
});
let init_component: ProceduralMesh = serde_json::from_value(user_args.clone()).unwrap();
let init_norm = serde_json::to_value(&init_component).unwrap();
let reload_component: ProceduralMesh = serde_json::from_value(user_args.clone()).unwrap();
let reload_norm = serde_json::to_value(&reload_component).unwrap();
assert_eq!(init_norm, reload_norm);
}
#[test]
fn procedural_mesh_args_diff_detects_real_changes() {
let v1: ProceduralMesh = serde_json::from_value(serde_json::json!({
"generator": "box",
"half_extents": [0.5, 0.5, 0.5],
}))
.unwrap();
let v2: ProceduralMesh = serde_json::from_value(serde_json::json!({
"generator": "box",
"half_extents": [1.0, 1.0, 1.0],
}))
.unwrap();
let n1 = serde_json::to_value(&v1).unwrap();
let n2 = serde_json::to_value(&v2).unwrap();
assert_ne!(n1, n2);
}
#[test]
fn procedural_mesh_reload_result_default_is_all_zero() {
let r = ProceduralMeshReloadResult::default();
assert_eq!(r.regenerated, 0);
assert_eq!(r.unchanged, 0);
assert_eq!(r.failed, 0);
}
#[test]
fn state_with_only_procedural_meshes_still_spawns_a_watcher() {
let world_path = concinnity_host::scratch::path("asset_hot_reload_proc_only_world.jsonl")
.to_string_lossy()
.into_owned();
let mut proc = ProceduralMeshSourceMap::new();
proc.entries.push(ProceduralMeshSourceEntry {
name: "box_mesh".to_string(),
args: serde_json::from_value(serde_json::json!({"generator": "box"})).unwrap(),
draw_indices: vec![DrawIndex(0)],
});
let state = AssetHotReloadState::from_sources(
HotReloadSources {
procedural_meshes: proc,
..Default::default()
},
Some(world_path),
Default::default(),
);
assert_eq!(state.procedural_meshes.len(), 1);
assert_eq!(state.procedural_meshes.entries[0].name, "box_mesh");
}
#[test]
fn a_shader_source_is_an_asset_event() {
for path in ["/tmp/scene.hlsl", "/tmp/SCENE.HLSL"] {
let evt = Event::new(EventKind::Modify(notify::event::ModifyKind::Any))
.add_path(PathBuf::from(path));
assert!(is_asset_event(&evt), "{path}");
}
}
#[test]
fn shader_extension_matches_case_insensitively() {
assert!(is_shader_extension("hlsl"));
assert!(is_shader_extension("HLSL"));
assert!(is_shader_extension("Hlsl"));
assert!(!is_shader_extension("metal"));
assert!(!is_shader_extension("png"));
assert!(!is_shader_extension("glb"));
}
fn modified(path: &str) -> Event {
Event::new(EventKind::Modify(notify::event::ModifyKind::Any)).add_path(PathBuf::from(path))
}
#[test]
fn each_extension_routes_to_its_reload_pass() {
for (path, expected) in [
("/tmp/lit.hlsl", ReloadKind::Shaders),
("/tmp/LIT.HLSL", ReloadKind::Shaders),
("/tmp/world.jsonl", ReloadKind::World),
("/tmp/WORLD.JSONL", ReloadKind::World),
("/tmp/intro.md", ReloadKind::Stories),
("/tmp/INTRO.MD", ReloadKind::Stories),
("/tmp/model.glb", ReloadKind::Assets),
("/tmp/albedo.png", ReloadKind::Assets),
("/tmp/studio.hdr", ReloadKind::Assets),
("/tmp/grade.cube", ReloadKind::Assets),
("/tmp/buffer.bin", ReloadKind::Assets),
] {
assert_eq!(
classify_event(&modified(path)),
Some(expected),
"{path} routes to {expected:?}"
);
}
}
#[test]
fn an_irrelevant_change_routes_nowhere() {
assert_eq!(classify_event(&modified("/tmp/notes.txt")), None);
let accessed = Event::new(EventKind::Access(notify::event::AccessKind::Read))
.add_path(PathBuf::from("/tmp/model.glb"));
assert_eq!(classify_event(&accessed), None);
}
#[test]
fn a_shader_among_several_paths_still_routes_to_the_shader_pass() {
let leading = Event::new(EventKind::Modify(notify::event::ModifyKind::Any))
.add_path(PathBuf::from("/tmp/lit.hlsl"))
.add_path(PathBuf::from("/tmp/albedo.png"));
let trailing = Event::new(EventKind::Modify(notify::event::ModifyKind::Any))
.add_path(PathBuf::from("/tmp/albedo.png"))
.add_path(PathBuf::from("/tmp/lit.hlsl"));
assert_eq!(classify_event(&leading), Some(ReloadKind::Shaders));
assert_eq!(classify_event(&trailing), Some(ReloadKind::Shaders));
}
#[test]
fn creates_and_removes_route_like_modifies() {
for kind in [
EventKind::Create(notify::event::CreateKind::File),
EventKind::Remove(notify::event::RemoveKind::File),
] {
let evt = Event::new(kind).add_path(PathBuf::from("/tmp/model.glb"));
assert_eq!(classify_event(&evt), Some(ReloadKind::Assets), "{kind:?}");
}
}
#[test]
fn state_with_only_shaders_still_spawns_a_watcher() {
use concinnity_engine::live_edit::shader_sources::{
ShaderFile, ShaderSourceEntry, ShaderSourceMap,
};
let path = concinnity_host::scratch::path("asset_hot_reload_shader_only.hlsl")
.to_string_lossy()
.into_owned();
let shaders = ShaderSourceMap {
entries: vec![ShaderSourceEntry {
id: concinnity_core::ecs::asset_id::AssetId(1),
name: "lit".to_string(),
bucket: 0,
files: vec![ShaderFile {
stage: concinnity_core::components::ShaderStage::Fragment,
resolved_path: path,
}],
}],
};
let state = AssetHotReloadState::from_sources(
HotReloadSources {
shaders,
..Default::default()
},
None,
Default::default(),
);
assert_eq!(state.shaders.catalog.len(), 1);
assert_eq!(state.shaders.catalog.entries[0].name, "lit");
}
#[test]
fn apply_skinned_layouts_leaves_entries_without_a_matching_layout_alone() {
let mut entries = vec![SkinnedMeshSourceEntry {
source: "a.glb".to_string(),
skin_index: 0,
skinned_index: SkinnedIndex(7),
vertex_base: 42,
vertex_count: 12,
index_count: 36,
joint_count: 2,
}];
let layouts = vec![backend::SkinnedSlotLayout {
skinned_index: SkinnedIndex(0),
vertex_base: 0,
vertex_count: 99,
index_count: 99,
}];
apply_skinned_layouts_to_entries(&mut entries, &layouts);
assert_eq!(entries[0].vertex_base, 42);
assert_eq!(entries[0].vertex_count, 12);
assert_eq!(entries[0].index_count, 36);
}
#[derive(Default)]
struct RecordingBackend {
texture_updates: Vec<(usize, u32, u32)>,
lut_updates: Vec<u32>,
mesh_updates: Vec<DrawIndex>,
static_rebuild_change_counts: Vec<usize>,
skinned_updates: Vec<SkinnedIndex>,
skinned_rebuild_change_counts: Vec<usize>,
skeleton_updates: Vec<(SkinnedIndex, usize)>,
env_updates: usize,
fog_updates: usize,
geometry_sizes: std::collections::HashMap<DrawIndex, (usize, usize)>,
lod_counts: std::collections::HashMap<DrawIndex, Vec<usize>>,
skinned_layouts: Vec<(SkinnedIndex, u32, usize, usize)>,
fail_texture_updates: bool,
fail_lut_updates: bool,
fail_mesh_updates: bool,
fail_static_rebuild: bool,
fail_skinned_updates: bool,
fail_skinned_rebuild: bool,
fail_skeleton_update: bool,
fail_env_updates: bool,
}
impl scene_flow::SceneControl for RecordingBackend {
fn update_visibility(&mut self, _: DrawIndex, _: bool) {}
fn set_fade(&mut self, _: f32) {}
}
impl backend::SceneHost for RecordingBackend {
fn scene(&self) -> Option<&scene_state::SceneState> {
None
}
fn scene_mut(&mut self) -> Option<&mut scene_state::SceneState> {
None
}
fn edit_geometry(&mut self, _: backend::GeometryEdit<'_>) -> Option<error::RenderResult<()>> {
None
}
}
impl backend::RenderBackend for RecordingBackend {
fn window_closed(&mut self) -> bool {
false
}
fn request_cursor_capture(&mut self) {}
fn take_input(&mut self) -> InputSnapshot {
InputSnapshot::default()
}
fn wait_idle(&self) {}
fn draw_frame(&mut self, _: backend::FrameParams<'_>) -> error::RenderResult<()> {
Ok(())
}
fn update_view(&mut self, _: [[f32; 4]; 4]) {}
fn update_models(&mut self, _: &[(DrawIndex, [[f32; 4]; 4])]) {}
fn retire_draw_object(&mut self, _: DrawIndex) {}
}
impl backend::SkinnedDraws for RecordingBackend {
fn upload_skinned(
&mut self,
_: &[mesh_payload::SkinnedVertex],
_: &[u32],
_: Vec<render_types::SkinnedDrawObject>,
) -> error::RenderResult<()> {
Ok(())
}
fn update_skinned_pose(&mut self, _: SkinnedIndex, _: &[[[f32; 4]; 4]]) {}
}
impl backend::DrawStreaming for RecordingBackend {
fn evict_texture_slot(&mut self, _: usize) -> error::RenderResult<()> {
Ok(())
}
fn update_texture_slot(
&mut self,
slot: usize,
image: &concinnity_core::bake::texture::TextureImage,
) -> error::RenderResult<()> {
self.texture_updates
.push((slot, image.width(), image.height()));
if self.fail_texture_updates {
return Err(error::RenderError::Other("texture update rejected".into()));
}
Ok(())
}
fn evict_mesh(&mut self, _: DrawIndex, _: u64) -> error::RenderResult<()> {
Ok(())
}
fn upload_mesh(
&mut self,
_: DrawIndex,
_: &[mesh_payload::Vertex],
_: &[u16],
_: u64,
) -> error::RenderResult<()> {
Ok(())
}
fn setup_chunk_streaming(&mut self, _: usize, _: usize) -> error::RenderResult<()> {
Ok(())
}
fn add_chunk_mesh(
&mut self,
_: backend::ChunkMesh<'_>,
_: draw_slot::SlotAlloc,
) -> error::RenderResult<()> {
Ok(())
}
fn remove_chunk_mesh(&mut self, _: DrawIndex, _: u64) -> error::RenderResult<()> {
Ok(())
}
fn set_chunk_model(&mut self, _: DrawIndex, _: [[f32; 4]; 4]) -> error::RenderResult<()> {
Ok(())
}
}
impl backend::WindowControl for RecordingBackend {}
impl backend::RenderTuning for RecordingBackend {
fn update_fog_settings(&mut self, _: Option<volumetric_fog::FogSettings>) {
self.fog_updates += 1;
}
}
impl backend::LiveEdit for RecordingBackend {
fn update_color_lut(&mut self, size: u32, _: &[u8]) -> error::RenderResult<()> {
self.lut_updates.push(size);
if self.fail_lut_updates {
return Err(error::RenderError::Other("lut update rejected".into()));
}
Ok(())
}
fn draw_geometry_size(&self, draw_idx: DrawIndex) -> Option<(usize, usize)> {
self.geometry_sizes.get(&draw_idx).copied()
}
fn draw_lod_index_counts(&self, draw_idx: DrawIndex) -> Option<Vec<usize>> {
self.lod_counts.get(&draw_idx).cloned()
}
fn update_mesh_geometry(
&mut self,
draw_idx: DrawIndex,
_: &[mesh_payload::Vertex],
_: &[u16],
_: &[(f32, Vec<u16>)],
) -> error::RenderResult<()> {
self.mesh_updates.push(draw_idx);
if self.fail_mesh_updates {
return Err(error::RenderError::Other("mesh update rejected".into()));
}
Ok(())
}
fn rebuild_static_geometry(
&mut self,
changes: Vec<backend::DrawGeometryUpdate>,
) -> error::RenderResult<()> {
self.static_rebuild_change_counts.push(changes.len());
if self.fail_static_rebuild {
return Err(error::RenderError::Other("static rebuild rejected".into()));
}
Ok(())
}
fn update_skinned_mesh_geometry(
&mut self,
skinned_index: SkinnedIndex,
_: u32,
_: &[mesh_payload::SkinnedVertex],
_: &[u16],
) -> error::RenderResult<()> {
self.skinned_updates.push(skinned_index);
if self.fail_skinned_updates {
return Err(error::RenderError::Other("skinned update rejected".into()));
}
Ok(())
}
fn rebuild_skinned_geometry(
&mut self,
changes: Vec<backend::SkinnedDrawGeometryUpdate>,
) -> error::RenderResult<Vec<backend::SkinnedSlotLayout>> {
self.skinned_rebuild_change_counts.push(changes.len());
if self.fail_skinned_rebuild {
return Err(error::RenderError::Other("skinned rebuild rejected".into()));
}
Ok(self
.skinned_layouts
.iter()
.map(|&(skinned_index, vertex_base, vertex_count, index_count)| {
backend::SkinnedSlotLayout {
skinned_index,
vertex_base,
vertex_count,
index_count,
}
})
.collect())
}
fn update_skinned_skeleton(
&mut self,
skinned_index: SkinnedIndex,
new_joint_count: usize,
) -> error::RenderResult<()> {
self.skeleton_updates.push((skinned_index, new_joint_count));
if self.fail_skeleton_update {
return Err(error::RenderError::Other("skeleton update rejected".into()));
}
Ok(())
}
fn update_environment_map(&mut self, _: &[u8]) -> error::RenderResult<()> {
self.env_updates += 1;
if self.fail_env_updates {
return Err(error::RenderError::Other(
"environment map update rejected".into(),
));
}
Ok(())
}
}
impl backend::SceneEffects for RecordingBackend {}
impl backend::BackendProbe for RecordingBackend {
fn capabilities(&self) -> backend::DeviceCapabilities {
backend::DeviceCapabilities::ALL
}
}
fn write_tiny_png(path: &std::path::Path) {
std::fs::write(path, concinnity_testing::fixtures::png::one_pixel()).unwrap();
}
fn write_tiny_cube(path: &std::path::Path) {
let text = "LUT_3D_SIZE 2\n\
0 0 0\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n";
std::fs::write(path, text).unwrap();
}
fn zero_vertex() -> mesh_payload::Vertex {
mesh_payload::Vertex {
pos: [0.0; 3],
normal: [0.0; 3],
tangent: [0.0; 3],
color: [0.0; 3],
uv: [0.0; 2],
}
}
fn zero_skinned_vertex() -> mesh_payload::SkinnedVertex {
mesh_payload::SkinnedVertex {
pos: [0.0; 3],
normal: [0.0; 3],
tangent: [0.0; 3],
color: [0.0; 3],
uv: [0.0; 2],
joints: [0; 4],
weights: [0.0; 4],
}
}
fn joint_def(name: &str) -> SkeletonJoint {
SkeletonJoint {
name: name.to_string(),
parent: -1,
translation: [0.0; 3],
rotation_deg: [0.0; 3],
scale: [1.0; 3],
}
}
fn inject_batch(state: &AssetHotReloadState, batch: DecodedAssetBatch) {
let (tx, rx) = std::sync::mpsc::channel();
tx.send(batch).unwrap();
*state.asset_batch_inflight.lock().unwrap() = Some(rx);
}
#[test]
fn decode_asset_batch_decodes_a_png_texture() {
let dir = tempfile::tempdir().unwrap();
let png = dir.path().join("albedo.png");
write_tiny_png(&png);
let entry = TextureSourceEntry {
source: png.to_string_lossy().into_owned(),
image_index: 0,
slot: 3,
};
let batch = decode_asset_batch(vec![entry], None, Vec::new(), Vec::new());
assert_eq!(batch.decode_failures, 0);
assert_eq!(batch.textures.len(), 1);
let tex = &batch.textures[0];
assert_eq!(tex.slot, 3);
assert_eq!((tex.width, tex.height), (1, 1));
assert_eq!(tex.pixels, vec![10, 20, 30, 255]);
}
#[test]
fn decode_asset_batch_counts_a_missing_texture_as_a_failure() {
let dir = tempfile::tempdir().unwrap();
let entry = TextureSourceEntry {
source: dir
.path()
.join("never_written.png")
.to_string_lossy()
.into_owned(),
image_index: 0,
slot: 0,
};
let batch = decode_asset_batch(vec![entry], None, Vec::new(), Vec::new());
assert!(batch.textures.is_empty());
assert_eq!(batch.decode_failures, 1);
}
#[test]
fn decode_asset_batch_mixes_successes_and_failures() {
let dir = tempfile::tempdir().unwrap();
let png = dir.path().join("ok.png");
write_tiny_png(&png);
let good = TextureSourceEntry {
source: png.to_string_lossy().into_owned(),
image_index: 0,
slot: 1,
};
let bad = TextureSourceEntry {
source: dir.path().join("gone.png").to_string_lossy().into_owned(),
image_index: 0,
slot: 2,
};
let batch = decode_asset_batch(vec![good, bad], None, Vec::new(), Vec::new());
assert_eq!(batch.textures.len(), 1);
assert_eq!(batch.decode_failures, 1);
}
#[test]
fn decode_asset_batch_counts_an_unparseable_glb_texture_as_a_failure() {
let dir = tempfile::tempdir().unwrap();
let glb = dir.path().join("broken.glb");
std::fs::write(&glb, b"not a glb at all").unwrap();
let entry = TextureSourceEntry {
source: glb.to_string_lossy().into_owned(),
image_index: 0,
slot: 0,
};
let batch = decode_asset_batch(vec![entry], None, Vec::new(), Vec::new());
assert!(batch.textures.is_empty());
assert_eq!(batch.decode_failures, 1);
}
#[test]
fn decode_asset_batch_decodes_a_cube_lut() {
let dir = tempfile::tempdir().unwrap();
let cube = dir.path().join("grade.cube");
write_tiny_cube(&cube);
let lut = ColorLutSource {
resolved_path: cube.to_string_lossy().into_owned(),
};
let batch = decode_asset_batch(Vec::new(), Some(lut), Vec::new(), Vec::new());
assert_eq!(batch.decode_failures, 0);
let lut = batch.color_lut.expect("decoded lut");
assert_eq!(lut.size, 2);
assert_eq!(lut.data.len(), 2 * 2 * 2 * 4);
}
#[test]
fn decode_asset_batch_counts_a_missing_lut_as_a_failure() {
let dir = tempfile::tempdir().unwrap();
let lut = ColorLutSource {
resolved_path: dir.path().join("gone.cube").to_string_lossy().into_owned(),
};
let batch = decode_asset_batch(Vec::new(), Some(lut), Vec::new(), Vec::new());
assert!(batch.color_lut.is_none());
assert_eq!(batch.decode_failures, 1);
}
#[test]
fn decode_asset_batch_counts_a_missing_mesh_source_as_a_failure() {
let dir = tempfile::tempdir().unwrap();
let entry = MeshSourceEntry {
source: dir.path().join("gone.glb").to_string_lossy().into_owned(),
primitive_index: 0,
lod_levels: 1,
lod_distances: Vec::new(),
draw_indices: vec![DrawIndex(0)],
};
let batch = decode_asset_batch(Vec::new(), None, vec![entry], Vec::new());
assert!(batch.meshes.is_empty());
assert_eq!(batch.decode_failures, 1);
}
#[test]
fn decode_asset_batch_counts_a_missing_skinned_source_as_a_failure() {
let dir = tempfile::tempdir().unwrap();
let entry = SkinnedMeshSourceEntry {
source: dir.path().join("gone.glb").to_string_lossy().into_owned(),
skin_index: 0,
skinned_index: SkinnedIndex(0),
vertex_base: 0,
vertex_count: 8,
index_count: 24,
joint_count: 2,
};
let batch = decode_asset_batch(Vec::new(), None, Vec::new(), vec![entry]);
assert!(batch.skinned_meshes.is_empty());
assert_eq!(batch.decode_failures, 1);
}
#[test]
fn reload_assets_with_no_sources_spawns_nothing() {
let state =
AssetHotReloadState::from_sources(HotReloadSources::default(), None, Default::default());
reload_assets(&state);
assert!(state.asset_batch_inflight.lock().unwrap().is_none());
assert!(state.env_map_inflight.lock().unwrap().is_none());
}
#[test]
fn reload_assets_skips_the_spawn_while_a_batch_is_in_flight() {
let mut map = TextureSourceMap::new();
map.push_texture("standalone.png".to_string(), 0, 0);
let state = AssetHotReloadState::from_sources(
HotReloadSources {
map,
..Default::default()
},
None,
Default::default(),
);
let (_tx, rx) = std::sync::mpsc::channel::<DecodedAssetBatch>();
*state.asset_batch_inflight.lock().unwrap() = Some(rx);
reload_assets(&state);
let slot = state.asset_batch_inflight.lock().unwrap();
assert!(matches!(
slot.as_ref().unwrap().try_recv(),
Err(std::sync::mpsc::TryRecvError::Empty)
));
}
#[test]
fn reload_assets_spawns_a_decode_worker_for_a_lut_source() {
let dir = tempfile::tempdir().unwrap();
let cube = dir.path().join("grade.cube");
write_tiny_cube(&cube);
let state = AssetHotReloadState::from_sources(
HotReloadSources {
color_lut: Some(ColorLutSource {
resolved_path: cube.to_string_lossy().into_owned(),
}),
..Default::default()
},
None,
Default::default(),
);
reload_assets(&state);
let rx = state
.asset_batch_inflight
.lock()
.unwrap()
.take()
.expect("worker scheduled");
let batch = rx.recv().expect("worker sent a batch");
assert_eq!(batch.color_lut.expect("decoded lut").size, 2);
assert!(state.env_map_inflight.lock().unwrap().is_none());
}
#[test]
fn poll_pending_assets_with_nothing_in_flight_returns_false() {
let mut state =
AssetHotReloadState::from_sources(HotReloadSources::default(), None, Default::default());
let mut backend = RecordingBackend::default();
assert!(!poll_pending_assets(&mut state, &mut backend));
}
#[test]
fn poll_pending_assets_keeps_waiting_while_the_worker_runs() {
let mut state =
AssetHotReloadState::from_sources(HotReloadSources::default(), None, Default::default());
let (_tx, rx) = std::sync::mpsc::channel::<DecodedAssetBatch>();
*state.asset_batch_inflight.lock().unwrap() = Some(rx);
let mut backend = RecordingBackend::default();
assert!(!poll_pending_assets(&mut state, &mut backend));
assert!(state.asset_batch_inflight.lock().unwrap().is_some());
}
#[test]
fn poll_pending_assets_clears_the_slot_when_the_worker_disconnects() {
let mut state =
AssetHotReloadState::from_sources(HotReloadSources::default(), None, Default::default());
let (tx, rx) = std::sync::mpsc::channel::<DecodedAssetBatch>();
drop(tx);
*state.asset_batch_inflight.lock().unwrap() = Some(rx);
let mut backend = RecordingBackend::default();
assert!(poll_pending_assets(&mut state, &mut backend));
assert!(state.asset_batch_inflight.lock().unwrap().is_none());
}
#[test]
fn poll_pending_assets_reloads_every_texture_through_the_shared_pool() {
let mut state =
AssetHotReloadState::from_sources(HotReloadSources::default(), None, Default::default());
let batch = DecodedAssetBatch {
textures: vec![
DecodedTexture {
slot: 2,
width: 4,
height: 4,
pixels: vec![0; 64],
source: "a.png".to_string(),
},
DecodedTexture {
slot: 5,
width: 8,
height: 8,
pixels: vec![0; 256],
source: "n.png".to_string(),
},
],
..Default::default()
};
inject_batch(&state, batch);
let mut backend = RecordingBackend::default();
assert!(poll_pending_assets(&mut state, &mut backend));
assert_eq!(backend.texture_updates, vec![(2, 4, 4), (5, 8, 8)]);
assert!(state.asset_batch_inflight.lock().unwrap().is_none());
}
#[test]
fn poll_pending_assets_survives_a_backend_texture_rejection() {
let mut state =
AssetHotReloadState::from_sources(HotReloadSources::default(), None, Default::default());
let batch = DecodedAssetBatch {
textures: vec![DecodedTexture {
slot: 0,
width: 1,
height: 1,
pixels: vec![0; 4],
source: "a.png".to_string(),
}],
..Default::default()
};
inject_batch(&state, batch);
let mut backend = RecordingBackend {
fail_texture_updates: true,
..Default::default()
};
assert!(poll_pending_assets(&mut state, &mut backend));
assert_eq!(backend.texture_updates.len(), 1);
}
#[test]
fn poll_pending_assets_applies_a_color_lut() {
let mut state =
AssetHotReloadState::from_sources(HotReloadSources::default(), None, Default::default());
let batch = DecodedAssetBatch {
color_lut: Some(DecodedColorLut {
size: 2,
data: vec![0; 32],
source: "grade.cube".to_string(),
}),
..Default::default()
};
inject_batch(&state, batch);
let mut backend = RecordingBackend::default();
assert!(poll_pending_assets(&mut state, &mut backend));
assert_eq!(backend.lut_updates, vec![2]);
}
fn one_mesh_state(draw_indices: Vec<DrawIndex>) -> AssetHotReloadState {
let mut meshes = MeshSourceMap::new();
meshes.entries.push(MeshSourceEntry {
source: "model.glb".to_string(),
primitive_index: 0,
lod_levels: 1,
lod_distances: Vec::new(),
draw_indices,
});
AssetHotReloadState::from_sources(
HotReloadSources {
meshes,
..Default::default()
},
None,
Default::default(),
)
}
fn decoded_mesh(entry_idx: usize, vertex_count: usize) -> DecodedMesh {
DecodedMesh {
entry_idx,
vertices: vec![zero_vertex(); vertex_count],
indices: vec![0; vertex_count * 3],
lod_alternates: Vec::new(),
}
}
#[test]
fn poll_pending_assets_updates_meshes_in_place_per_draw_slot() {
let mut state = one_mesh_state(vec![DrawIndex(2), DrawIndex(5)]);
let batch = DecodedAssetBatch {
meshes: vec![decoded_mesh(0, 3)],
..Default::default()
};
inject_batch(&state, batch);
let mut backend = RecordingBackend::default();
assert!(poll_pending_assets(&mut state, &mut backend));
assert_eq!(backend.mesh_updates, vec![DrawIndex(2), DrawIndex(5)]);
assert!(backend.static_rebuild_change_counts.is_empty());
}
#[test]
fn poll_pending_assets_rebuilds_a_size_changed_mesh() {
let mut state = one_mesh_state(vec![DrawIndex(2), DrawIndex(5)]);
let batch = DecodedAssetBatch {
meshes: vec![decoded_mesh(0, 3)],
..Default::default()
};
inject_batch(&state, batch);
let mut backend = RecordingBackend::default();
backend.geometry_sizes.insert(DrawIndex(2), (999, 999));
assert!(poll_pending_assets(&mut state, &mut backend));
assert!(backend.mesh_updates.is_empty());
assert_eq!(backend.static_rebuild_change_counts, vec![2]);
}
#[test]
fn poll_pending_assets_skips_an_out_of_range_mesh_entry() {
let mut state =
AssetHotReloadState::from_sources(HotReloadSources::default(), None, Default::default());
let batch = DecodedAssetBatch {
meshes: vec![decoded_mesh(7, 3)],
..Default::default()
};
inject_batch(&state, batch);
let mut backend = RecordingBackend::default();
assert!(poll_pending_assets(&mut state, &mut backend));
assert!(backend.mesh_updates.is_empty());
assert!(backend.static_rebuild_change_counts.is_empty());
}
fn one_skinned_state(entry: SkinnedMeshSourceEntry) -> AssetHotReloadState {
let mut skinned = SkinnedMeshSourceMap::new();
skinned.entries.push(entry);
AssetHotReloadState::from_sources(
HotReloadSources {
skinned_meshes: skinned,
..Default::default()
},
None,
Default::default(),
)
}
fn skinned_entry() -> SkinnedMeshSourceEntry {
SkinnedMeshSourceEntry {
source: "rig.glb".to_string(),
skin_index: 0,
skinned_index: SkinnedIndex(4),
vertex_base: 0,
vertex_count: 2,
index_count: 3,
joint_count: 2,
}
}
fn decoded_skinned(entry_idx: usize, vertex_count: usize, joints: usize) -> DecodedSkinnedMesh {
DecodedSkinnedMesh {
entry_idx,
vertices: vec![zero_skinned_vertex(); vertex_count],
indices: vec![0; 3],
skeleton: (0..joints).map(|i| joint_def(&format!("j{i}"))).collect(),
}
}
#[test]
fn poll_pending_assets_updates_skinned_meshes_in_place() {
let mut state = one_skinned_state(skinned_entry());
let batch = DecodedAssetBatch {
skinned_meshes: vec![decoded_skinned(0, 2, 2)],
..Default::default()
};
inject_batch(&state, batch);
let mut backend = RecordingBackend::default();
assert!(poll_pending_assets(&mut state, &mut backend));
assert_eq!(backend.skinned_updates, vec![SkinnedIndex(4)]);
assert!(backend.skinned_rebuild_change_counts.is_empty());
assert!(state.pending_skeleton_updates.is_empty());
}
#[test]
fn poll_pending_assets_rebuilds_size_changed_skinned_and_refreshes_the_layout() {
let mut state = one_skinned_state(skinned_entry());
let batch = DecodedAssetBatch {
skinned_meshes: vec![decoded_skinned(0, 5, 2)],
..Default::default()
};
inject_batch(&state, batch);
let mut backend = RecordingBackend::default();
backend.skinned_layouts.push((SkinnedIndex(4), 7, 5, 3));
assert!(poll_pending_assets(&mut state, &mut backend));
assert!(backend.skinned_updates.is_empty());
assert_eq!(backend.skinned_rebuild_change_counts, vec![1]);
let entry = &state.skinned_meshes.entries[0];
assert_eq!(entry.vertex_base, 7);
assert_eq!(entry.vertex_count, 5);
assert_eq!(entry.index_count, 3);
}
#[test]
fn poll_pending_assets_queues_a_skeleton_update_on_joint_count_change() {
let mut state = one_skinned_state(skinned_entry());
let batch = DecodedAssetBatch {
skinned_meshes: vec![decoded_skinned(0, 2, 3)],
..Default::default()
};
inject_batch(&state, batch);
let mut backend = RecordingBackend::default();
assert!(poll_pending_assets(&mut state, &mut backend));
assert_eq!(backend.skeleton_updates, vec![(SkinnedIndex(4), 3)]);
assert_eq!(state.skinned_meshes.entries[0].joint_count, 3);
let drained = state.drain_pending_skeleton_updates();
assert_eq!(drained.len(), 1);
assert_eq!(drained[0].skinned_index, SkinnedIndex(4));
}
#[test]
fn poll_pending_assets_failed_skinned_rebuild_drops_queued_skeleton_updates() {
let mut state = one_skinned_state(skinned_entry());
let batch = DecodedAssetBatch {
skinned_meshes: vec![decoded_skinned(0, 5, 3)],
..Default::default()
};
inject_batch(&state, batch);
let mut backend = RecordingBackend {
fail_skinned_rebuild: true,
..Default::default()
};
assert!(poll_pending_assets(&mut state, &mut backend));
assert!(state.pending_skeleton_updates.is_empty());
assert!(backend.skeleton_updates.is_empty());
assert_eq!(state.skinned_meshes.entries[0].joint_count, 2);
}
#[test]
fn poll_pending_assets_failed_joint_resize_keeps_the_old_count() {
let mut state = one_skinned_state(skinned_entry());
let batch = DecodedAssetBatch {
skinned_meshes: vec![decoded_skinned(0, 2, 3)],
..Default::default()
};
inject_batch(&state, batch);
let mut backend = RecordingBackend {
fail_skeleton_update: true,
..Default::default()
};
assert!(poll_pending_assets(&mut state, &mut backend));
assert_eq!(state.skinned_meshes.entries[0].joint_count, 2);
assert!(state.pending_skeleton_updates.is_empty());
}
#[test]
fn poll_pending_envmap_with_nothing_in_flight_returns_false() {
let state =
AssetHotReloadState::from_sources(HotReloadSources::default(), None, Default::default());
let mut backend = RecordingBackend::default();
assert!(!poll_pending_envmap(&state, &mut backend));
assert_eq!(backend.env_updates, 0);
}
#[test]
fn poll_pending_envmap_keeps_waiting_while_the_worker_runs() {
let state =
AssetHotReloadState::from_sources(HotReloadSources::default(), None, Default::default());
let (_tx, rx) = std::sync::mpsc::channel::<Result<Vec<u8>, String>>();
*state.env_map_inflight.lock().unwrap() = Some(rx);
let mut backend = RecordingBackend::default();
assert!(!poll_pending_envmap(&state, &mut backend));
assert!(state.env_map_inflight.lock().unwrap().is_some());
}
#[test]
fn poll_pending_envmap_applies_a_successful_payload() {
let state =
AssetHotReloadState::from_sources(HotReloadSources::default(), None, Default::default());
let (tx, rx) = std::sync::mpsc::channel();
tx.send(Ok(vec![1u8, 2, 3])).unwrap();
*state.env_map_inflight.lock().unwrap() = Some(rx);
let mut backend = RecordingBackend::default();
assert!(poll_pending_envmap(&state, &mut backend));
assert_eq!(backend.env_updates, 1);
assert!(state.env_map_inflight.lock().unwrap().is_none());
}
#[test]
fn poll_pending_envmap_consumes_a_failed_convolution_without_a_backend_call() {
let state =
AssetHotReloadState::from_sources(HotReloadSources::default(), None, Default::default());
let (tx, rx) = std::sync::mpsc::channel();
tx.send(Err("bad hdr".to_string())).unwrap();
*state.env_map_inflight.lock().unwrap() = Some(rx);
let mut backend = RecordingBackend::default();
assert!(poll_pending_envmap(&state, &mut backend));
assert_eq!(backend.env_updates, 0);
assert!(state.env_map_inflight.lock().unwrap().is_none());
}
#[test]
fn poll_pending_envmap_clears_the_slot_when_the_worker_disconnects() {
let state =
AssetHotReloadState::from_sources(HotReloadSources::default(), None, Default::default());
let (tx, rx) = std::sync::mpsc::channel::<Result<Vec<u8>, String>>();
drop(tx);
*state.env_map_inflight.lock().unwrap() = Some(rx);
let mut backend = RecordingBackend::default();
assert!(poll_pending_envmap(&state, &mut backend));
assert_eq!(backend.env_updates, 0);
assert!(state.env_map_inflight.lock().unwrap().is_none());
}
#[test]
fn poll_pending_envmap_survives_a_backend_rejection() {
let state =
AssetHotReloadState::from_sources(HotReloadSources::default(), None, Default::default());
let (tx, rx) = std::sync::mpsc::channel();
tx.send(Ok(vec![9u8; 4])).unwrap();
*state.env_map_inflight.lock().unwrap() = Some(rx);
let mut backend = RecordingBackend {
fail_env_updates: true,
..Default::default()
};
assert!(poll_pending_envmap(&state, &mut backend));
assert_eq!(backend.env_updates, 1);
assert!(state.env_map_inflight.lock().unwrap().is_none());
}
#[test]
fn reload_assets_spawns_an_envmap_worker() {
let dir = tempfile::tempdir().unwrap();
let hdr = dir.path().join("sky.hdr");
let state = AssetHotReloadState::from_sources(
HotReloadSources {
environment_map: Some(EnvironmentMapSource {
resolved_path: hdr.to_string_lossy().into_owned(),
prefilter_face_size: 8,
irradiance_face_size: 8,
prefilter_samples: 4,
prefilter_clamp: 4.0,
}),
..Default::default()
},
None,
Default::default(),
);
reload_assets(&state);
let rx = state
.env_map_inflight
.lock()
.unwrap()
.take()
.expect("envmap worker scheduled");
let _ = rx.recv();
}
#[test]
fn poll_pending_assets_survives_a_color_lut_rejection() {
let mut state =
AssetHotReloadState::from_sources(HotReloadSources::default(), None, Default::default());
let batch = DecodedAssetBatch {
color_lut: Some(DecodedColorLut {
size: 2,
data: vec![0; 32],
source: "grade.cube".to_string(),
}),
..Default::default()
};
inject_batch(&state, batch);
let mut backend = RecordingBackend {
fail_lut_updates: true,
..Default::default()
};
assert!(poll_pending_assets(&mut state, &mut backend));
assert_eq!(backend.lut_updates, vec![2]);
}
#[test]
fn poll_pending_assets_rebuilds_on_a_changed_lod_breakdown() {
let mut state = one_mesh_state(vec![DrawIndex(0)]);
let batch = DecodedAssetBatch {
meshes: vec![DecodedMesh {
entry_idx: 0,
vertices: vec![zero_vertex(); 3],
indices: vec![0; 9],
lod_alternates: vec![(10.0, vec![0u16; 6])],
}],
..Default::default()
};
inject_batch(&state, batch);
let mut backend = RecordingBackend::default();
backend.lod_counts.insert(DrawIndex(0), vec![3]);
assert!(poll_pending_assets(&mut state, &mut backend));
assert!(backend.mesh_updates.is_empty());
assert_eq!(backend.static_rebuild_change_counts, vec![1]);
}
#[test]
fn poll_pending_assets_skips_an_out_of_range_skinned_entry() {
let mut state = one_skinned_state(skinned_entry());
let batch = DecodedAssetBatch {
skinned_meshes: vec![decoded_skinned(9, 2, 2)],
..Default::default()
};
inject_batch(&state, batch);
let mut backend = RecordingBackend::default();
assert!(poll_pending_assets(&mut state, &mut backend));
assert!(backend.skinned_updates.is_empty());
assert!(backend.skinned_rebuild_change_counts.is_empty());
}
#[test]
fn poll_pending_assets_survives_a_skinned_in_place_rejection() {
let mut state = one_skinned_state(skinned_entry());
let batch = DecodedAssetBatch {
skinned_meshes: vec![decoded_skinned(0, 2, 2)],
..Default::default()
};
inject_batch(&state, batch);
let mut backend = RecordingBackend {
fail_skinned_updates: true,
..Default::default()
};
assert!(poll_pending_assets(&mut state, &mut backend));
assert_eq!(backend.skinned_updates, vec![SkinnedIndex(4)]);
assert!(backend.skinned_rebuild_change_counts.is_empty());
}
fn write_world_line(dir: &std::path::Path, line: &str) -> String {
let path = dir.join("world.jsonl");
std::fs::write(&path, format!("{line}\n")).unwrap();
path.to_string_lossy().into_owned()
}
#[test]
fn reload_volumetric_fog_missing_file_is_a_no_op() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("gone.jsonl");
let mut last = None;
let mut backend = RecordingBackend::default();
let r = reload_volumetric_fog(path.to_str().unwrap(), &mut last, &mut backend);
assert!(!r.updated);
assert!(last.is_none());
assert_eq!(backend.fog_updates, 0);
}
#[test]
fn reload_volumetric_fog_invalid_jsonl_is_a_no_op() {
let dir = tempfile::tempdir().unwrap();
let path = write_world_line(dir.path(), "{ this is not json");
let mut last = None;
let mut backend = RecordingBackend::default();
let r = reload_volumetric_fog(&path, &mut last, &mut backend);
assert!(!r.updated);
assert_eq!(backend.fog_updates, 0);
}
#[test]
fn reload_volumetric_fog_pushes_an_enabled_fog_once() {
let dir = tempfile::tempdir().unwrap();
let path = write_world_line(
dir.path(),
r#"["VolumetricFog",{"$id":"fog","enabled":true,"density":0.5}]"#,
);
let mut last = None;
let mut backend = RecordingBackend::default();
let r = reload_volumetric_fog(&path, &mut last, &mut backend);
assert!(r.updated);
assert!(last.is_some());
assert_eq!(backend.fog_updates, 1);
let r = reload_volumetric_fog(&path, &mut last, &mut backend);
assert!(!r.updated);
assert_eq!(backend.fog_updates, 1);
}
#[test]
fn reload_volumetric_fog_disabling_pushes_none() {
let dir = tempfile::tempdir().unwrap();
let path = write_world_line(
dir.path(),
r#"["VolumetricFog",{"$id":"fog","enabled":true}]"#,
);
let mut last = None;
let mut backend = RecordingBackend::default();
assert!(reload_volumetric_fog(&path, &mut last, &mut backend).updated);
assert!(last.is_some());
let path = write_world_line(
dir.path(),
r#"["VolumetricFog",{"$id":"fog","enabled":false}]"#,
);
assert!(reload_volumetric_fog(&path, &mut last, &mut backend).updated);
assert!(last.is_none());
assert_eq!(backend.fog_updates, 2);
}
#[test]
fn reload_volumetric_fog_removed_asset_pushes_none() {
let dir = tempfile::tempdir().unwrap();
let path = write_world_line(
dir.path(),
r#"["VolumetricFog",{"$id":"fog","enabled":true}]"#,
);
let mut last = None;
let mut backend = RecordingBackend::default();
assert!(reload_volumetric_fog(&path, &mut last, &mut backend).updated);
let path = write_world_line(dir.path(), r#"["GraphicsConfig",{"$id":"gfx"}]"#);
assert!(reload_volumetric_fog(&path, &mut last, &mut backend).updated);
assert!(last.is_none());
}
#[test]
fn reload_volumetric_fog_bad_args_keep_the_previous_state() {
let dir = tempfile::tempdir().unwrap();
let path = write_world_line(
dir.path(),
r#"["VolumetricFog",{"$id":"fog","density":"oops"}]"#,
);
let mut last = None;
let mut backend = RecordingBackend::default();
let r = reload_volumetric_fog(&path, &mut last, &mut backend);
assert!(!r.updated);
assert!(last.is_none());
assert_eq!(backend.fog_updates, 0);
}
fn normalized_box_args(half: f32) -> ProceduralMesh {
serde_json::from_value(serde_json::json!({
"generator": "box",
"half_extents": [half, half, half],
}))
.unwrap()
}
fn one_proc_mesh_map(name: &str, args: ProceduralMesh) -> ProceduralMeshSourceMap {
let mut map = ProceduralMeshSourceMap::new();
map.entries.push(ProceduralMeshSourceEntry {
name: name.to_string(),
args,
draw_indices: vec![DrawIndex(0), DrawIndex(1)],
});
map
}
fn box_world_line(name: &str, half: f32) -> String {
format!(
r#"["ProceduralMesh",{{"$id":"{name}","generator":"box","half_extents":[{half},{half},{half}]}}]"#,
)
}
#[test]
fn reload_procedural_meshes_with_an_empty_map_short_circuits() {
let mut map = ProceduralMeshSourceMap::new();
let mut backend = RecordingBackend::default();
let r = reload_procedural_meshes("does_not_matter.jsonl", &mut map, &mut backend);
assert_eq!((r.regenerated, r.unchanged, r.failed), (0, 0, 0));
}
#[test]
fn reload_procedural_meshes_missing_file_regenerates_nothing() {
let dir = tempfile::tempdir().unwrap();
let mut map = one_proc_mesh_map("box_mesh", normalized_box_args(0.5));
let mut backend = RecordingBackend::default();
let r = reload_procedural_meshes(
dir.path().join("gone.jsonl").to_str().unwrap(),
&mut map,
&mut backend,
);
assert_eq!((r.regenerated, r.unchanged, r.failed), (0, 0, 0));
assert!(backend.mesh_updates.is_empty());
}
#[test]
fn reload_procedural_meshes_skips_unchanged_args() {
let dir = tempfile::tempdir().unwrap();
let path = write_world_line(dir.path(), &box_world_line("box_mesh", 0.5));
let mut map = one_proc_mesh_map("box_mesh", normalized_box_args(0.5));
let mut backend = RecordingBackend::default();
let r = reload_procedural_meshes(&path, &mut map, &mut backend);
assert_eq!((r.regenerated, r.unchanged, r.failed), (0, 1, 0));
assert!(backend.mesh_updates.is_empty());
}
#[test]
fn reload_procedural_meshes_treats_a_missing_jsonl_entry_as_unchanged() {
let dir = tempfile::tempdir().unwrap();
let path = write_world_line(dir.path(), r#"["GraphicsConfig",{"$id":"gfx"}]"#);
let mut map = one_proc_mesh_map("box_mesh", normalized_box_args(0.5));
let mut backend = RecordingBackend::default();
let r = reload_procedural_meshes(&path, &mut map, &mut backend);
assert_eq!((r.regenerated, r.unchanged, r.failed), (0, 1, 0));
}
#[test]
fn reload_procedural_meshes_treats_unparseable_args_as_unchanged() {
let dir = tempfile::tempdir().unwrap();
let path = write_world_line(
dir.path(),
r#"["ProceduralMesh",{"$id":"box_mesh","generator":42}]"#,
);
let mut map = one_proc_mesh_map("box_mesh", normalized_box_args(0.5));
let mut backend = RecordingBackend::default();
let r = reload_procedural_meshes(&path, &mut map, &mut backend);
assert_eq!((r.regenerated, r.unchanged, r.failed), (0, 1, 0));
}
#[test]
fn reload_procedural_meshes_regenerates_changed_args_in_place() {
let dir = tempfile::tempdir().unwrap();
let path = write_world_line(dir.path(), &box_world_line("box_mesh", 1.0));
let mut map = one_proc_mesh_map("box_mesh", normalized_box_args(0.5));
let mut backend = RecordingBackend::default();
let r = reload_procedural_meshes(&path, &mut map, &mut backend);
assert_eq!((r.regenerated, r.unchanged, r.failed), (1, 0, 0));
assert_eq!(backend.mesh_updates, vec![DrawIndex(0), DrawIndex(1)]);
assert!(backend.static_rebuild_change_counts.is_empty());
assert_eq!(map.entries[0].args, normalized_box_args(1.0));
}
#[test]
fn reload_procedural_meshes_keeps_args_when_the_backend_rejects_the_update() {
let dir = tempfile::tempdir().unwrap();
let path = write_world_line(dir.path(), &box_world_line("box_mesh", 1.0));
let mut map = one_proc_mesh_map("box_mesh", normalized_box_args(0.5));
let mut backend = RecordingBackend {
fail_mesh_updates: true,
..Default::default()
};
let r = reload_procedural_meshes(&path, &mut map, &mut backend);
assert_eq!((r.regenerated, r.unchanged, r.failed), (0, 0, 1));
assert_eq!(map.entries[0].args, normalized_box_args(0.5));
}
#[test]
fn reload_procedural_meshes_rebuilds_on_size_change() {
let dir = tempfile::tempdir().unwrap();
let path = write_world_line(dir.path(), &box_world_line("box_mesh", 1.0));
let mut map = one_proc_mesh_map("box_mesh", normalized_box_args(0.5));
let mut backend = RecordingBackend::default();
backend.geometry_sizes.insert(DrawIndex(0), (1, 1));
let r = reload_procedural_meshes(&path, &mut map, &mut backend);
assert_eq!((r.regenerated, r.unchanged, r.failed), (1, 0, 0));
assert!(backend.mesh_updates.is_empty());
assert_eq!(backend.static_rebuild_change_counts, vec![2]);
assert_eq!(map.entries[0].args, normalized_box_args(1.0));
}
#[test]
fn reload_procedural_meshes_failed_rebuild_keeps_captured_args() {
let dir = tempfile::tempdir().unwrap();
let path = write_world_line(dir.path(), &box_world_line("box_mesh", 1.0));
let mut map = one_proc_mesh_map("box_mesh", normalized_box_args(0.5));
let mut backend = RecordingBackend {
fail_static_rebuild: true,
..Default::default()
};
backend.geometry_sizes.insert(DrawIndex(0), (1, 1));
let r = reload_procedural_meshes(&path, &mut map, &mut backend);
assert_eq!((r.regenerated, r.unchanged, r.failed), (0, 0, 1));
assert_eq!(map.entries[0].args, normalized_box_args(0.5));
}
#[test]
fn reload_stories_missing_world_file_returns_nothing() {
let dir = tempfile::tempdir().unwrap();
let mut snapshots = std::collections::HashMap::new();
let stories = reload_stories(
dir.path().join("gone.jsonl").to_str().unwrap(),
&mut snapshots,
);
assert!(stories.is_empty());
assert!(snapshots.is_empty());
}
#[test]
fn reload_stories_ignores_worlds_without_stories() {
let dir = tempfile::tempdir().unwrap();
let path = write_world_line(dir.path(), r#"["GraphicsConfig",{"$id":"gfx"}]"#);
let mut snapshots = std::collections::HashMap::new();
assert!(reload_stories(&path, &mut snapshots).is_empty());
assert!(snapshots.is_empty());
}
#[test]
fn state_debug_format_summarizes_the_catalog() {
let mut map = TextureSourceMap::new();
map.push_texture("standalone.png".to_string(), 0, 0);
let state = AssetHotReloadState::from_sources(
HotReloadSources {
map,
..Default::default()
},
None,
Default::default(),
);
let dump = format!("{state:?}");
assert!(dump.contains("AssetHotReloadState"));
assert!(dump.contains("entries: 1"));
assert!(dump.contains("assets: false"));
assert!(dump.contains("env_map_inflight: false"));
assert!(dump.contains("asset_batch_inflight: false"));
}
#[test]
fn fresh_state_starts_with_empty_story_snapshots() {
let state =
AssetHotReloadState::from_sources(HotReloadSources::default(), None, Default::default());
assert!(state.story_snapshots.is_empty());
assert!(state.world_jsonl_path.is_none());
}
#[test]
fn access_events_are_not_asset_events() {
let evt = Event::new(EventKind::Access(notify::event::AccessKind::Any))
.add_path(PathBuf::from("/tmp/a.png"));
assert!(!is_asset_event(&evt));
}
#[test]
fn events_without_paths_are_not_asset_events() {
let evt = Event::new(EventKind::Modify(notify::event::ModifyKind::Any));
assert!(!is_asset_event(&evt));
}
#[test]
fn extensionless_paths_are_not_asset_events() {
let evt = Event::new(EventKind::Modify(notify::event::ModifyKind::Any))
.add_path(PathBuf::from("/tmp/Makefile"));
assert!(!is_asset_event(&evt));
}
#[test]
fn create_and_remove_events_count_as_asset_events() {
let create = Event::new(EventKind::Create(notify::event::CreateKind::Any))
.add_path(PathBuf::from("/tmp/a.png"));
assert!(is_asset_event(&create));
let remove = Event::new(EventKind::Remove(notify::event::RemoveKind::Any))
.add_path(PathBuf::from("/tmp/a.png"));
assert!(is_asset_event(&remove));
}
#[test]
fn uppercase_texture_extension_still_matches() {
let evt = Event::new(EventKind::Modify(notify::event::ModifyKind::Any))
.add_path(PathBuf::from("/tmp/ALBEDO.PNG"));
assert!(is_asset_event(&evt));
}
#[test]
fn signal_raises_only_the_pass_each_kind_serves() {
let signals = super::signals::ReloadSignals::default();
signal(ReloadKind::Shaders, &signals);
assert!(signals.take_shaders().is_empty());
assert!(!signals.take_assets());
signal(ReloadKind::World, &signals);
assert!(signals.take_world());
assert!(!signals.take_assets());
signal(ReloadKind::Stories, &signals);
assert!(signals.take_stories());
assert!(!signals.take_world());
signal(ReloadKind::Assets, &signals);
assert!(signals.take_assets());
assert!(signals.take_animations());
assert!(!signals.take_world());
assert!(!signals.take_stories());
}
#[test]
fn spawn_watcher_returns_none_when_no_directory_is_watchable() {
let dir = tempfile::tempdir().unwrap();
let mut meshes = MeshSourceMap::new();
meshes.entries.push(MeshSourceEntry {
source: dir
.path()
.join("no_such_subdir")
.join("model.glb")
.to_string_lossy()
.into_owned(),
primitive_index: 0,
lod_levels: 1,
lod_distances: Vec::new(),
draw_indices: vec![DrawIndex(0)],
});
let sources = HotReloadSources {
meshes,
..Default::default()
};
assert!(spawn_watcher(&sources, None, Default::default()).is_none());
}
#[test]
fn spawn_watcher_subscribes_to_an_existing_source_directory() {
let dir = tempfile::tempdir().unwrap();
let mut map = TextureSourceMap::new();
map.push_texture(
dir.path().join("albedo.png").to_string_lossy().into_owned(),
0,
0,
);
let sources = HotReloadSources {
map,
..Default::default()
};
assert!(spawn_watcher(&sources, None, Default::default()).is_some());
}
#[test]
fn story_source_dirs_collects_unique_parents_from_story_imports() {
let dir = tempfile::tempdir().unwrap();
let world = dir.path().join("world.jsonl");
let lines = [
r#"["StoryImport",{"$id":"s1","source":"stories/tale.md"}]"#,
r#"["StoryImport",{"$id":"s2","source":"bare.md"}]"#,
r#"["StoryImport",{"$id":"s3","source":"stories/other.md"}]"#,
r#"["GraphicsConfig",{"$id":"gfx"}]"#,
r#"["StoryImport",{"$id":"s4"}]"#,
"not json at all",
];
std::fs::write(&world, lines.join("\n")).unwrap();
let dirs = story_source_dirs(world.to_str().unwrap());
assert_eq!(dirs.len(), 2);
assert!(dirs.contains(&PathBuf::from(".")));
assert!(dirs.contains(&PathBuf::from("stories")));
}
#[test]
fn story_source_dirs_of_a_missing_world_is_empty() {
let dir = tempfile::tempdir().unwrap();
let dirs = story_source_dirs(dir.path().join("gone.jsonl").to_str().unwrap());
assert!(dirs.is_empty());
}
#[test]
fn poll_pending_assets_survives_an_in_place_mesh_rejection() {
let mut state = one_mesh_state(vec![DrawIndex(2), DrawIndex(5)]);
let batch = DecodedAssetBatch {
meshes: vec![decoded_mesh(0, 3)],
..Default::default()
};
inject_batch(&state, batch);
let mut backend = RecordingBackend {
fail_mesh_updates: true,
..Default::default()
};
assert!(poll_pending_assets(&mut state, &mut backend));
assert_eq!(backend.mesh_updates, vec![DrawIndex(2), DrawIndex(5)]);
assert!(backend.static_rebuild_change_counts.is_empty());
assert!(state.asset_batch_inflight.lock().unwrap().is_none());
}
#[test]
fn poll_pending_assets_survives_a_failed_static_rebuild() {
let mut state = one_mesh_state(vec![DrawIndex(2), DrawIndex(5)]);
let batch = DecodedAssetBatch {
meshes: vec![decoded_mesh(0, 3)],
..Default::default()
};
inject_batch(&state, batch);
let mut backend = RecordingBackend {
fail_static_rebuild: true,
..Default::default()
};
backend.geometry_sizes.insert(DrawIndex(2), (999, 999));
assert!(poll_pending_assets(&mut state, &mut backend));
assert!(backend.mesh_updates.is_empty());
assert_eq!(backend.static_rebuild_change_counts, vec![2]);
assert!(state.asset_batch_inflight.lock().unwrap().is_none());
}
#[test]
fn reload_assets_skips_the_envmap_spawn_while_a_convolution_is_in_flight() {
let dir = tempfile::tempdir().unwrap();
let env_map = EnvironmentMapSource {
resolved_path: dir.path().join("studio.hdr").to_string_lossy().into_owned(),
prefilter_face_size: 64,
irradiance_face_size: 16,
prefilter_samples: 64,
prefilter_clamp: 12.0,
};
let state = AssetHotReloadState::from_sources(
HotReloadSources {
environment_map: Some(env_map),
..Default::default()
},
None,
Default::default(),
);
let (_tx, rx) = std::sync::mpsc::channel::<Result<Vec<u8>, String>>();
*state.env_map_inflight.lock().unwrap() = Some(rx);
reload_assets(&state);
let slot = state.env_map_inflight.lock().unwrap();
assert!(matches!(
slot.as_ref().unwrap().try_recv(),
Err(std::sync::mpsc::TryRecvError::Empty)
));
assert!(state.asset_batch_inflight.lock().unwrap().is_none());
}
fn drive_run_frame(
state: &mut AssetHotReloadState,
) -> (
FrameHotReloadEffects,
RecordingBackend,
Option<volumetric_fog::FogSettings>,
) {
let mut backend = RecordingBackend::default();
let mut fog = live_edit::parked::PushedFogSettings(None);
let effects = run_frame(state, &mut backend, &mut fog, None);
(effects, backend, fog.0)
}
#[test]
fn run_frame_with_no_pending_flags_returns_empty_effects() {
let mut state =
AssetHotReloadState::from_sources(HotReloadSources::default(), None, Default::default());
let (effects, backend, last_fog) = drive_run_frame(&mut state);
assert!(effects.skeleton_updates.is_empty());
assert!(effects.story_updates.is_empty());
assert!(last_fog.is_none());
assert_eq!(backend.fog_updates, 0);
assert!(backend.mesh_updates.is_empty());
assert!(backend.texture_updates.is_empty());
}
#[test]
fn run_frame_consumes_the_state_reload_flag_without_spawning_on_an_empty_catalog() {
let mut state =
AssetHotReloadState::from_sources(HotReloadSources::default(), None, Default::default());
state.signals.request_assets();
let (effects, _backend, _last_fog) = drive_run_frame(&mut state);
assert!(!state.signals.take_assets());
assert!(state.asset_batch_inflight.lock().unwrap().is_none());
assert!(state.env_map_inflight.lock().unwrap().is_none());
assert!(effects.story_updates.is_empty());
}
#[test]
fn run_frame_consumes_the_pending_shader_set() {
let mut state =
AssetHotReloadState::from_sources(HotReloadSources::default(), None, Default::default());
state.signals.request_all();
let _ = drive_run_frame(&mut state);
assert!(state.signals.take_shaders().is_empty());
assert!(state.signals.take_sdf_volumes().is_empty());
}
#[test]
fn run_frame_reloads_stories_when_the_story_flag_is_set() {
let _guard = crate::test_support::lock();
let dir = tempfile::tempdir().unwrap();
let md = dir.path().join("tale.md");
std::fs::write(
&md,
"---\ntitle: Tale\ncharacters:\n a: Ana\n---\n\n# start\n\nHello there.\n",
)
.unwrap();
let world = dir.path().join("world.jsonl");
std::fs::write(
&world,
format!(
"{}\n",
serde_json::json!(["StoryImport", {"$id": "tale", "source": md.to_str().unwrap()}])
),
)
.unwrap();
let mut state = AssetHotReloadState::from_sources(
HotReloadSources::default(),
Some(world.to_string_lossy().into_owned()),
Default::default(),
);
state.signals.request_stories();
let (effects, _backend, _last_fog) = drive_run_frame(&mut state);
assert_eq!(effects.story_updates.len(), 1);
assert_eq!(effects.story_updates[0].title, "Tale");
assert!(!state.signals.take_stories());
}
#[test]
fn run_frame_reloads_world_assets_when_the_world_flag_is_set() {
let dir = tempfile::tempdir().unwrap();
let world = write_world_line(
dir.path(),
r#"["VolumetricFog",{"$id":"fog","enabled":true,"density":0.5}]"#,
);
let mut state = AssetHotReloadState::from_sources(
HotReloadSources::default(),
Some(world),
Default::default(),
);
state.signals.request_world();
let (_effects, backend, last_fog) = drive_run_frame(&mut state);
assert_eq!(backend.fog_updates, 1);
assert!(last_fog.is_some());
assert!(!state.signals.take_world());
}
use super::driver::{HotReloadDriver, apply_effects};
use super::world_path::WorldPathHandle;
#[test]
fn driver_on_a_world_without_graphics_stays_unarmed() {
let mut driver = HotReloadDriver::new();
let mut world = World::new();
driver.drive(&mut world);
assert!(driver.signals().is_none());
}
#[test]
fn driver_rearm_keeps_its_signals() {
let mut driver = HotReloadDriver::new();
driver.arm(HotReloadSources::default());
let first = driver.signals().expect("armed driver exposes its signals");
driver.arm(HotReloadSources::default());
let second = driver
.signals()
.expect("re-armed driver exposes its signals");
assert!(std::sync::Arc::ptr_eq(&first, &second));
assert!(std::sync::Arc::ptr_eq(
&first,
&driver.state.as_ref().unwrap().signals
));
}
fn armed_world_path(driver: &HotReloadDriver) -> Option<String> {
driver.state.as_ref()?.world_jsonl_path.clone()
}
#[test]
fn driver_arms_with_the_handle_path() {
let dir = tempfile::tempdir().unwrap();
let world = dir
.path()
.join("world.jsonl")
.to_string_lossy()
.into_owned();
let mut driver = HotReloadDriver::new().with_world_path(WorldPathHandle::new(world.as_str()));
driver.arm(HotReloadSources::default());
assert_eq!(armed_world_path(&driver), Some(world));
}
#[test]
fn driver_rearm_follows_a_set_on_a_cloned_handle() {
let dir = tempfile::tempdir().unwrap();
let first = dir
.path()
.join("first.jsonl")
.to_string_lossy()
.into_owned();
let second = dir
.path()
.join("second.jsonl")
.to_string_lossy()
.into_owned();
let handle = WorldPathHandle::new(first.as_str());
let mut driver = HotReloadDriver::new().with_world_path(handle.clone());
driver.arm(HotReloadSources::default());
assert_eq!(armed_world_path(&driver), Some(first));
handle.set(second.as_str());
driver.arm(HotReloadSources::default());
assert_eq!(armed_world_path(&driver), Some(second));
}
#[test]
fn driver_without_a_handle_watches_no_world_file() {
let mut driver = HotReloadDriver::new();
driver.arm(HotReloadSources::default());
assert_eq!(armed_world_path(&driver), None);
}
#[test]
fn armed_driver_survives_a_drive_over_an_empty_world() {
let mut driver = HotReloadDriver::new();
driver.arm(HotReloadSources::default());
let mut world = World::new();
driver.drive(&mut world);
assert!(driver.signals().is_some());
}
#[test]
fn apply_effects_splices_the_matching_skeleton_pose_only() {
use concinnity_core::animation::skeleton::{Joint, JointPose, Skeleton};
use concinnity_core::components::SkeletonPose;
let mut world = World::new();
world.add_component(SkeletonPose::new(
Default::default(),
SkinnedIndex(0),
Skeleton::new(Vec::new()),
));
world.add_component(SkeletonPose::new(
Default::default(),
SkinnedIndex(1),
Skeleton::new(Vec::new()),
));
let new_skeleton = Skeleton::new(vec![Joint {
name: String::new(),
parent: None,
bind: JointPose::default(),
}]);
apply_effects(
&mut world,
FrameHotReloadEffects {
skeleton_updates: vec![PendingSkeletonUpdate {
skinned_index: SkinnedIndex(1),
new_skeleton,
}],
story_updates: Vec::new(),
reload_reports: Vec::new(),
},
);
let joints_of = |idx: u32| {
world
.query::<SkeletonPose>()
.find(|p| p.skinned_index == SkinnedIndex(idx))
.map(|p| (p.skeleton.len(), p.joint_matrices.len()))
.unwrap()
};
assert_eq!(joints_of(1), (1, 1));
assert_eq!(joints_of(0).0, 0);
}
#[test]
fn apply_effects_sends_a_story_reload_event() {
let mut world = World::new();
let story = Story {
title: "Tale".to_string(),
nodes: Vec::new(),
text_speed: 0.0,
scaffold: Default::default(),
save_key: String::new(),
};
apply_effects(
&mut world,
FrameHotReloadEffects {
skeleton_updates: Vec::new(),
story_updates: vec![story],
reload_reports: Vec::new(),
},
);
let mut cursor = EventCursor::default();
let events = world
.events::<StoryReload>()
.expect("a StoryReload event queue exists after apply");
let received: Vec<_> = events.read(&mut cursor).collect();
assert_eq!(received.len(), 1);
assert_eq!(received[0].story.title, "Tale");
}