synvael/crates/client/src/tests/chunks.rs

274 lines
8.9 KiB
Rust

// 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(&center));
}
#[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<ChunkPos> = 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<MeshKey>,
/// Keys cleared via [`MeshSink::remove_mesh`], in call order.
removed: Vec<MeshKey>,
}
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);
}