test(workspace): relocate all tests into per-crate src/tests via #[path]
This commit is contained in:
parent
8e7cd15085
commit
7b3908dd7f
|
|
@ -130,121 +130,5 @@ fn check_frame_len(len: u64, max_len: usize) -> Result<usize, NetError> {
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use shared::protocol::{
|
||||
ClientHello, ControlMessage, FeatureFlags, PROTOCOL_VERSION, PlayerIdentity,
|
||||
};
|
||||
|
||||
/// Round-trips a set of boundary values through the varint codec, asserting both the decoded value and the exact byte length consumed.
|
||||
#[test]
|
||||
fn varint_round_trip_boundaries() -> Result<(), NetError> {
|
||||
let cases = [
|
||||
0,
|
||||
1,
|
||||
127,
|
||||
128,
|
||||
16_383,
|
||||
16_384,
|
||||
u64::from(u32::MAX),
|
||||
u64::MAX,
|
||||
];
|
||||
for value in cases {
|
||||
let mut buf = Vec::new();
|
||||
write_varint(value, &mut buf);
|
||||
let (decoded, consumed) = read_varint(&buf)?;
|
||||
assert_eq!(decoded, value, "decoded value mismatch");
|
||||
assert_eq!(
|
||||
consumed,
|
||||
buf.len(),
|
||||
"consumed length must equal encoded length"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A frame encodes as `[varint payload length][payload]`, and decoding the prefix recovers exactly the payload byte count.
|
||||
#[test]
|
||||
fn encode_frame_prefixes_payload_length() -> Result<(), NetError> {
|
||||
let msg = ControlMessage::ClientHello(ClientHello {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
client_build: "synvael-client-0.1.0".to_string(),
|
||||
player_identity: PlayerIdentity {
|
||||
display_name: "Player1".to_string(),
|
||||
},
|
||||
installed_packs: Vec::new(),
|
||||
requested_features: FeatureFlags(0),
|
||||
});
|
||||
let payload = postcard::to_stdvec(&msg)?;
|
||||
let frame = encode_frame(&msg)?;
|
||||
|
||||
let (declared_len, prefix_bytes) = read_varint(&frame)?;
|
||||
assert_eq!(
|
||||
declared_len,
|
||||
payload.len() as u64,
|
||||
"prefix must equal payload length"
|
||||
);
|
||||
assert_eq!(
|
||||
&frame[prefix_bytes..],
|
||||
payload.as_slice(),
|
||||
"payload bytes must follow the prefix unchanged"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Confirms the documented one/two-byte boundary encodings so a regression in the continuation logic is caught directly.
|
||||
#[test]
|
||||
fn varint_boundary_lengths() {
|
||||
let mut buf = Vec::new();
|
||||
write_varint(127, &mut buf);
|
||||
assert_eq!(buf.len(), 1, "127 must encode in a single byte");
|
||||
|
||||
buf.clear();
|
||||
write_varint(128, &mut buf);
|
||||
assert_eq!(buf.len(), 2, "128 must encode in two bytes");
|
||||
}
|
||||
|
||||
/// A varint whose final byte still sets the continuation bit is a truncated buffer and must error rather than panic.
|
||||
#[test]
|
||||
fn varint_truncated_is_error() {
|
||||
// Two bytes both flagged as "continued", with no terminating byte.
|
||||
let truncated = [0x80u8, 0x80u8];
|
||||
assert!(
|
||||
read_varint(&truncated).is_err(),
|
||||
"truncated varint must return an error"
|
||||
);
|
||||
}
|
||||
|
||||
/// An encoding longer than the ten bytes a `u64` can occupy is rejected as overlong rather than silently accepted.
|
||||
#[test]
|
||||
fn varint_overlong_is_error() {
|
||||
// Eleven continuation bytes followed by a terminator exceeds the u64 limit.
|
||||
let overlong = [0x80u8; 11];
|
||||
assert!(
|
||||
read_varint(&overlong).is_err(),
|
||||
"overlong varint must return an error"
|
||||
);
|
||||
}
|
||||
|
||||
/// A declared length within the cap is accepted; one exceeding it is rejected as `FrameTooLarge` before any allocation.
|
||||
#[test]
|
||||
fn frame_len_bound_is_enforced() {
|
||||
assert_eq!(
|
||||
check_frame_len(64, 128).ok(),
|
||||
Some(64),
|
||||
"a length within the cap is accepted"
|
||||
);
|
||||
assert_eq!(
|
||||
check_frame_len(128, 128).ok(),
|
||||
Some(128),
|
||||
"a length equal to the cap is accepted"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
check_frame_len(129, 128),
|
||||
Err(NetError::FrameTooLarge { len: 129, max: 128 })
|
||||
),
|
||||
"a length over the cap must be rejected",
|
||||
);
|
||||
}
|
||||
}
|
||||
#[path = "tests/codec.rs"]
|
||||
mod tests;
|
||||
|
|
|
|||
|
|
@ -121,27 +121,5 @@ impl ServerCertVerifier for AcceptAnyServerCert {
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn alpn_identifier_is_synvael() {
|
||||
assert_eq!(ALPN, b"synvael");
|
||||
}
|
||||
|
||||
// A tokio runtime is required because `quinn::Endpoint` spawns its driver task on construction.
|
||||
#[tokio::test]
|
||||
async fn server_endpoint_constructs_and_binds() -> Result<(), NetError> {
|
||||
let bind = "127.0.0.1:0".parse().map_err(std::io::Error::other)?;
|
||||
let endpoint = server_endpoint(bind)?;
|
||||
// A concrete port is assigned once the UDP socket is bound.
|
||||
assert_ne!(endpoint.local_addr()?.port(), 0, "socket must bind a port");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_endpoint_constructs_and_binds() -> Result<(), NetError> {
|
||||
client_endpoint()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[path = "tests/endpoint.rs"]
|
||||
mod tests;
|
||||
|
|
|
|||
|
|
@ -43,3 +43,31 @@ pub enum NetError {
|
|||
#[error("no initial cipher suite for quic: {0}")]
|
||||
NoInitialCipherSuite(#[from] quinn::crypto::rustls::NoInitialCipherSuite),
|
||||
}
|
||||
|
||||
/// Errors produced while performing the Synvael application handshake.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum HandshakeError {
|
||||
/// The synchronous `quinn` connect call failed before the connection attempt began.
|
||||
#[error("quic connect error: {0}")]
|
||||
Connect(#[from] quinn::ConnectError),
|
||||
/// The QUIC connection failed to establish or was lost during the handshake.
|
||||
#[error("quic connection error: {0}")]
|
||||
Connection(#[from] quinn::ConnectionError),
|
||||
/// A control-stream frame failed to encode, decode, or transfer.
|
||||
#[error("control frame codec error: {0}")]
|
||||
Codec(#[from] NetError),
|
||||
/// The server refused the handshake. Carries the structured reason received (client side) or sent (server side) over the wire.
|
||||
#[error("handshake rejected: {0:?}")]
|
||||
Rejected(shared::protocol::HandshakeReject),
|
||||
/// A control message other than the one expected for this handshake step arrived.
|
||||
#[error("unexpected control message during handshake")]
|
||||
UnexpectedMessage,
|
||||
/// The client's protocol version did not match the server's. Returned locally by the server after it has sent a [`shared::protocol::HandshakeReject`] to the client.
|
||||
#[error("protocol version mismatch: client {client}, server {server}")]
|
||||
VersionMismatch {
|
||||
/// Protocol version advertised by the client in its `ClientHello`.
|
||||
client: u32,
|
||||
/// Protocol version the server was built against (`shared::protocol::PROTOCOL_VERSION`).
|
||||
server: u32,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,11 +11,10 @@ use shared::protocol::{
|
|||
ClientHello, ControlMessage, Disconnect, HandshakeAck, HandshakeReject, PROTOCOL_VERSION,
|
||||
RejectReason, StreamLayout,
|
||||
};
|
||||
use thiserror::Error;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::codec::{MAX_CONTROL_FRAME_LEN, read_frame, write_frame};
|
||||
use crate::error::NetError;
|
||||
use crate::error::HandshakeError;
|
||||
|
||||
/// Application close code used when a peer is rejected during the handshake.
|
||||
const CLOSE_CODE_REJECTED: u32 = 1;
|
||||
|
|
@ -26,34 +25,6 @@ const CLOSE_CODE_GRACEFUL: u32 = 0;
|
|||
/// Upper bound on the wait for a rejected client to read the `HandshakeReject` and close, before the server tears the connection down anyway.
|
||||
const REJECT_DELIVERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// Errors produced while performing the Synvael application handshake.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum HandshakeError {
|
||||
/// The synchronous `quinn` connect call failed before the connection attempt began.
|
||||
#[error("quic connect error: {0}")]
|
||||
Connect(#[from] quinn::ConnectError),
|
||||
/// The QUIC connection failed to establish or was lost during the handshake.
|
||||
#[error("quic connection error: {0}")]
|
||||
Connection(#[from] quinn::ConnectionError),
|
||||
/// A control-stream frame failed to encode, decode, or transfer.
|
||||
#[error("control frame codec error: {0}")]
|
||||
Codec(#[from] NetError),
|
||||
/// The server refused the handshake. Carries the structured reason received (client side) or sent (server side) over the wire.
|
||||
#[error("handshake rejected: {0:?}")]
|
||||
Rejected(HandshakeReject),
|
||||
/// A control message other than the one expected for this handshake step arrived.
|
||||
#[error("unexpected control message during handshake")]
|
||||
UnexpectedMessage,
|
||||
/// The client's protocol version did not match the server's. Returned locally by the server after it has sent a [`HandshakeReject`] to the client.
|
||||
#[error("protocol version mismatch: client {client}, server {server}")]
|
||||
VersionMismatch {
|
||||
/// Protocol version advertised by the client in its `ClientHello`.
|
||||
client: u32,
|
||||
/// Protocol version the server was built against (`PROTOCOL_VERSION`).
|
||||
server: u32,
|
||||
},
|
||||
}
|
||||
|
||||
/// A completed client-side handshake: an established connection, the retained control stream, and the server's acceptance parameters.
|
||||
#[derive(Debug)]
|
||||
pub struct Connected {
|
||||
|
|
|
|||
|
|
@ -11,3 +11,7 @@ pub mod endpoint;
|
|||
pub mod error;
|
||||
pub mod handshake;
|
||||
pub mod runtime;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/handshake.rs"]
|
||||
mod handshake_tests;
|
||||
|
|
|
|||
118
crates/net/src/tests/codec.rs
Normal file
118
crates/net/src/tests/codec.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
use super::*;
|
||||
use shared::protocol::{
|
||||
ClientHello, ControlMessage, FeatureFlags, PROTOCOL_VERSION, PlayerIdentity,
|
||||
};
|
||||
|
||||
/// Round-trips a set of boundary values through the varint codec, asserting both the decoded value and the exact byte length consumed.
|
||||
#[test]
|
||||
fn varint_round_trip_boundaries() -> Result<(), NetError> {
|
||||
let cases = [
|
||||
0,
|
||||
1,
|
||||
127,
|
||||
128,
|
||||
16_383,
|
||||
16_384,
|
||||
u64::from(u32::MAX),
|
||||
u64::MAX,
|
||||
];
|
||||
for value in cases {
|
||||
let mut buf = Vec::new();
|
||||
write_varint(value, &mut buf);
|
||||
let (decoded, consumed) = read_varint(&buf)?;
|
||||
assert_eq!(decoded, value, "decoded value mismatch");
|
||||
assert_eq!(
|
||||
consumed,
|
||||
buf.len(),
|
||||
"consumed length must equal encoded length"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A frame encodes as `[varint payload length][payload]`, and decoding the prefix recovers exactly the payload byte count.
|
||||
#[test]
|
||||
fn encode_frame_prefixes_payload_length() -> Result<(), NetError> {
|
||||
let msg = ControlMessage::ClientHello(ClientHello {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
client_build: "synvael-client-0.1.0".to_string(),
|
||||
player_identity: PlayerIdentity {
|
||||
display_name: "Player1".to_string(),
|
||||
},
|
||||
installed_packs: Vec::new(),
|
||||
requested_features: FeatureFlags(0),
|
||||
});
|
||||
let payload = postcard::to_stdvec(&msg)?;
|
||||
let frame = encode_frame(&msg)?;
|
||||
|
||||
let (declared_len, prefix_bytes) = read_varint(&frame)?;
|
||||
assert_eq!(
|
||||
declared_len,
|
||||
payload.len() as u64,
|
||||
"prefix must equal payload length"
|
||||
);
|
||||
assert_eq!(
|
||||
&frame[prefix_bytes..],
|
||||
payload.as_slice(),
|
||||
"payload bytes must follow the prefix unchanged"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Confirms the documented one/two-byte boundary encodings so a regression in the continuation logic is caught directly.
|
||||
#[test]
|
||||
fn varint_boundary_lengths() {
|
||||
let mut buf = Vec::new();
|
||||
write_varint(127, &mut buf);
|
||||
assert_eq!(buf.len(), 1, "127 must encode in a single byte");
|
||||
|
||||
buf.clear();
|
||||
write_varint(128, &mut buf);
|
||||
assert_eq!(buf.len(), 2, "128 must encode in two bytes");
|
||||
}
|
||||
|
||||
/// A varint whose final byte still sets the continuation bit is a truncated buffer and must error rather than panic.
|
||||
#[test]
|
||||
fn varint_truncated_is_error() {
|
||||
// Two bytes both flagged as "continued", with no terminating byte.
|
||||
let truncated = [0x80u8, 0x80u8];
|
||||
assert!(
|
||||
read_varint(&truncated).is_err(),
|
||||
"truncated varint must return an error"
|
||||
);
|
||||
}
|
||||
|
||||
/// An encoding longer than the ten bytes a `u64` can occupy is rejected as overlong rather than silently accepted.
|
||||
#[test]
|
||||
fn varint_overlong_is_error() {
|
||||
// Eleven continuation bytes followed by a terminator exceeds the u64 limit.
|
||||
let overlong = [0x80u8; 11];
|
||||
assert!(
|
||||
read_varint(&overlong).is_err(),
|
||||
"overlong varint must return an error"
|
||||
);
|
||||
}
|
||||
|
||||
/// A declared length within the cap is accepted; one exceeding it is rejected as `FrameTooLarge` before any allocation.
|
||||
#[test]
|
||||
fn frame_len_bound_is_enforced() {
|
||||
assert_eq!(
|
||||
check_frame_len(64, 128).ok(),
|
||||
Some(64),
|
||||
"a length within the cap is accepted"
|
||||
);
|
||||
assert_eq!(
|
||||
check_frame_len(128, 128).ok(),
|
||||
Some(128),
|
||||
"a length equal to the cap is accepted"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
check_frame_len(129, 128),
|
||||
Err(NetError::FrameTooLarge { len: 129, max: 128 })
|
||||
),
|
||||
"a length over the cap must be rejected",
|
||||
);
|
||||
}
|
||||
24
crates/net/src/tests/endpoint.rs
Normal file
24
crates/net/src/tests/endpoint.rs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn alpn_identifier_is_synvael() {
|
||||
assert_eq!(ALPN, b"synvael");
|
||||
}
|
||||
|
||||
// A tokio runtime is required because `quinn::Endpoint` spawns its driver task on construction.
|
||||
#[tokio::test]
|
||||
async fn server_endpoint_constructs_and_binds() -> Result<(), NetError> {
|
||||
let bind = "127.0.0.1:0".parse().map_err(std::io::Error::other)?;
|
||||
let endpoint = server_endpoint(bind)?;
|
||||
// A concrete port is assigned once the UDP socket is bound.
|
||||
assert_ne!(endpoint.local_addr()?.port(), 0, "socket must bind a port");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_endpoint_constructs_and_binds() -> Result<(), NetError> {
|
||||
client_endpoint()?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -4,8 +4,9 @@
|
|||
//!
|
||||
//! Each test binds a real QUIC server endpoint on `127.0.0.1:0`, reads the OS-assigned port, and drives a client through the full `ClientHello` -> `HandshakeAck` / `HandshakeReject` exchange, exercising the async `read_frame`/`write_frame` path end-to-end.
|
||||
|
||||
use net::endpoint::{client_endpoint, server_endpoint};
|
||||
use net::handshake::{HandshakeError, accept_connection, connect};
|
||||
use crate::endpoint::{client_endpoint, server_endpoint};
|
||||
use crate::error::HandshakeError;
|
||||
use crate::handshake::{accept_connection, connect};
|
||||
use shared::protocol::{ClientHello, FeatureFlags, PROTOCOL_VERSION, PlayerIdentity, RejectReason};
|
||||
|
||||
/// Builds a `ClientHello` for `display_name` advertising `protocol_version`.
|
||||
|
|
@ -13,12 +13,5 @@ pub fn add(left: u64, right: u64) -> u64 {
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_works() {
|
||||
let result = add(2, 2);
|
||||
assert_eq!(result, 4);
|
||||
}
|
||||
}
|
||||
#[path = "tests/lib.rs"]
|
||||
mod tests;
|
||||
|
|
|
|||
9
crates/scripting/src/tests/lib.rs
Normal file
9
crates/scripting/src/tests/lib.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_works() {
|
||||
let result = add(2, 2);
|
||||
assert_eq!(result, 4);
|
||||
}
|
||||
|
|
@ -62,53 +62,5 @@ impl ChunkCache {
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use shared::generator::{VoxelGenerator, WorldGenConfig};
|
||||
use shared::world::{BlockId, ChunkPos};
|
||||
use std::num::NonZeroUsize;
|
||||
|
||||
/// Builds a generator with a small, cheap terrain configuration for cache tests.
|
||||
fn test_generator() -> VoxelGenerator {
|
||||
let config = WorldGenConfig {
|
||||
base_height: 8,
|
||||
noise_scale: 0.05,
|
||||
surface_block: BlockId(1),
|
||||
subsurface_block: BlockId(2),
|
||||
stone_block: BlockId(3),
|
||||
};
|
||||
VoxelGenerator::new(config, 42)
|
||||
}
|
||||
|
||||
/// A second lookup of the same position must be served from the store, not regenerated.
|
||||
#[test]
|
||||
fn repeated_lookup_is_a_cache_hit() {
|
||||
let generator = test_generator();
|
||||
let cache = ChunkCache::new(NonZeroUsize::new(4).unwrap_or(NonZeroUsize::MIN));
|
||||
let pos = ChunkPos::new(0, 0, 0);
|
||||
|
||||
let first = cache.get_or_generate(pos, &generator);
|
||||
let second = cache.get_or_generate(pos, &generator);
|
||||
|
||||
// Determinism guarantees identical output, and a single resident entry can only hold if the second call was a hit rather than a fresh generation-and-insert of a distinct value.
|
||||
assert_eq!(first.blocks, second.blocks);
|
||||
assert_eq!(cache.len(), 1);
|
||||
}
|
||||
|
||||
/// Inserting beyond capacity evicts the least-recently-used entry.
|
||||
#[test]
|
||||
fn exceeding_capacity_evicts_oldest() {
|
||||
let generator = test_generator();
|
||||
let cache = ChunkCache::new(NonZeroUsize::new(2).unwrap_or(NonZeroUsize::MIN));
|
||||
|
||||
let _ = cache.get_or_generate(ChunkPos::new(0, 0, 0), &generator);
|
||||
let _ = cache.get_or_generate(ChunkPos::new(1, 0, 0), &generator);
|
||||
// Touch the first so the second becomes the least-recently-used before the overflow.
|
||||
let _ = cache.get_or_generate(ChunkPos::new(0, 0, 0), &generator);
|
||||
let _ = cache.get_or_generate(ChunkPos::new(2, 0, 0), &generator);
|
||||
|
||||
assert_eq!(cache.len(), 2);
|
||||
assert!(cache.contains(ChunkPos::new(0, 0, 0)));
|
||||
assert!(!cache.contains(ChunkPos::new(1, 0, 0)));
|
||||
}
|
||||
}
|
||||
#[path = "tests/chunk_cache.rs"]
|
||||
mod tests;
|
||||
|
|
|
|||
|
|
@ -241,139 +241,5 @@ fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), SaveError> {
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use shared::world::BlockId;
|
||||
|
||||
/// Builds a representative modified chunk with a few edits spanning the local index range.
|
||||
fn sample(pos: ChunkPos) -> ChunkData {
|
||||
let mut data = ChunkData::new(pos, 7);
|
||||
data.set(0, BlockId(4));
|
||||
data.set(1000, BlockId(9));
|
||||
data.set(32_767, BlockId(2));
|
||||
data
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_coords_floor_negative_columns() {
|
||||
// Truncating division would map -1 to region 0; Euclidean flooring maps it to region -1.
|
||||
assert_eq!(region_coords(0, 0), (0, 0));
|
||||
assert_eq!(region_coords(31, 31), (0, 0));
|
||||
assert_eq!(region_coords(-1, -1), (-1, -1));
|
||||
assert_eq!(region_coords(-32, -33), (-1, -2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_path_names_the_region_file() {
|
||||
let dir = Path::new("/saves/world/region");
|
||||
assert_eq!(
|
||||
region_path(dir, -1, 5),
|
||||
Path::new("/saves/world/region/r.-1.0.region")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_missing_file_is_empty() -> Result<(), SaveError> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let region = RegionFile::open(dir.path().join("r.0.0.region"))?;
|
||||
assert!(region.is_empty());
|
||||
assert_eq!(region.read_chunk(ChunkPos::new(0, 0, 0))?, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_chunks_through_disk() -> Result<(), SaveError> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let path = dir.path().join("r.0.0.region");
|
||||
|
||||
let positions = [
|
||||
ChunkPos::new(0, 0, 0),
|
||||
ChunkPos::new(1, 2, 3),
|
||||
ChunkPos::new(-5, 10, -30),
|
||||
];
|
||||
|
||||
let mut region = RegionFile::open(path.clone())?;
|
||||
for pos in positions {
|
||||
region.write_chunk(pos, &sample(pos), 123)?;
|
||||
}
|
||||
assert!(region.is_dirty());
|
||||
region.save()?;
|
||||
assert!(!region.is_dirty());
|
||||
|
||||
// Reopen from disk in a fresh instance and confirm every chunk decodes byte-identically.
|
||||
let reopened = RegionFile::open(path)?;
|
||||
assert_eq!(reopened.len(), positions.len());
|
||||
for pos in positions {
|
||||
assert_eq!(reopened.read_chunk(pos)?, Some(sample(pos)));
|
||||
}
|
||||
// A position never written has no record.
|
||||
assert_eq!(reopened.read_chunk(ChunkPos::new(9, 9, 9))?, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_offsets_are_valid_and_contiguous() -> Result<(), SaveError> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let path = dir.path().join("r.0.0.region");
|
||||
|
||||
let mut region = RegionFile::open(path.clone())?;
|
||||
for pos in [
|
||||
ChunkPos::new(0, 0, 0),
|
||||
ChunkPos::new(2, 0, 1),
|
||||
ChunkPos::new(-1, 4, -1),
|
||||
] {
|
||||
region.write_chunk(pos, &sample(pos), 0)?;
|
||||
}
|
||||
region.save()?;
|
||||
|
||||
let reopened = RegionFile::open(path)?;
|
||||
let index_len = reopened.index.encode()?.len() as u64;
|
||||
|
||||
// Records are packed contiguously immediately after the index, in ascending position order.
|
||||
let mut expected_offset = index_len;
|
||||
for (_pos, entry) in reopened.index.entries() {
|
||||
assert_eq!(entry.offset, expected_offset);
|
||||
expected_offset += u64::from(entry.length);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_drops_only_the_named_chunk() -> Result<(), SaveError> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let path = dir.path().join("r.0.0.region");
|
||||
let kept = ChunkPos::new(0, 0, 0);
|
||||
let dropped = ChunkPos::new(1, 1, 1);
|
||||
|
||||
let mut region = RegionFile::open(path.clone())?;
|
||||
region.write_chunk(kept, &sample(kept), 0)?;
|
||||
region.write_chunk(dropped, &sample(dropped), 0)?;
|
||||
region.save()?;
|
||||
|
||||
let mut region = RegionFile::open(path.clone())?;
|
||||
region.remove_chunk(dropped);
|
||||
region.save()?;
|
||||
|
||||
let reopened = RegionFile::open(path)?;
|
||||
assert_eq!(reopened.read_chunk(dropped)?, None);
|
||||
assert_eq!(reopened.read_chunk(kept)?, Some(sample(kept)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stray_tmp_file_does_not_corrupt_reads() -> Result<(), SaveError> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let path = dir.path().join("r.0.0.region");
|
||||
let pos = ChunkPos::new(0, 0, 0);
|
||||
|
||||
let mut region = RegionFile::open(path.clone())?;
|
||||
region.write_chunk(pos, &sample(pos), 0)?;
|
||||
region.save()?;
|
||||
|
||||
// A leftover .tmp from an interrupted save must be ignored: only the renamed target is read.
|
||||
fs::write(dir.path().join("r.0.0.region.tmp"), b"garbage")?;
|
||||
let reopened = RegionFile::open(path)?;
|
||||
assert_eq!(reopened.read_chunk(pos)?, Some(sample(pos)));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[path = "../tests/region_file.rs"]
|
||||
mod tests;
|
||||
|
|
|
|||
50
crates/server/src/tests/chunk_cache.rs
Normal file
50
crates/server/src/tests/chunk_cache.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
use super::*;
|
||||
use shared::generator::{VoxelGenerator, WorldGenConfig};
|
||||
use shared::world::{BlockId, ChunkPos};
|
||||
use std::num::NonZeroUsize;
|
||||
|
||||
/// Builds a generator with a small, cheap terrain configuration for cache tests.
|
||||
fn test_generator() -> VoxelGenerator {
|
||||
let config = WorldGenConfig {
|
||||
base_height: 8,
|
||||
noise_scale: 0.05,
|
||||
surface_block: BlockId(1),
|
||||
subsurface_block: BlockId(2),
|
||||
stone_block: BlockId(3),
|
||||
};
|
||||
VoxelGenerator::new(config, 42)
|
||||
}
|
||||
|
||||
/// A second lookup of the same position must be served from the store, not regenerated.
|
||||
#[test]
|
||||
fn repeated_lookup_is_a_cache_hit() {
|
||||
let generator = test_generator();
|
||||
let cache = ChunkCache::new(NonZeroUsize::new(4).unwrap_or(NonZeroUsize::MIN));
|
||||
let pos = ChunkPos::new(0, 0, 0);
|
||||
|
||||
let first = cache.get_or_generate(pos, &generator);
|
||||
let second = cache.get_or_generate(pos, &generator);
|
||||
|
||||
// Determinism guarantees identical output, and a single resident entry can only hold if the second call was a hit rather than a fresh generation-and-insert of a distinct value.
|
||||
assert_eq!(first.blocks, second.blocks);
|
||||
assert_eq!(cache.len(), 1);
|
||||
}
|
||||
|
||||
/// Inserting beyond capacity evicts the least-recently-used entry.
|
||||
#[test]
|
||||
fn exceeding_capacity_evicts_oldest() {
|
||||
let generator = test_generator();
|
||||
let cache = ChunkCache::new(NonZeroUsize::new(2).unwrap_or(NonZeroUsize::MIN));
|
||||
|
||||
let _ = cache.get_or_generate(ChunkPos::new(0, 0, 0), &generator);
|
||||
let _ = cache.get_or_generate(ChunkPos::new(1, 0, 0), &generator);
|
||||
// Touch the first so the second becomes the least-recently-used before the overflow.
|
||||
let _ = cache.get_or_generate(ChunkPos::new(0, 0, 0), &generator);
|
||||
let _ = cache.get_or_generate(ChunkPos::new(2, 0, 0), &generator);
|
||||
|
||||
assert_eq!(cache.len(), 2);
|
||||
assert!(cache.contains(ChunkPos::new(0, 0, 0)));
|
||||
assert!(!cache.contains(ChunkPos::new(1, 0, 0)));
|
||||
}
|
||||
136
crates/server/src/tests/region_file.rs
Normal file
136
crates/server/src/tests/region_file.rs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
use super::*;
|
||||
use shared::world::BlockId;
|
||||
|
||||
/// Builds a representative modified chunk with a few edits spanning the local index range.
|
||||
fn sample(pos: ChunkPos) -> ChunkData {
|
||||
let mut data = ChunkData::new(pos, 7);
|
||||
data.set(0, BlockId(4));
|
||||
data.set(1000, BlockId(9));
|
||||
data.set(32_767, BlockId(2));
|
||||
data
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_coords_floor_negative_columns() {
|
||||
// Truncating division would map -1 to region 0; Euclidean flooring maps it to region -1.
|
||||
assert_eq!(region_coords(0, 0), (0, 0));
|
||||
assert_eq!(region_coords(31, 31), (0, 0));
|
||||
assert_eq!(region_coords(-1, -1), (-1, -1));
|
||||
assert_eq!(region_coords(-32, -33), (-1, -2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_path_names_the_region_file() {
|
||||
let dir = Path::new("/saves/world/region");
|
||||
assert_eq!(
|
||||
region_path(dir, -1, 5),
|
||||
Path::new("/saves/world/region/r.-1.0.region")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_missing_file_is_empty() -> Result<(), SaveError> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let region = RegionFile::open(dir.path().join("r.0.0.region"))?;
|
||||
assert!(region.is_empty());
|
||||
assert_eq!(region.read_chunk(ChunkPos::new(0, 0, 0))?, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_chunks_through_disk() -> Result<(), SaveError> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let path = dir.path().join("r.0.0.region");
|
||||
|
||||
let positions = [
|
||||
ChunkPos::new(0, 0, 0),
|
||||
ChunkPos::new(1, 2, 3),
|
||||
ChunkPos::new(-5, 10, -30),
|
||||
];
|
||||
|
||||
let mut region = RegionFile::open(path.clone())?;
|
||||
for pos in positions {
|
||||
region.write_chunk(pos, &sample(pos), 123)?;
|
||||
}
|
||||
assert!(region.is_dirty());
|
||||
region.save()?;
|
||||
assert!(!region.is_dirty());
|
||||
|
||||
// Reopen from disk in a fresh instance and confirm every chunk decodes byte-identically.
|
||||
let reopened = RegionFile::open(path)?;
|
||||
assert_eq!(reopened.len(), positions.len());
|
||||
for pos in positions {
|
||||
assert_eq!(reopened.read_chunk(pos)?, Some(sample(pos)));
|
||||
}
|
||||
// A position never written has no record.
|
||||
assert_eq!(reopened.read_chunk(ChunkPos::new(9, 9, 9))?, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_offsets_are_valid_and_contiguous() -> Result<(), SaveError> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let path = dir.path().join("r.0.0.region");
|
||||
|
||||
let mut region = RegionFile::open(path.clone())?;
|
||||
for pos in [
|
||||
ChunkPos::new(0, 0, 0),
|
||||
ChunkPos::new(2, 0, 1),
|
||||
ChunkPos::new(-1, 4, -1),
|
||||
] {
|
||||
region.write_chunk(pos, &sample(pos), 0)?;
|
||||
}
|
||||
region.save()?;
|
||||
|
||||
let reopened = RegionFile::open(path)?;
|
||||
let index_len = reopened.index.encode()?.len() as u64;
|
||||
|
||||
// Records are packed contiguously immediately after the index, in ascending position order.
|
||||
let mut expected_offset = index_len;
|
||||
for (_pos, entry) in reopened.index.entries() {
|
||||
assert_eq!(entry.offset, expected_offset);
|
||||
expected_offset += u64::from(entry.length);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_drops_only_the_named_chunk() -> Result<(), SaveError> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let path = dir.path().join("r.0.0.region");
|
||||
let kept = ChunkPos::new(0, 0, 0);
|
||||
let dropped = ChunkPos::new(1, 1, 1);
|
||||
|
||||
let mut region = RegionFile::open(path.clone())?;
|
||||
region.write_chunk(kept, &sample(kept), 0)?;
|
||||
region.write_chunk(dropped, &sample(dropped), 0)?;
|
||||
region.save()?;
|
||||
|
||||
let mut region = RegionFile::open(path.clone())?;
|
||||
region.remove_chunk(dropped);
|
||||
region.save()?;
|
||||
|
||||
let reopened = RegionFile::open(path)?;
|
||||
assert_eq!(reopened.read_chunk(dropped)?, None);
|
||||
assert_eq!(reopened.read_chunk(kept)?, Some(sample(kept)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stray_tmp_file_does_not_corrupt_reads() -> Result<(), SaveError> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let path = dir.path().join("r.0.0.region");
|
||||
let pos = ChunkPos::new(0, 0, 0);
|
||||
|
||||
let mut region = RegionFile::open(path.clone())?;
|
||||
region.write_chunk(pos, &sample(pos), 0)?;
|
||||
region.save()?;
|
||||
|
||||
// A leftover .tmp from an interrupted save must be ignored: only the renamed target is read.
|
||||
fs::write(dir.path().join("r.0.0.region.tmp"), b"garbage")?;
|
||||
let reopened = RegionFile::open(path)?;
|
||||
assert_eq!(reopened.read_chunk(pos)?, Some(sample(pos)));
|
||||
Ok(())
|
||||
}
|
||||
213
crates/server/src/tests/world_server.rs
Normal file
213
crates/server/src/tests/world_server.rs
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
use super::*;
|
||||
use shared::generator::{VoxelGenerator, WorldGenConfig};
|
||||
use shared::save::SaveError;
|
||||
use shared::world::{BlockId, ChunkData, ChunkPos};
|
||||
use std::collections::HashSet;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::save::{RegionFile, SaveRequest, region_path};
|
||||
|
||||
/// Builds a generator with a small, cheap terrain configuration for streaming tests.
|
||||
fn test_generator() -> VoxelGenerator {
|
||||
let config = WorldGenConfig {
|
||||
base_height: 8,
|
||||
noise_scale: 0.05,
|
||||
surface_block: BlockId(1),
|
||||
subsurface_block: BlockId(2),
|
||||
stone_block: BlockId(3),
|
||||
};
|
||||
VoxelGenerator::new(config, 42)
|
||||
}
|
||||
|
||||
/// Builds a server world whose saves resolve against `region_dir`, backed by a small baseline cache.
|
||||
fn test_world(region_dir: std::path::PathBuf) -> ServerWorld {
|
||||
let capacity = std::num::NonZeroUsize::new(64).unwrap_or(std::num::NonZeroUsize::MIN);
|
||||
ServerWorld::new(test_generator(), region_dir, capacity)
|
||||
}
|
||||
|
||||
/// Repeatedly reconciles `desired` until the worker pool reports no outstanding work, returning the final pass's stats. Fails the test if the pool does not drain within a fixed timeout.
|
||||
fn drain_to_idle(world: &mut ServerWorld, desired: &HashSet<ChunkPos>) -> StreamStats {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
let stats = world.reconcile(desired);
|
||||
if stats.in_flight == 0 {
|
||||
return stats;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"worker pool did not drain in time"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
|
||||
/// Issues a flush against the world's save actor and blocks until every dirty region is written. Because write-backs and this flush travel the same sender to the single actor thread, the reply confirms the preceding writes are durable.
|
||||
fn flush(world: &ServerWorld) -> Result<(), SaveError> {
|
||||
let (reply_tx, reply_rx) = crossbeam_channel::bounded(1);
|
||||
// A send error means the actor has already stopped, leaving nothing to flush.
|
||||
if world
|
||||
.save_tx
|
||||
.send(SaveRequest::Flush { reply: reply_tx })
|
||||
.is_err()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
reply_rx.recv().unwrap_or(Ok(()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_converges_over_multiple_passes() -> Result<(), SaveError> {
|
||||
// A fresh empty directory means every load is a miss and resolves to the baseline.
|
||||
let dir = tempfile::tempdir()?;
|
||||
let mut world = test_world(dir.path().to_path_buf());
|
||||
let mut desired = HashSet::new();
|
||||
cylinder_chunks(ChunkPos::new(0, 0, 0), 2, &mut desired);
|
||||
|
||||
// The first pass only dispatches work; because loading is off-thread, nothing is resident yet and every position is in flight.
|
||||
let first = world.reconcile(&desired);
|
||||
assert_eq!(first.loaded, 0);
|
||||
assert_eq!(first.resident, 0);
|
||||
assert!(first.in_flight > 0);
|
||||
|
||||
// Later passes drain finished chunks until the pool is idle, at which point every desired position must be resident.
|
||||
let final_stats = drain_to_idle(&mut world, &desired);
|
||||
assert_eq!(final_stats.in_flight, 0);
|
||||
assert_eq!(final_stats.resident, desired.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evicted_chunk_is_not_repopulated_on_arrival() -> Result<(), SaveError> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let mut world = test_world(dir.path().to_path_buf());
|
||||
let target = ChunkPos::new(0, 0, 0);
|
||||
let mut desired = HashSet::new();
|
||||
desired.insert(target);
|
||||
|
||||
// Dispatch the chunk, then immediately stop wanting it.
|
||||
world.reconcile(&desired);
|
||||
|
||||
// Every subsequent pass reconciles against an empty desired set, so the finished chunk is discarded on arrival rather than inserted.
|
||||
let empty = HashSet::new();
|
||||
let final_stats = drain_to_idle(&mut world, &empty);
|
||||
assert_eq!(final_stats.in_flight, 0);
|
||||
assert_eq!(final_stats.resident, 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_modification_is_applied_over_baseline() -> Result<(), SaveError> {
|
||||
// A modified chunk is written to disk, then streamed back; the resident chunk must show the edit rather than the bare baseline.
|
||||
let dir = tempfile::tempdir()?;
|
||||
let pos = ChunkPos::new(0, 0, 0);
|
||||
let edited_index = 100u32;
|
||||
let edited_block = BlockId(999);
|
||||
|
||||
let mut data = ChunkData::new(pos, 0);
|
||||
data.set(edited_index, edited_block);
|
||||
|
||||
let mut region = RegionFile::open(region_path(dir.path(), pos.x, pos.z))?;
|
||||
region.write_chunk(pos, &data, 0)?;
|
||||
region.save()?;
|
||||
|
||||
let mut world = test_world(dir.path().to_path_buf());
|
||||
let mut desired = HashSet::new();
|
||||
desired.insert(pos);
|
||||
drain_to_idle(&mut world, &desired);
|
||||
|
||||
// The resident chunk must carry the stored edit layered over its regenerated baseline.
|
||||
assert!(
|
||||
world
|
||||
.chunk(pos)
|
||||
.is_some_and(|chunk| chunk.blocks[edited_index as usize] == edited_block)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_chunk_is_written_back_on_eviction() -> Result<(), SaveError> {
|
||||
// A resident chunk edited away from its baseline must survive an evict -> flush -> reload round-trip.
|
||||
let dir = tempfile::tempdir()?;
|
||||
let pos = ChunkPos::new(0, 0, 0);
|
||||
let edited_index = 100usize;
|
||||
let edited_block = BlockId(999);
|
||||
|
||||
let mut world = test_world(dir.path().to_path_buf());
|
||||
let mut desired = HashSet::new();
|
||||
desired.insert(pos);
|
||||
drain_to_idle(&mut world, &desired);
|
||||
|
||||
// Mutate the resident chunk so it diverges from the baseline the eviction diff regenerates.
|
||||
assert!(
|
||||
world
|
||||
.chunks
|
||||
.get_mut(&pos)
|
||||
.map(|chunk| chunk.blocks[edited_index] = edited_block)
|
||||
.is_some()
|
||||
);
|
||||
|
||||
// Reconciling against an empty desired set evicts the chunk, sending its diff to the actor.
|
||||
world.reconcile(&HashSet::new());
|
||||
// The flush shares the eviction's sender, so its reply confirms the write-back is on disk.
|
||||
flush(&world)?;
|
||||
|
||||
// A fresh world over the same directory must stream the chunk back with the edit intact.
|
||||
let mut reloaded = test_world(dir.path().to_path_buf());
|
||||
drain_to_idle(&mut reloaded, &desired);
|
||||
assert!(
|
||||
reloaded
|
||||
.chunk(pos)
|
||||
.is_some_and(|chunk| chunk.blocks[edited_index] == edited_block)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_chunk_is_not_written_back_on_eviction() -> Result<(), SaveError> {
|
||||
// An unmodified chunk equals its baseline, so eviction must persist no record for it.
|
||||
let dir = tempfile::tempdir()?;
|
||||
let pos = ChunkPos::new(0, 0, 0);
|
||||
|
||||
let mut world = test_world(dir.path().to_path_buf());
|
||||
let mut desired = HashSet::new();
|
||||
desired.insert(pos);
|
||||
drain_to_idle(&mut world, &desired);
|
||||
|
||||
// Evict without modifying the chunk, then flush.
|
||||
world.reconcile(&HashSet::new());
|
||||
flush(&world)?;
|
||||
|
||||
// No record may exist for a chunk that never diverged from its baseline.
|
||||
let region = RegionFile::open(region_path(dir.path(), pos.x, pos.z))?;
|
||||
assert!(region.read_chunk(pos)?.is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_contains_expected_columns() {
|
||||
let mut set = HashSet::new();
|
||||
cylinder_chunks(ChunkPos::new(0, 0, 0), 2, &mut set);
|
||||
|
||||
assert!(set.contains(&ChunkPos::new(0, 0, 0)));
|
||||
// A corner cell is outside the disc (dx=2, dz=2 -> 8 > 4).
|
||||
assert!(!set.contains(&ChunkPos::new(2, 0, 2)));
|
||||
// An axis cell at exactly the radius is included (dx=2, dz=0 -> 4 == 4).
|
||||
assert!(set.contains(&ChunkPos::new(2, 0, 0)));
|
||||
// The vertical extent is radius/2 = 1, so y=2 is out of range.
|
||||
assert!(!set.contains(&ChunkPos::new(0, 2, 0)));
|
||||
assert!(set.contains(&ChunkPos::new(0, 1, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_translates_with_center() {
|
||||
let mut origin = HashSet::new();
|
||||
cylinder_chunks(ChunkPos::new(0, 0, 0), 3, &mut origin);
|
||||
|
||||
let mut shifted = HashSet::new();
|
||||
cylinder_chunks(ChunkPos::new(10, 0, -5), 3, &mut shifted);
|
||||
|
||||
// The shape is translation-invariant: the same count regardless of center.
|
||||
assert_eq!(origin.len(), shifted.len());
|
||||
}
|
||||
|
|
@ -274,216 +274,5 @@ pub fn cylinder_chunks<S: std::hash::BuildHasher>(
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use shared::generator::{VoxelGenerator, WorldGenConfig};
|
||||
use shared::save::SaveError;
|
||||
use shared::world::{BlockId, ChunkData, ChunkPos};
|
||||
use std::collections::HashSet;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::save::{RegionFile, SaveRequest, region_path};
|
||||
|
||||
/// Builds a generator with a small, cheap terrain configuration for streaming tests.
|
||||
fn test_generator() -> VoxelGenerator {
|
||||
let config = WorldGenConfig {
|
||||
base_height: 8,
|
||||
noise_scale: 0.05,
|
||||
surface_block: BlockId(1),
|
||||
subsurface_block: BlockId(2),
|
||||
stone_block: BlockId(3),
|
||||
};
|
||||
VoxelGenerator::new(config, 42)
|
||||
}
|
||||
|
||||
/// Builds a server world whose saves resolve against `region_dir`, backed by a small baseline cache.
|
||||
fn test_world(region_dir: std::path::PathBuf) -> ServerWorld {
|
||||
let capacity = std::num::NonZeroUsize::new(64).unwrap_or(std::num::NonZeroUsize::MIN);
|
||||
ServerWorld::new(test_generator(), region_dir, capacity)
|
||||
}
|
||||
|
||||
/// Repeatedly reconciles `desired` until the worker pool reports no outstanding work, returning the final pass's stats. Fails the test if the pool does not drain within a fixed timeout.
|
||||
fn drain_to_idle(world: &mut ServerWorld, desired: &HashSet<ChunkPos>) -> StreamStats {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
let stats = world.reconcile(desired);
|
||||
if stats.in_flight == 0 {
|
||||
return stats;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"worker pool did not drain in time"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
|
||||
/// Issues a flush against the world's save actor and blocks until every dirty region is written. Because write-backs and this flush travel the same sender to the single actor thread, the reply confirms the preceding writes are durable.
|
||||
fn flush(world: &ServerWorld) -> Result<(), SaveError> {
|
||||
let (reply_tx, reply_rx) = crossbeam_channel::bounded(1);
|
||||
// A send error means the actor has already stopped, leaving nothing to flush.
|
||||
if world
|
||||
.save_tx
|
||||
.send(SaveRequest::Flush { reply: reply_tx })
|
||||
.is_err()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
reply_rx.recv().unwrap_or(Ok(()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_converges_over_multiple_passes() -> Result<(), SaveError> {
|
||||
// A fresh empty directory means every load is a miss and resolves to the baseline.
|
||||
let dir = tempfile::tempdir()?;
|
||||
let mut world = test_world(dir.path().to_path_buf());
|
||||
let mut desired = HashSet::new();
|
||||
cylinder_chunks(ChunkPos::new(0, 0, 0), 2, &mut desired);
|
||||
|
||||
// The first pass only dispatches work; because loading is off-thread, nothing is resident yet and every position is in flight.
|
||||
let first = world.reconcile(&desired);
|
||||
assert_eq!(first.loaded, 0);
|
||||
assert_eq!(first.resident, 0);
|
||||
assert!(first.in_flight > 0);
|
||||
|
||||
// Later passes drain finished chunks until the pool is idle, at which point every desired position must be resident.
|
||||
let final_stats = drain_to_idle(&mut world, &desired);
|
||||
assert_eq!(final_stats.in_flight, 0);
|
||||
assert_eq!(final_stats.resident, desired.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evicted_chunk_is_not_repopulated_on_arrival() -> Result<(), SaveError> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let mut world = test_world(dir.path().to_path_buf());
|
||||
let target = ChunkPos::new(0, 0, 0);
|
||||
let mut desired = HashSet::new();
|
||||
desired.insert(target);
|
||||
|
||||
// Dispatch the chunk, then immediately stop wanting it.
|
||||
world.reconcile(&desired);
|
||||
|
||||
// Every subsequent pass reconciles against an empty desired set, so the finished chunk is discarded on arrival rather than inserted.
|
||||
let empty = HashSet::new();
|
||||
let final_stats = drain_to_idle(&mut world, &empty);
|
||||
assert_eq!(final_stats.in_flight, 0);
|
||||
assert_eq!(final_stats.resident, 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_modification_is_applied_over_baseline() -> Result<(), SaveError> {
|
||||
// A modified chunk is written to disk, then streamed back; the resident chunk must show the edit rather than the bare baseline.
|
||||
let dir = tempfile::tempdir()?;
|
||||
let pos = ChunkPos::new(0, 0, 0);
|
||||
let edited_index = 100u32;
|
||||
let edited_block = BlockId(999);
|
||||
|
||||
let mut data = ChunkData::new(pos, 0);
|
||||
data.set(edited_index, edited_block);
|
||||
|
||||
let mut region = RegionFile::open(region_path(dir.path(), pos.x, pos.z))?;
|
||||
region.write_chunk(pos, &data, 0)?;
|
||||
region.save()?;
|
||||
|
||||
let mut world = test_world(dir.path().to_path_buf());
|
||||
let mut desired = HashSet::new();
|
||||
desired.insert(pos);
|
||||
drain_to_idle(&mut world, &desired);
|
||||
|
||||
// The resident chunk must carry the stored edit layered over its regenerated baseline.
|
||||
assert!(
|
||||
world
|
||||
.chunk(pos)
|
||||
.is_some_and(|chunk| chunk.blocks[edited_index as usize] == edited_block)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_chunk_is_written_back_on_eviction() -> Result<(), SaveError> {
|
||||
// A resident chunk edited away from its baseline must survive an evict -> flush -> reload round-trip.
|
||||
let dir = tempfile::tempdir()?;
|
||||
let pos = ChunkPos::new(0, 0, 0);
|
||||
let edited_index = 100usize;
|
||||
let edited_block = BlockId(999);
|
||||
|
||||
let mut world = test_world(dir.path().to_path_buf());
|
||||
let mut desired = HashSet::new();
|
||||
desired.insert(pos);
|
||||
drain_to_idle(&mut world, &desired);
|
||||
|
||||
// Mutate the resident chunk so it diverges from the baseline the eviction diff regenerates.
|
||||
assert!(
|
||||
world
|
||||
.chunks
|
||||
.get_mut(&pos)
|
||||
.map(|chunk| chunk.blocks[edited_index] = edited_block)
|
||||
.is_some()
|
||||
);
|
||||
|
||||
// Reconciling against an empty desired set evicts the chunk, sending its diff to the actor.
|
||||
world.reconcile(&HashSet::new());
|
||||
// The flush shares the eviction's sender, so its reply confirms the write-back is on disk.
|
||||
flush(&world)?;
|
||||
|
||||
// A fresh world over the same directory must stream the chunk back with the edit intact.
|
||||
let mut reloaded = test_world(dir.path().to_path_buf());
|
||||
drain_to_idle(&mut reloaded, &desired);
|
||||
assert!(
|
||||
reloaded
|
||||
.chunk(pos)
|
||||
.is_some_and(|chunk| chunk.blocks[edited_index] == edited_block)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_chunk_is_not_written_back_on_eviction() -> Result<(), SaveError> {
|
||||
// An unmodified chunk equals its baseline, so eviction must persist no record for it.
|
||||
let dir = tempfile::tempdir()?;
|
||||
let pos = ChunkPos::new(0, 0, 0);
|
||||
|
||||
let mut world = test_world(dir.path().to_path_buf());
|
||||
let mut desired = HashSet::new();
|
||||
desired.insert(pos);
|
||||
drain_to_idle(&mut world, &desired);
|
||||
|
||||
// Evict without modifying the chunk, then flush.
|
||||
world.reconcile(&HashSet::new());
|
||||
flush(&world)?;
|
||||
|
||||
// No record may exist for a chunk that never diverged from its baseline.
|
||||
let region = RegionFile::open(region_path(dir.path(), pos.x, pos.z))?;
|
||||
assert!(region.read_chunk(pos)?.is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_contains_expected_columns() {
|
||||
let mut set = HashSet::new();
|
||||
cylinder_chunks(ChunkPos::new(0, 0, 0), 2, &mut set);
|
||||
|
||||
assert!(set.contains(&ChunkPos::new(0, 0, 0)));
|
||||
// A corner cell is outside the disc (dx=2, dz=2 -> 8 > 4).
|
||||
assert!(!set.contains(&ChunkPos::new(2, 0, 2)));
|
||||
// An axis cell at exactly the radius is included (dx=2, dz=0 -> 4 == 4).
|
||||
assert!(set.contains(&ChunkPos::new(2, 0, 0)));
|
||||
// The vertical extent is radius/2 = 1, so y=2 is out of range.
|
||||
assert!(!set.contains(&ChunkPos::new(0, 2, 0)));
|
||||
assert!(set.contains(&ChunkPos::new(0, 1, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cylinder_translates_with_center() {
|
||||
let mut origin = HashSet::new();
|
||||
cylinder_chunks(ChunkPos::new(0, 0, 0), 3, &mut origin);
|
||||
|
||||
let mut shifted = HashSet::new();
|
||||
cylinder_chunks(ChunkPos::new(10, 0, -5), 3, &mut shifted);
|
||||
|
||||
// The shape is translation-invariant: the same count regardless of center.
|
||||
assert_eq!(origin.len(), shifted.len());
|
||||
}
|
||||
}
|
||||
#[path = "tests/world_server.rs"]
|
||||
mod tests;
|
||||
|
|
|
|||
|
|
@ -194,90 +194,5 @@ impl Default for StreamLayout {
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stream_layout_default_is_canonical() {
|
||||
let layout = StreamLayout::default();
|
||||
assert_eq!(layout.control, 0);
|
||||
assert_eq!(layout.input, 1);
|
||||
assert_eq!(layout.authority, 2);
|
||||
assert_eq!(layout.chunk_lod0, 3);
|
||||
assert_eq!(layout.chunk_lod1, 4);
|
||||
assert_eq!(layout.chunk_lod2, 5);
|
||||
assert_eq!(layout.chunk_lod3, 6);
|
||||
assert_eq!(layout.chunk_lod4, 7);
|
||||
assert_eq!(layout.asset, 8);
|
||||
assert_eq!(layout.mod_data, 9);
|
||||
}
|
||||
|
||||
fn roundtrip_test(msg: &ControlMessage) -> Result<(), postcard::Error> {
|
||||
let bytes = postcard::to_stdvec(msg)?;
|
||||
let decoded: ControlMessage = postcard::from_bytes(&bytes)?;
|
||||
assert_eq!(msg, &decoded);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_client_hello() -> Result<(), postcard::Error> {
|
||||
let msg = ControlMessage::ClientHello(ClientHello {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
client_build: "synvael-client-0.1.0".to_string(),
|
||||
player_identity: PlayerIdentity {
|
||||
display_name: "Player1".to_string(),
|
||||
},
|
||||
installed_packs: vec![PackRef {
|
||||
id: "core:base".to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
content_hash: [0; 32],
|
||||
tier: PackTier::Data,
|
||||
}],
|
||||
requested_features: FeatureFlags(0),
|
||||
});
|
||||
roundtrip_test(&msg)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_handshake_ack() -> Result<(), postcard::Error> {
|
||||
let msg = ControlMessage::HandshakeAck(HandshakeAck {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
server_build: "synvael-server-0.1.0".to_string(),
|
||||
world_packs: vec![],
|
||||
missing_packs: vec![],
|
||||
stream_layout: StreamLayout::default(),
|
||||
tick_rate_hint: 20,
|
||||
});
|
||||
roundtrip_test(&msg)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_handshake_reject() -> Result<(), postcard::Error> {
|
||||
let msg = ControlMessage::HandshakeReject(HandshakeReject {
|
||||
reason: RejectReason::ProtocolMismatch,
|
||||
detail: "Expected v1, got v2".to_string(),
|
||||
upgrade_url: Some("https://synvael.example/download".to_string()),
|
||||
});
|
||||
roundtrip_test(&msg)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_tier_match_rules() {
|
||||
// Resource packs are client-side overlays: a mismatch is always tolerated.
|
||||
assert!(!PackTier::Resource.requires_strict_match());
|
||||
// Data packs affect authoritative content and must match exactly.
|
||||
assert!(PackTier::Data.requires_strict_match());
|
||||
// A client-only mod cannot desync server state, so a mismatch is tolerated.
|
||||
assert!(!PackTier::Mod { client_only: true }.requires_strict_match());
|
||||
// A server-affecting mod must match exactly.
|
||||
assert!(PackTier::Mod { client_only: false }.requires_strict_match());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_disconnect() -> Result<(), postcard::Error> {
|
||||
let msg = ControlMessage::Disconnect(Disconnect {
|
||||
reason: "Server closing".to_string(),
|
||||
});
|
||||
roundtrip_test(&msg)
|
||||
}
|
||||
}
|
||||
#[path = "tests/protocol.rs"]
|
||||
mod tests;
|
||||
|
|
|
|||
|
|
@ -106,73 +106,5 @@ pub fn decode(bytes: &[u8]) -> Result<(RecordMeta, ChunkData), SaveError> {
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::world::{BlockId, ChunkPos};
|
||||
|
||||
/// Builds a representative modified chunk with a few edits spanning the local index range.
|
||||
fn sample() -> ChunkData {
|
||||
let mut data = ChunkData::new(ChunkPos::new(1, -2, 3), 7);
|
||||
data.set(0, BlockId(4));
|
||||
data.set(1000, BlockId(9));
|
||||
data.set(32_767, BlockId(2));
|
||||
data
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_payload_and_metadata() -> Result<(), SaveError> {
|
||||
let data = sample();
|
||||
let bytes = encode(&data, 123_456)?;
|
||||
let (meta, decoded) = decode(&bytes)?;
|
||||
|
||||
assert_eq!(decoded, data);
|
||||
assert_eq!(meta.chunk_format_version, CHUNK_FORMAT_VERSION);
|
||||
assert_eq!(meta.flags, 0);
|
||||
assert_eq!(meta.last_modified, 123_456);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_magic() -> Result<(), SaveError> {
|
||||
let mut bytes = encode(&sample(), 0)?;
|
||||
bytes[0] = b'X';
|
||||
assert!(matches!(decode(&bytes), Err(SaveError::BadMagic { .. })));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncated_header() -> Result<(), SaveError> {
|
||||
let bytes = encode(&sample(), 0)?;
|
||||
// A buffer shorter than the fixed header cannot yield a full record.
|
||||
assert!(matches!(
|
||||
decode(&bytes[..HEADER_LEN - 1]),
|
||||
Err(SaveError::Truncated { .. })
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncated_payload() -> Result<(), SaveError> {
|
||||
let bytes = encode(&sample(), 0)?;
|
||||
// Keep the whole header but cut the compressed payload short.
|
||||
assert!(matches!(
|
||||
decode(&bytes[..=HEADER_LEN]),
|
||||
Err(SaveError::Truncated { .. })
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_declared_length_mismatch() -> Result<(), SaveError> {
|
||||
let mut bytes = encode(&sample(), 0)?;
|
||||
// The uncompressed-length field is the u32 at offset 16 (after magic, version,
|
||||
// flags, and the timestamp). Overwriting it with a value the payload cannot
|
||||
// decompress to must be caught by the post-decompression length check.
|
||||
bytes[16..20].copy_from_slice(&1u32.to_le_bytes());
|
||||
assert!(matches!(
|
||||
decode(&bytes),
|
||||
Err(SaveError::LengthMismatch { .. })
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[path = "../tests/record.rs"]
|
||||
mod tests;
|
||||
|
|
|
|||
|
|
@ -261,97 +261,5 @@ fn len_u32(len: usize) -> Result<u32, SaveError> {
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Builds a populated index with entries at varied positions, a free span, and a stamp exception.
|
||||
fn sample() -> RegionIndex {
|
||||
let mut index = RegionIndex::new(3);
|
||||
index.insert(
|
||||
ChunkPos::new(0, 0, 0),
|
||||
HeaderEntry {
|
||||
offset: 4096,
|
||||
length: 128,
|
||||
flags: 0,
|
||||
},
|
||||
);
|
||||
index.insert(
|
||||
ChunkPos::new(-5, 12, -30),
|
||||
HeaderEntry {
|
||||
offset: 8192,
|
||||
length: 256,
|
||||
flags: 0,
|
||||
},
|
||||
);
|
||||
index.push_free(FreeSpan {
|
||||
offset: 512,
|
||||
length: 64,
|
||||
});
|
||||
index.set_worldgen_version(ChunkPos::new(0, 0, 0), 2);
|
||||
index.bump_tile_version();
|
||||
index
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_index() -> Result<(), SaveError> {
|
||||
let index = sample();
|
||||
let decoded = RegionIndex::decode(&index.encode()?)?;
|
||||
assert_eq!(decoded, index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_worldgen_versions_above_u16_max() -> Result<(), SaveError> {
|
||||
// Regression guard: base and stamp worldgen versions are u32, so a value that would not
|
||||
// fit a u16 must survive encode -> decode without truncation.
|
||||
let mut index = RegionIndex::new(70_000);
|
||||
index.set_worldgen_version(ChunkPos::new(0, 0, 0), 100_000);
|
||||
let decoded = RegionIndex::decode(&index.encode()?)?;
|
||||
assert_eq!(decoded.base_worldgen_version(), 70_000);
|
||||
assert_eq!(decoded.worldgen_version(ChunkPos::new(0, 0, 0)), 100_000);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stamp_equal_to_base_is_not_recorded() {
|
||||
let mut index = RegionIndex::new(7);
|
||||
// A stamp equal to the base is redundant, so no exception entry is stored.
|
||||
index.set_worldgen_version(ChunkPos::new(1, 1, 1), 7);
|
||||
assert_eq!(index.worldgen_version(ChunkPos::new(1, 1, 1)), 7);
|
||||
assert!(index.stamps.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_magic() -> Result<(), SaveError> {
|
||||
let mut bytes = sample().encode()?;
|
||||
bytes[0] = b'X';
|
||||
assert!(matches!(
|
||||
RegionIndex::decode(&bytes),
|
||||
Err(SaveError::BadMagic { .. })
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_version() -> Result<(), SaveError> {
|
||||
let mut bytes = sample().encode()?;
|
||||
// The format_version u32 sits just after the 4 magic bytes.
|
||||
bytes[4..8].copy_from_slice(&999u32.to_le_bytes());
|
||||
assert!(matches!(
|
||||
RegionIndex::decode(&bytes),
|
||||
Err(SaveError::UnsupportedVersion { .. })
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncated_table() -> Result<(), SaveError> {
|
||||
let bytes = sample().encode()?;
|
||||
// Cut the buffer mid-header-table so an entry read runs off the end.
|
||||
assert!(matches!(
|
||||
RegionIndex::decode(&bytes[..20]),
|
||||
Err(SaveError::Truncated { .. })
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[path = "../tests/region.rs"]
|
||||
mod tests;
|
||||
|
|
|
|||
69
crates/shared/src/tests/chunk.rs
Normal file
69
crates/shared/src/tests/chunk.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Builds a chunk whose voxels cycle through `distinct` material ids, guaranteeing exactly `distinct` distinct materials and therefore a palette of that size.
|
||||
fn chunk_cycling(distinct: usize) -> Chunk {
|
||||
let mut chunk = Chunk::default();
|
||||
for (i, block) in chunk.blocks.iter_mut().enumerate() {
|
||||
// `distinct` is a small test constant, so the modulo result always fits in a u16.
|
||||
#[expect(
|
||||
clippy::cast_possible_truncation,
|
||||
reason = "distinct is a small test constant within u16 range"
|
||||
)]
|
||||
let id = (i % distinct) as u16;
|
||||
*block = BlockId(id);
|
||||
}
|
||||
chunk
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bit_width_matches_palette_size() {
|
||||
// The 4->5 (2->3 bit) and 8->9 (3->4 bit) transitions are the boundaries where packing bugs hide.
|
||||
assert_eq!(PalettedChunk::bits_for_palette(1), 1);
|
||||
assert_eq!(PalettedChunk::bits_for_palette(2), 1);
|
||||
assert_eq!(PalettedChunk::bits_for_palette(3), 2);
|
||||
assert_eq!(PalettedChunk::bits_for_palette(4), 2);
|
||||
assert_eq!(PalettedChunk::bits_for_palette(5), 3);
|
||||
assert_eq!(PalettedChunk::bits_for_palette(8), 3);
|
||||
assert_eq!(PalettedChunk::bits_for_palette(9), 4);
|
||||
assert_eq!(PalettedChunk::bits_for_palette(16), 4);
|
||||
assert_eq!(PalettedChunk::bits_for_palette(17), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_preserves_all_voxels() {
|
||||
// Sizes span every bit-width boundary through five bits, including the all-air case (distinct = 1).
|
||||
for distinct in [1usize, 2, 3, 4, 5, 8, 9, 16, 17] {
|
||||
let original = chunk_cycling(distinct);
|
||||
let paletted = PalettedChunk::from_chunk(&original);
|
||||
assert_eq!(
|
||||
paletted.palette.len(),
|
||||
distinct,
|
||||
"palette must hold exactly the distinct materials for {distinct}"
|
||||
);
|
||||
let restored = paletted.to_chunk();
|
||||
assert_eq!(
|
||||
original.blocks, restored.blocks,
|
||||
"round trip must preserve every voxel for {distinct} materials"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_air_chunk_has_single_entry_palette() {
|
||||
let paletted = PalettedChunk::from_chunk(&Chunk::default());
|
||||
assert_eq!(paletted.palette, vec![BlockId::AIR]);
|
||||
assert_eq!(paletted.bits_per_index, 1);
|
||||
assert_eq!(paletted.to_chunk().blocks, Chunk::default().blocks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_index_straddling_word_boundary() {
|
||||
// With a 3-bit palette, voxel 21 begins at bit 63 and spills into the next 64-bit word; a distinctive value there pins the straddle handling.
|
||||
let mut original = chunk_cycling(5);
|
||||
original.blocks[21] = BlockId(4);
|
||||
let restored = PalettedChunk::from_chunk(&original).to_chunk();
|
||||
assert_eq!(restored.blocks[21], BlockId(4));
|
||||
assert_eq!(original.blocks, restored.blocks);
|
||||
}
|
||||
65
crates/shared/src/tests/chunk_data.rs
Normal file
65
crates/shared/src/tests/chunk_data.rs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A baseline chunk with a recognisable, non-uniform fill so edits are distinguishable from it.
|
||||
fn baseline() -> Chunk {
|
||||
let mut chunk = Chunk::default();
|
||||
chunk.set(0, 0, 0, BlockId(1));
|
||||
chunk.set(1, 2, 3, BlockId(2));
|
||||
chunk
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materialize_reproduces_diffed_chunk() {
|
||||
let base = baseline();
|
||||
let mut current = base.clone();
|
||||
current.set(5, 6, 7, BlockId(9));
|
||||
current.set(0, 0, 0, BlockId(3));
|
||||
|
||||
let data = ChunkData::from_diff(ChunkPos::new(0, 0, 0), 0, &base, ¤t);
|
||||
let restored = data.materialize(&base);
|
||||
|
||||
assert_eq!(restored.blocks, current.blocks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_chunk_diffs_to_nothing() {
|
||||
let base = baseline();
|
||||
let data = ChunkData::from_diff(ChunkPos::new(0, 0, 0), 0, &base, &base.clone());
|
||||
assert!(data.is_unmodified());
|
||||
assert_eq!(data.edits.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverted_edit_leaves_no_entry() {
|
||||
let base = baseline();
|
||||
let mut current = base.clone();
|
||||
// Change a voxel and then change it straight back to its baseline value.
|
||||
current.set(4, 4, 4, BlockId(7));
|
||||
current.set(4, 4, 4, base.get(4, 4, 4));
|
||||
|
||||
let data = ChunkData::from_diff(ChunkPos::new(0, 0, 0), 0, &base, ¤t);
|
||||
assert!(data.is_unmodified());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_stores_only_changed_voxels() {
|
||||
let base = baseline();
|
||||
let mut current = base.clone();
|
||||
current.set(1, 1, 1, BlockId(4));
|
||||
current.set(2, 2, 2, BlockId(5));
|
||||
current.set(3, 3, 3, BlockId(6));
|
||||
|
||||
let data = ChunkData::from_diff(ChunkPos::new(0, 0, 0), 0, &base, ¤t);
|
||||
assert_eq!(data.edits.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_does_not_reconcile_against_baseline() {
|
||||
// `set` is deliberately dumb: writing a baseline-equal value still records an entry.
|
||||
let mut data = ChunkData::new(ChunkPos::new(0, 0, 0), 0);
|
||||
data.set(42, BlockId::AIR);
|
||||
assert_eq!(data.edits.len(), 1);
|
||||
assert!(!data.is_unmodified());
|
||||
}
|
||||
25
crates/shared/src/tests/coords.rs
Normal file
25
crates/shared/src/tests/coords.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn from_world_maps_positive_positions() {
|
||||
// A block at 40 falls in chunk 1 (chunk 1 spans blocks 32..=63).
|
||||
assert_eq!(ChunkPos::from_world(40.0, 0.0, 0.0).x, 1);
|
||||
// The last block of chunk 0 (block 31) stays in chunk 0.
|
||||
assert_eq!(ChunkPos::from_world(31.0, 0.0, 0.0).x, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_world_floors_negative_positions() {
|
||||
// Block -1 belongs to chunk -1, not chunk 0: this is the div_euclid contract.
|
||||
assert_eq!(ChunkPos::from_world(-1.0, 0.0, 0.0).x, -1);
|
||||
// Block -33 belongs to chunk -2 (chunk -2 spans blocks -64..=-33).
|
||||
assert_eq!(ChunkPos::from_world(-33.0, 0.0, 0.0).x, -2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_world_floors_fractional_positions() {
|
||||
// A position of -0.5 lies inside block -1, which is in chunk -1.
|
||||
assert_eq!(ChunkPos::from_world(-0.5, 0.0, 0.0).x, -1);
|
||||
}
|
||||
50
crates/shared/src/tests/entity.rs
Normal file
50
crates/shared/src/tests/entity.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
use super::*;
|
||||
|
||||
#[expect(
|
||||
clippy::cast_precision_loss,
|
||||
reason = "CHUNK_SIZE is 32, exactly representable as f32"
|
||||
)]
|
||||
const CHUNK_SIZE_F: f32 = CHUNK_SIZE as f32;
|
||||
|
||||
#[test]
|
||||
fn renormalize_leaves_in_range_offsets_untouched() {
|
||||
// A local offset already inside [0, CHUNK_SIZE) must not move the anchor.
|
||||
let mut pos = EntityPos::new(ChunkPos::new(1, 2, 3), Vec3::new(5.0, 10.0, 15.0));
|
||||
pos.renormalize();
|
||||
assert_eq!(pos.chunk, ChunkPos::new(1, 2, 3));
|
||||
assert!(pos.local.abs_diff_eq(Vec3::new(5.0, 10.0, 15.0), 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renormalize_carries_positive_overflow() {
|
||||
// One block past the chunk's far edge lands in the next chunk at local 1.0.
|
||||
let over = CHUNK_SIZE_F + 1.0;
|
||||
let mut pos = EntityPos::new(ChunkPos::new(0, 0, 0), Vec3::new(over, 0.0, 0.0));
|
||||
pos.renormalize();
|
||||
assert_eq!(pos.chunk, ChunkPos::new(1, 0, 0));
|
||||
assert!(pos.local.abs_diff_eq(Vec3::new(1.0, 0.0, 0.0), 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renormalize_borrows_on_negative_offset() {
|
||||
// The div_euclid analogue: -0.5 must borrow a chunk, not clamp to zero.
|
||||
let mut pos = EntityPos::new(ChunkPos::new(0, 0, 0), Vec3::new(-0.5, 0.0, 0.0));
|
||||
pos.renormalize();
|
||||
assert_eq!(pos.chunk, ChunkPos::new(-1, 0, 0));
|
||||
assert!(
|
||||
pos.local
|
||||
.abs_diff_eq(Vec3::new(CHUNK_SIZE_F - 0.5, 0.0, 0.0), 1e-6)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renormalize_carries_multiple_chunks() {
|
||||
// A large offset carries more than one chunk in a single call.
|
||||
let far = CHUNK_SIZE_F * 2.0 + 6.0;
|
||||
let mut pos = EntityPos::new(ChunkPos::new(0, 0, 0), Vec3::new(far, 0.0, 0.0));
|
||||
pos.renormalize();
|
||||
assert_eq!(pos.chunk, ChunkPos::new(2, 0, 0));
|
||||
assert!(pos.local.abs_diff_eq(Vec3::new(6.0, 0.0, 0.0), 1e-6));
|
||||
}
|
||||
87
crates/shared/src/tests/protocol.rs
Normal file
87
crates/shared/src/tests/protocol.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stream_layout_default_is_canonical() {
|
||||
let layout = StreamLayout::default();
|
||||
assert_eq!(layout.control, 0);
|
||||
assert_eq!(layout.input, 1);
|
||||
assert_eq!(layout.authority, 2);
|
||||
assert_eq!(layout.chunk_lod0, 3);
|
||||
assert_eq!(layout.chunk_lod1, 4);
|
||||
assert_eq!(layout.chunk_lod2, 5);
|
||||
assert_eq!(layout.chunk_lod3, 6);
|
||||
assert_eq!(layout.chunk_lod4, 7);
|
||||
assert_eq!(layout.asset, 8);
|
||||
assert_eq!(layout.mod_data, 9);
|
||||
}
|
||||
|
||||
fn roundtrip_test(msg: &ControlMessage) -> Result<(), postcard::Error> {
|
||||
let bytes = postcard::to_stdvec(msg)?;
|
||||
let decoded: ControlMessage = postcard::from_bytes(&bytes)?;
|
||||
assert_eq!(msg, &decoded);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_client_hello() -> Result<(), postcard::Error> {
|
||||
let msg = ControlMessage::ClientHello(ClientHello {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
client_build: "synvael-client-0.1.0".to_string(),
|
||||
player_identity: PlayerIdentity {
|
||||
display_name: "Player1".to_string(),
|
||||
},
|
||||
installed_packs: vec![PackRef {
|
||||
id: "core:base".to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
content_hash: [0; 32],
|
||||
tier: PackTier::Data,
|
||||
}],
|
||||
requested_features: FeatureFlags(0),
|
||||
});
|
||||
roundtrip_test(&msg)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_handshake_ack() -> Result<(), postcard::Error> {
|
||||
let msg = ControlMessage::HandshakeAck(HandshakeAck {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
server_build: "synvael-server-0.1.0".to_string(),
|
||||
world_packs: vec![],
|
||||
missing_packs: vec![],
|
||||
stream_layout: StreamLayout::default(),
|
||||
tick_rate_hint: 20,
|
||||
});
|
||||
roundtrip_test(&msg)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_handshake_reject() -> Result<(), postcard::Error> {
|
||||
let msg = ControlMessage::HandshakeReject(HandshakeReject {
|
||||
reason: RejectReason::ProtocolMismatch,
|
||||
detail: "Expected v1, got v2".to_string(),
|
||||
upgrade_url: Some("https://synvael.example/download".to_string()),
|
||||
});
|
||||
roundtrip_test(&msg)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_tier_match_rules() {
|
||||
// Resource packs are client-side overlays: a mismatch is always tolerated.
|
||||
assert!(!PackTier::Resource.requires_strict_match());
|
||||
// Data packs affect authoritative content and must match exactly.
|
||||
assert!(PackTier::Data.requires_strict_match());
|
||||
// A client-only mod cannot desync server state, so a mismatch is tolerated.
|
||||
assert!(!PackTier::Mod { client_only: true }.requires_strict_match());
|
||||
// A server-affecting mod must match exactly.
|
||||
assert!(PackTier::Mod { client_only: false }.requires_strict_match());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_disconnect() -> Result<(), postcard::Error> {
|
||||
let msg = ControlMessage::Disconnect(Disconnect {
|
||||
reason: "Server closing".to_string(),
|
||||
});
|
||||
roundtrip_test(&msg)
|
||||
}
|
||||
70
crates/shared/src/tests/record.rs
Normal file
70
crates/shared/src/tests/record.rs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
use super::*;
|
||||
use crate::world::{BlockId, ChunkPos};
|
||||
|
||||
/// Builds a representative modified chunk with a few edits spanning the local index range.
|
||||
fn sample() -> ChunkData {
|
||||
let mut data = ChunkData::new(ChunkPos::new(1, -2, 3), 7);
|
||||
data.set(0, BlockId(4));
|
||||
data.set(1000, BlockId(9));
|
||||
data.set(32_767, BlockId(2));
|
||||
data
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_payload_and_metadata() -> Result<(), SaveError> {
|
||||
let data = sample();
|
||||
let bytes = encode(&data, 123_456)?;
|
||||
let (meta, decoded) = decode(&bytes)?;
|
||||
|
||||
assert_eq!(decoded, data);
|
||||
assert_eq!(meta.chunk_format_version, CHUNK_FORMAT_VERSION);
|
||||
assert_eq!(meta.flags, 0);
|
||||
assert_eq!(meta.last_modified, 123_456);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_magic() -> Result<(), SaveError> {
|
||||
let mut bytes = encode(&sample(), 0)?;
|
||||
bytes[0] = b'X';
|
||||
assert!(matches!(decode(&bytes), Err(SaveError::BadMagic { .. })));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncated_header() -> Result<(), SaveError> {
|
||||
let bytes = encode(&sample(), 0)?;
|
||||
// A buffer shorter than the fixed header cannot yield a full record.
|
||||
assert!(matches!(
|
||||
decode(&bytes[..HEADER_LEN - 1]),
|
||||
Err(SaveError::Truncated { .. })
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncated_payload() -> Result<(), SaveError> {
|
||||
let bytes = encode(&sample(), 0)?;
|
||||
// Keep the whole header but cut the compressed payload short.
|
||||
assert!(matches!(
|
||||
decode(&bytes[..=HEADER_LEN]),
|
||||
Err(SaveError::Truncated { .. })
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_declared_length_mismatch() -> Result<(), SaveError> {
|
||||
let mut bytes = encode(&sample(), 0)?;
|
||||
// The uncompressed-length field is the u32 at offset 16 (after magic, version,
|
||||
// flags, and the timestamp). Overwriting it with a value the payload cannot
|
||||
// decompress to must be caught by the post-decompression length check.
|
||||
bytes[16..20].copy_from_slice(&1u32.to_le_bytes());
|
||||
assert!(matches!(
|
||||
decode(&bytes),
|
||||
Err(SaveError::LengthMismatch { .. })
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
94
crates/shared/src/tests/region.rs
Normal file
94
crates/shared/src/tests/region.rs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Builds a populated index with entries at varied positions, a free span, and a stamp exception.
|
||||
fn sample() -> RegionIndex {
|
||||
let mut index = RegionIndex::new(3);
|
||||
index.insert(
|
||||
ChunkPos::new(0, 0, 0),
|
||||
HeaderEntry {
|
||||
offset: 4096,
|
||||
length: 128,
|
||||
flags: 0,
|
||||
},
|
||||
);
|
||||
index.insert(
|
||||
ChunkPos::new(-5, 12, -30),
|
||||
HeaderEntry {
|
||||
offset: 8192,
|
||||
length: 256,
|
||||
flags: 0,
|
||||
},
|
||||
);
|
||||
index.push_free(FreeSpan {
|
||||
offset: 512,
|
||||
length: 64,
|
||||
});
|
||||
index.set_worldgen_version(ChunkPos::new(0, 0, 0), 2);
|
||||
index.bump_tile_version();
|
||||
index
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_index() -> Result<(), SaveError> {
|
||||
let index = sample();
|
||||
let decoded = RegionIndex::decode(&index.encode()?)?;
|
||||
assert_eq!(decoded, index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_worldgen_versions_above_u16_max() -> Result<(), SaveError> {
|
||||
// Regression guard: base and stamp worldgen versions are u32, so a value that would not
|
||||
// fit a u16 must survive encode -> decode without truncation.
|
||||
let mut index = RegionIndex::new(70_000);
|
||||
index.set_worldgen_version(ChunkPos::new(0, 0, 0), 100_000);
|
||||
let decoded = RegionIndex::decode(&index.encode()?)?;
|
||||
assert_eq!(decoded.base_worldgen_version(), 70_000);
|
||||
assert_eq!(decoded.worldgen_version(ChunkPos::new(0, 0, 0)), 100_000);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stamp_equal_to_base_is_not_recorded() {
|
||||
let mut index = RegionIndex::new(7);
|
||||
// A stamp equal to the base is redundant, so no exception entry is stored.
|
||||
index.set_worldgen_version(ChunkPos::new(1, 1, 1), 7);
|
||||
assert_eq!(index.worldgen_version(ChunkPos::new(1, 1, 1)), 7);
|
||||
assert!(index.stamps.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_magic() -> Result<(), SaveError> {
|
||||
let mut bytes = sample().encode()?;
|
||||
bytes[0] = b'X';
|
||||
assert!(matches!(
|
||||
RegionIndex::decode(&bytes),
|
||||
Err(SaveError::BadMagic { .. })
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_version() -> Result<(), SaveError> {
|
||||
let mut bytes = sample().encode()?;
|
||||
// The format_version u32 sits just after the 4 magic bytes.
|
||||
bytes[4..8].copy_from_slice(&999u32.to_le_bytes());
|
||||
assert!(matches!(
|
||||
RegionIndex::decode(&bytes),
|
||||
Err(SaveError::UnsupportedVersion { .. })
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_truncated_table() -> Result<(), SaveError> {
|
||||
let bytes = sample().encode()?;
|
||||
// Cut the buffer mid-header-table so an entry read runs off the end.
|
||||
assert!(matches!(
|
||||
RegionIndex::decode(&bytes[..20]),
|
||||
Err(SaveError::Truncated { .. })
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -153,72 +153,5 @@ impl PalettedChunk {
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Builds a chunk whose voxels cycle through `distinct` material ids, guaranteeing exactly `distinct` distinct materials and therefore a palette of that size.
|
||||
fn chunk_cycling(distinct: usize) -> Chunk {
|
||||
let mut chunk = Chunk::default();
|
||||
for (i, block) in chunk.blocks.iter_mut().enumerate() {
|
||||
// `distinct` is a small test constant, so the modulo result always fits in a u16.
|
||||
#[expect(
|
||||
clippy::cast_possible_truncation,
|
||||
reason = "distinct is a small test constant within u16 range"
|
||||
)]
|
||||
let id = (i % distinct) as u16;
|
||||
*block = BlockId(id);
|
||||
}
|
||||
chunk
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bit_width_matches_palette_size() {
|
||||
// The 4->5 (2->3 bit) and 8->9 (3->4 bit) transitions are the boundaries where packing bugs hide.
|
||||
assert_eq!(PalettedChunk::bits_for_palette(1), 1);
|
||||
assert_eq!(PalettedChunk::bits_for_palette(2), 1);
|
||||
assert_eq!(PalettedChunk::bits_for_palette(3), 2);
|
||||
assert_eq!(PalettedChunk::bits_for_palette(4), 2);
|
||||
assert_eq!(PalettedChunk::bits_for_palette(5), 3);
|
||||
assert_eq!(PalettedChunk::bits_for_palette(8), 3);
|
||||
assert_eq!(PalettedChunk::bits_for_palette(9), 4);
|
||||
assert_eq!(PalettedChunk::bits_for_palette(16), 4);
|
||||
assert_eq!(PalettedChunk::bits_for_palette(17), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_preserves_all_voxels() {
|
||||
// Sizes span every bit-width boundary through five bits, including the all-air case (distinct = 1).
|
||||
for distinct in [1usize, 2, 3, 4, 5, 8, 9, 16, 17] {
|
||||
let original = chunk_cycling(distinct);
|
||||
let paletted = PalettedChunk::from_chunk(&original);
|
||||
assert_eq!(
|
||||
paletted.palette.len(),
|
||||
distinct,
|
||||
"palette must hold exactly the distinct materials for {distinct}"
|
||||
);
|
||||
let restored = paletted.to_chunk();
|
||||
assert_eq!(
|
||||
original.blocks, restored.blocks,
|
||||
"round trip must preserve every voxel for {distinct} materials"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_air_chunk_has_single_entry_palette() {
|
||||
let paletted = PalettedChunk::from_chunk(&Chunk::default());
|
||||
assert_eq!(paletted.palette, vec![BlockId::AIR]);
|
||||
assert_eq!(paletted.bits_per_index, 1);
|
||||
assert_eq!(paletted.to_chunk().blocks, Chunk::default().blocks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_index_straddling_word_boundary() {
|
||||
// With a 3-bit palette, voxel 21 begins at bit 63 and spills into the next 64-bit word; a distinctive value there pins the straddle handling.
|
||||
let mut original = chunk_cycling(5);
|
||||
original.blocks[21] = BlockId(4);
|
||||
let restored = PalettedChunk::from_chunk(&original).to_chunk();
|
||||
assert_eq!(restored.blocks[21], BlockId(4));
|
||||
assert_eq!(original.blocks, restored.blocks);
|
||||
}
|
||||
}
|
||||
#[path = "../tests/chunk.rs"]
|
||||
mod tests;
|
||||
|
|
|
|||
|
|
@ -89,68 +89,5 @@ impl ChunkData {
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A baseline chunk with a recognisable, non-uniform fill so edits are distinguishable from it.
|
||||
fn baseline() -> Chunk {
|
||||
let mut chunk = Chunk::default();
|
||||
chunk.set(0, 0, 0, BlockId(1));
|
||||
chunk.set(1, 2, 3, BlockId(2));
|
||||
chunk
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materialize_reproduces_diffed_chunk() {
|
||||
let base = baseline();
|
||||
let mut current = base.clone();
|
||||
current.set(5, 6, 7, BlockId(9));
|
||||
current.set(0, 0, 0, BlockId(3));
|
||||
|
||||
let data = ChunkData::from_diff(ChunkPos::new(0, 0, 0), 0, &base, ¤t);
|
||||
let restored = data.materialize(&base);
|
||||
|
||||
assert_eq!(restored.blocks, current.blocks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_chunk_diffs_to_nothing() {
|
||||
let base = baseline();
|
||||
let data = ChunkData::from_diff(ChunkPos::new(0, 0, 0), 0, &base, &base.clone());
|
||||
assert!(data.is_unmodified());
|
||||
assert_eq!(data.edits.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverted_edit_leaves_no_entry() {
|
||||
let base = baseline();
|
||||
let mut current = base.clone();
|
||||
// Change a voxel and then change it straight back to its baseline value.
|
||||
current.set(4, 4, 4, BlockId(7));
|
||||
current.set(4, 4, 4, base.get(4, 4, 4));
|
||||
|
||||
let data = ChunkData::from_diff(ChunkPos::new(0, 0, 0), 0, &base, ¤t);
|
||||
assert!(data.is_unmodified());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_stores_only_changed_voxels() {
|
||||
let base = baseline();
|
||||
let mut current = base.clone();
|
||||
current.set(1, 1, 1, BlockId(4));
|
||||
current.set(2, 2, 2, BlockId(5));
|
||||
current.set(3, 3, 3, BlockId(6));
|
||||
|
||||
let data = ChunkData::from_diff(ChunkPos::new(0, 0, 0), 0, &base, ¤t);
|
||||
assert_eq!(data.edits.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_does_not_reconcile_against_baseline() {
|
||||
// `set` is deliberately dumb: writing a baseline-equal value still records an entry.
|
||||
let mut data = ChunkData::new(ChunkPos::new(0, 0, 0), 0);
|
||||
data.set(42, BlockId::AIR);
|
||||
assert_eq!(data.edits.len(), 1);
|
||||
assert!(!data.is_unmodified());
|
||||
}
|
||||
}
|
||||
#[path = "../tests/chunk_data.rs"]
|
||||
mod tests;
|
||||
|
|
|
|||
|
|
@ -40,28 +40,5 @@ impl ChunkPos {
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn from_world_maps_positive_positions() {
|
||||
// A block at 40 falls in chunk 1 (chunk 1 spans blocks 32..=63).
|
||||
assert_eq!(ChunkPos::from_world(40.0, 0.0, 0.0).x, 1);
|
||||
// The last block of chunk 0 (block 31) stays in chunk 0.
|
||||
assert_eq!(ChunkPos::from_world(31.0, 0.0, 0.0).x, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_world_floors_negative_positions() {
|
||||
// Block -1 belongs to chunk -1, not chunk 0: this is the div_euclid contract.
|
||||
assert_eq!(ChunkPos::from_world(-1.0, 0.0, 0.0).x, -1);
|
||||
// Block -33 belongs to chunk -2 (chunk -2 spans blocks -64..=-33).
|
||||
assert_eq!(ChunkPos::from_world(-33.0, 0.0, 0.0).x, -2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_world_floors_fractional_positions() {
|
||||
// A position of -0.5 lies inside block -1, which is in chunk -1.
|
||||
assert_eq!(ChunkPos::from_world(-0.5, 0.0, 0.0).x, -1);
|
||||
}
|
||||
}
|
||||
#[path = "../tests/coords.rs"]
|
||||
mod tests;
|
||||
|
|
|
|||
|
|
@ -44,53 +44,5 @@ impl EntityPos {
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[expect(
|
||||
clippy::cast_precision_loss,
|
||||
reason = "CHUNK_SIZE is 32, exactly representable as f32"
|
||||
)]
|
||||
const CHUNK_SIZE_F: f32 = CHUNK_SIZE as f32;
|
||||
|
||||
#[test]
|
||||
fn renormalize_leaves_in_range_offsets_untouched() {
|
||||
// A local offset already inside [0, CHUNK_SIZE) must not move the anchor.
|
||||
let mut pos = EntityPos::new(ChunkPos::new(1, 2, 3), Vec3::new(5.0, 10.0, 15.0));
|
||||
pos.renormalize();
|
||||
assert_eq!(pos.chunk, ChunkPos::new(1, 2, 3));
|
||||
assert!(pos.local.abs_diff_eq(Vec3::new(5.0, 10.0, 15.0), 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renormalize_carries_positive_overflow() {
|
||||
// One block past the chunk's far edge lands in the next chunk at local 1.0.
|
||||
let over = CHUNK_SIZE_F + 1.0;
|
||||
let mut pos = EntityPos::new(ChunkPos::new(0, 0, 0), Vec3::new(over, 0.0, 0.0));
|
||||
pos.renormalize();
|
||||
assert_eq!(pos.chunk, ChunkPos::new(1, 0, 0));
|
||||
assert!(pos.local.abs_diff_eq(Vec3::new(1.0, 0.0, 0.0), 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renormalize_borrows_on_negative_offset() {
|
||||
// The div_euclid analogue: -0.5 must borrow a chunk, not clamp to zero.
|
||||
let mut pos = EntityPos::new(ChunkPos::new(0, 0, 0), Vec3::new(-0.5, 0.0, 0.0));
|
||||
pos.renormalize();
|
||||
assert_eq!(pos.chunk, ChunkPos::new(-1, 0, 0));
|
||||
assert!(
|
||||
pos.local
|
||||
.abs_diff_eq(Vec3::new(CHUNK_SIZE_F - 0.5, 0.0, 0.0), 1e-6)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renormalize_carries_multiple_chunks() {
|
||||
// A large offset carries more than one chunk in a single call.
|
||||
let far = CHUNK_SIZE_F * 2.0 + 6.0;
|
||||
let mut pos = EntityPos::new(ChunkPos::new(0, 0, 0), Vec3::new(far, 0.0, 0.0));
|
||||
pos.renormalize();
|
||||
assert_eq!(pos.chunk, ChunkPos::new(2, 0, 0));
|
||||
assert!(pos.local.abs_diff_eq(Vec3::new(6.0, 0.0, 0.0), 1e-6));
|
||||
}
|
||||
}
|
||||
#[path = "../tests/entity.rs"]
|
||||
mod tests;
|
||||
|
|
|
|||
Loading…
Reference in a new issue