274 lines
8.9 KiB
Rust
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(¢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<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);
|
|
}
|