// SPDX-License-Identifier: AGPL-3.0-only //! Unit tests for the chunk streaming logic in [`crate::chunks`]. use std::time::{Duration, Instant}; use shared::world::BlockId; use super::*; #[test] fn center_is_always_included() { let center = ChunkPos::new(0, 0, 0); assert!(desired_chunks(center, 4).contains(¢er)); } #[test] fn excludes_columns_beyond_the_disc() { let set = desired_chunks(ChunkPos::new(0, 0, 0), 4); // One chunk past the radius along an axis: squared distance 25 > 16. assert!(!set.contains(&ChunkPos::new(5, 0, 0))); // The far corner: squared distance 4*4 + 4*4 = 32 > 16. assert!(!set.contains(&ChunkPos::new(4, 0, 4))); } #[test] fn vertical_extent_is_half_the_radius() { let set = desired_chunks(ChunkPos::new(0, 0, 0), 4); // radius / 2 == 2, so the column at the center spans y in [-2, 2]. assert!(set.contains(&ChunkPos::new(0, 2, 0))); assert!(!set.contains(&ChunkPos::new(0, 3, 0))); } #[test] fn set_is_translation_invariant() { // Shifting the center shifts every member by the same offset; this also exercises negative coordinates on the shifted side. let base = desired_chunks(ChunkPos::new(0, 0, 0), 3); let shifted: HashSet = base .iter() .map(|p| ChunkPos::new(p.x - 10, p.y - 10, p.z - 10)) .collect(); assert_eq!(shifted, desired_chunks(ChunkPos::new(-10, -10, -10), 3)); } /// Builds a residency predicate over a fixed set of positions. fn resident_in(set: &[ChunkPos]) -> impl Fn(ChunkPos) -> bool + '_ { move |pos| set.contains(&pos) } #[test] fn loaded_chunk_remeshes_self_and_resident_neighbors() { let p = ChunkPos::new(0, 0, 0); let east = ChunkPos::new(1, 0, 0); let down = ChunkPos::new(0, -1, 0); // p plus two of its six neighbours are resident; the other four are not. let resident = [p, east, down]; let targets = remesh_targets(&[p], &[], resident_in(&resident)); assert_eq!(targets, resident.into_iter().collect()); } #[test] fn dropped_chunk_remeshes_neighbors_but_not_itself() { let p = ChunkPos::new(0, 0, 0); let neighbor = ChunkPos::new(1, 0, 0); let resident = [neighbor]; let targets = remesh_targets(&[], &[p], resident_in(&resident)); // The dropped chunk is never a target; its resident neighbour is. assert!(!targets.contains(&p)); assert_eq!(targets, [neighbor].into_iter().collect()); } #[test] fn remesh_targets_are_deduplicated() { // Two adjacent chunks loaded in one batch each name the other as a neighbour, but the set holds each once. let a = ChunkPos::new(0, 0, 0); let b = ChunkPos::new(1, 0, 0); let resident = [a, b]; let targets = remesh_targets(&[a, b], &[], resident_in(&resident)); assert_eq!(targets, resident.into_iter().collect()); } // --- Staleness decision (`should_apply`) --------------------------------- #[test] fn should_apply_accepts_current_result() { let pos = ChunkPos::new(1, 2, 3); let generation = JobGen::FIRST; let mut in_flight = HashMap::new(); in_flight.insert(pos, generation); // Resident and generation matches the outstanding job: apply. assert!(should_apply(pos, generation, |_| true, &in_flight)); } #[test] fn should_apply_rejects_stale_generation() { let pos = ChunkPos::new(0, 0, 0); let mut in_flight = HashMap::new(); // A newer job (next generation) is outstanding for the position. in_flight.insert(pos, JobGen::FIRST.next()); // The result carries the older generation and must be discarded. assert!(!should_apply(pos, JobGen::FIRST, |_| true, &in_flight)); } #[test] fn should_apply_rejects_unwanted_position() { let pos = ChunkPos::new(0, 0, 0); let generation = JobGen::FIRST; let mut in_flight = HashMap::new(); in_flight.insert(pos, generation); // The position is no longer resident even though a job is tracked. assert!(!should_apply(pos, generation, |_| false, &in_flight)); } #[test] fn should_apply_rejects_missing_in_flight() { let pos = ChunkPos::new(0, 0, 0); // No job is tracked for the position (it was evicted after dispatch). let in_flight = HashMap::new(); assert!(!should_apply(pos, JobGen::FIRST, |_| true, &in_flight)); } // --- Ingest pipeline plumbing -------------------------------------------- /// Recording [`MeshSink`] double capturing the keys passed to it, so ingest can be exercised without a GPU. #[derive(Default)] struct RecordingSink { /// Keys uploaded via [`MeshSink::insert_mesh`], in call order. inserted: Vec, /// Keys cleared via [`MeshSink::remove_mesh`], in call order. removed: Vec, } impl MeshSink for RecordingSink { fn insert_mesh( &mut self, key: MeshKey, _vertices: &[Vertex], _indices: &[u32], _world_offset: [f32; 3], ) -> Result<(), RendererError> { self.inserted.push(key); Ok(()) } fn remove_mesh(&mut self, key: MeshKey) { self.removed.push(key); } } /// Builds a chunk with a single solid block so it meshes to non-empty geometry. fn solid_chunk() -> Chunk { let mut chunk = Chunk::default(); chunk.set(0, 0, 0, BlockId(1)); chunk } /// Blocks until the pool yields a finished mesh, panicking if none arrives within a generous deadline. fn wait_for_result(pool: &MeshPool) -> MeshResult { let deadline = Instant::now() + Duration::from_secs(5); loop { if let Some(result) = pool.poll() { return result; } assert!( Instant::now() < deadline, "worker pool did not return a mesh within the deadline" ); std::thread::sleep(Duration::from_millis(1)); } } #[test] fn finished_mesh_is_uploaded() { let mut manager = ChunkManager::new(); let pos = ChunkPos::new(0, 0, 0); manager.resident.insert(pos, Arc::new(solid_chunk())); manager.pending_remesh.insert(pos); assert_eq!(manager.dispatch_pending(), 1); let result = wait_for_result(&manager.pool); let mut sink = RecordingSink::default(); assert!(manager.apply_result(&result, &mut sink)); // A non-empty mesh is uploaded once and the in-flight entry is cleared. assert_eq!(sink.inserted, vec![(0, 0, 0)]); assert!(sink.removed.is_empty()); assert!(!manager.in_flight.contains_key(&pos)); } #[test] fn superseded_mesh_is_discarded() { let mut manager = ChunkManager::new(); let pos = ChunkPos::new(0, 0, 0); manager.resident.insert(pos, Arc::new(solid_chunk())); manager.pending_remesh.insert(pos); manager.dispatch_pending(); let stale = wait_for_result(&manager.pool); // A newer job supersedes the outstanding one before the first result is applied. manager.pending_remesh.insert(pos); manager.dispatch_pending(); let mut sink = RecordingSink::default(); assert!(!manager.apply_result(&stale, &mut sink)); assert!(sink.inserted.is_empty()); assert!(sink.removed.is_empty()); // The newer job remains tracked as outstanding. assert!(manager.in_flight.contains_key(&pos)); } #[test] fn evicted_mesh_is_discarded() { let mut manager = ChunkManager::new(); let pos = ChunkPos::new(0, 0, 0); manager.resident.insert(pos, Arc::new(solid_chunk())); manager.pending_remesh.insert(pos); manager.dispatch_pending(); let result = wait_for_result(&manager.pool); // The chunk leaves the load radius before its mesh arrives. manager.resident.remove(&pos); manager.in_flight.remove(&pos); let mut sink = RecordingSink::default(); assert!(!manager.apply_result(&result, &mut sink)); assert!(sink.inserted.is_empty()); assert!(sink.removed.is_empty()); } #[test] fn stats_report_live_pipeline_state() { let mut manager = ChunkManager::new(); let center = ChunkPos::new(0, 0, 0); manager .resident .insert(center, Arc::new(ChunkManager::new().baseline.clone())); manager.pending_remesh.insert(ChunkPos::new(1, 0, 0)); let stats = manager.stats(center); assert_eq!(stats.resident, 1); assert_eq!(stats.pending_remesh, 1); assert_eq!(stats.in_flight, 0); assert_eq!(stats.load_radius, LOAD_RADIUS); assert_eq!(stats.desired, desired_chunks(center, LOAD_RADIUS).len()); // One resident chunk accounts for exactly one chunk's worth of voxel storage. assert_eq!(stats.resident_bytes, CHUNK_RESIDENT_BYTES); assert!(stats.mesh_workers >= 1); } #[test] fn totals_accumulate_across_frames() { let mut totals = ChunkTotals::default(); totals.accumulate(1, 2, 3, 4, 5); totals.accumulate(10, 20, 30, 40, 50); assert_eq!(totals.loaded, 11); assert_eq!(totals.dropped, 22); assert_eq!(totals.evicted, 33); assert_eq!(totals.dispatched, 44); assert_eq!(totals.applied, 55); } #[test] fn totals_saturate_rather_than_overflow() { let mut totals = ChunkTotals { loaded: u64::MAX, ..ChunkTotals::default() }; totals.accumulate(1, 0, 0, 0, 0); assert_eq!(totals.loaded, u64::MAX); }