test(workspace): relocate all tests into per-crate src/tests via #[path]

This commit is contained in:
Serkyo 2026-07-14 00:16:15 +02:00
parent 8e7cd15085
commit 7b3908dd7f
30 changed files with 1072 additions and 1042 deletions

View file

@ -130,121 +130,5 @@ fn check_frame_len(len: u64, max_len: usize) -> Result<usize, NetError> {
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "tests/codec.rs"]
use super::*; mod tests;
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",
);
}
}

View file

@ -121,27 +121,5 @@ impl ServerCertVerifier for AcceptAnyServerCert {
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "tests/endpoint.rs"]
use super::*; mod tests;
#[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(())
}
}

View file

@ -43,3 +43,31 @@ pub enum NetError {
#[error("no initial cipher suite for quic: {0}")] #[error("no initial cipher suite for quic: {0}")]
NoInitialCipherSuite(#[from] quinn::crypto::rustls::NoInitialCipherSuite), 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,
},
}

View file

@ -11,11 +11,10 @@ use shared::protocol::{
ClientHello, ControlMessage, Disconnect, HandshakeAck, HandshakeReject, PROTOCOL_VERSION, ClientHello, ControlMessage, Disconnect, HandshakeAck, HandshakeReject, PROTOCOL_VERSION,
RejectReason, StreamLayout, RejectReason, StreamLayout,
}; };
use thiserror::Error;
use tracing::{info, warn}; use tracing::{info, warn};
use crate::codec::{MAX_CONTROL_FRAME_LEN, read_frame, write_frame}; 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. /// Application close code used when a peer is rejected during the handshake.
const CLOSE_CODE_REJECTED: u32 = 1; 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. /// 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); 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. /// A completed client-side handshake: an established connection, the retained control stream, and the server's acceptance parameters.
#[derive(Debug)] #[derive(Debug)]
pub struct Connected { pub struct Connected {

View file

@ -11,3 +11,7 @@ pub mod endpoint;
pub mod error; pub mod error;
pub mod handshake; pub mod handshake;
pub mod runtime; pub mod runtime;
#[cfg(test)]
#[path = "tests/handshake.rs"]
mod handshake_tests;

View 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",
);
}

View 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(())
}

View file

@ -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. //! 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 crate::endpoint::{client_endpoint, server_endpoint};
use net::handshake::{HandshakeError, accept_connection, connect}; use crate::error::HandshakeError;
use crate::handshake::{accept_connection, connect};
use shared::protocol::{ClientHello, FeatureFlags, PROTOCOL_VERSION, PlayerIdentity, RejectReason}; use shared::protocol::{ClientHello, FeatureFlags, PROTOCOL_VERSION, PlayerIdentity, RejectReason};
/// Builds a `ClientHello` for `display_name` advertising `protocol_version`. /// Builds a `ClientHello` for `display_name` advertising `protocol_version`.

View file

@ -13,12 +13,5 @@ pub fn add(left: u64, right: u64) -> u64 {
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "tests/lib.rs"]
use super::*; mod tests;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}

View 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);
}

View file

@ -62,53 +62,5 @@ impl ChunkCache {
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "tests/chunk_cache.rs"]
use super::*; mod tests;
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)));
}
}

View file

@ -241,139 +241,5 @@ fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), SaveError> {
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "../tests/region_file.rs"]
use super::*; mod tests;
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(())
}
}

View 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)));
}

View 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(())
}

View 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());
}

View file

@ -274,216 +274,5 @@ pub fn cylinder_chunks<S: std::hash::BuildHasher>(
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "tests/world_server.rs"]
use super::*; mod tests;
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());
}
}

View file

@ -194,90 +194,5 @@ impl Default for StreamLayout {
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "tests/protocol.rs"]
use super::*; mod tests;
#[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)
}
}

View file

@ -106,73 +106,5 @@ pub fn decode(bytes: &[u8]) -> Result<(RecordMeta, ChunkData), SaveError> {
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "../tests/record.rs"]
use super::*; mod tests;
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(())
}
}

View file

@ -261,97 +261,5 @@ fn len_u32(len: usize) -> Result<u32, SaveError> {
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "../tests/region.rs"]
use super::*; mod tests;
/// 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(())
}
}

View 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);
}

View 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, &current);
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, &current);
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, &current);
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());
}

View 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);
}

View 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));
}

View 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)
}

View 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(())
}

View 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(())
}

View file

@ -153,72 +153,5 @@ impl PalettedChunk {
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "../tests/chunk.rs"]
use super::*; mod tests;
/// 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);
}
}

View file

@ -89,68 +89,5 @@ impl ChunkData {
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "../tests/chunk_data.rs"]
use super::*; mod tests;
/// 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, &current);
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, &current);
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, &current);
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());
}
}

View file

@ -40,28 +40,5 @@ impl ChunkPos {
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "../tests/coords.rs"]
use super::*; mod tests;
#[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);
}
}

View file

@ -44,53 +44,5 @@ impl EntityPos {
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "../tests/entity.rs"]
use super::*; mod tests;
#[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));
}
}