diff --git a/crates/net/src/codec.rs b/crates/net/src/codec.rs index 9270a0c..fc26464 100644 --- a/crates/net/src/codec.rs +++ b/crates/net/src/codec.rs @@ -130,121 +130,5 @@ fn check_frame_len(len: u64, max_len: usize) -> Result { } #[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; diff --git a/crates/net/src/endpoint.rs b/crates/net/src/endpoint.rs index 2acc0c6..2ef792b 100644 --- a/crates/net/src/endpoint.rs +++ b/crates/net/src/endpoint.rs @@ -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; diff --git a/crates/net/src/error.rs b/crates/net/src/error.rs index 00e2e4e..0af78cc 100644 --- a/crates/net/src/error.rs +++ b/crates/net/src/error.rs @@ -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, + }, +} diff --git a/crates/net/src/handshake.rs b/crates/net/src/handshake.rs index cbc35b0..5f871fb 100644 --- a/crates/net/src/handshake.rs +++ b/crates/net/src/handshake.rs @@ -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 { diff --git a/crates/net/src/lib.rs b/crates/net/src/lib.rs index 4cea45d..594e421 100644 --- a/crates/net/src/lib.rs +++ b/crates/net/src/lib.rs @@ -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; diff --git a/crates/net/src/tests/codec.rs b/crates/net/src/tests/codec.rs new file mode 100644 index 0000000..1d31fb7 --- /dev/null +++ b/crates/net/src/tests/codec.rs @@ -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", + ); +} diff --git a/crates/net/src/tests/endpoint.rs b/crates/net/src/tests/endpoint.rs new file mode 100644 index 0000000..0f50b09 --- /dev/null +++ b/crates/net/src/tests/endpoint.rs @@ -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(()) +} diff --git a/crates/net/tests/handshake.rs b/crates/net/src/tests/handshake.rs similarity index 96% rename from crates/net/tests/handshake.rs rename to crates/net/src/tests/handshake.rs index 866778c..b4f10aa 100644 --- a/crates/net/tests/handshake.rs +++ b/crates/net/src/tests/handshake.rs @@ -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`. diff --git a/crates/scripting/src/lib.rs b/crates/scripting/src/lib.rs index 16e87cc..7114121 100644 --- a/crates/scripting/src/lib.rs +++ b/crates/scripting/src/lib.rs @@ -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; diff --git a/crates/scripting/src/tests/lib.rs b/crates/scripting/src/tests/lib.rs new file mode 100644 index 0000000..9aadc1b --- /dev/null +++ b/crates/scripting/src/tests/lib.rs @@ -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); +} diff --git a/crates/server/src/chunk_cache.rs b/crates/server/src/chunk_cache.rs index a4b191a..89d752e 100644 --- a/crates/server/src/chunk_cache.rs +++ b/crates/server/src/chunk_cache.rs @@ -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; diff --git a/crates/server/src/save/region_file.rs b/crates/server/src/save/region_file.rs index 87b45a4..49ab57d 100644 --- a/crates/server/src/save/region_file.rs +++ b/crates/server/src/save/region_file.rs @@ -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; diff --git a/crates/server/src/tests/chunk_cache.rs b/crates/server/src/tests/chunk_cache.rs new file mode 100644 index 0000000..f07f796 --- /dev/null +++ b/crates/server/src/tests/chunk_cache.rs @@ -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))); +} diff --git a/crates/server/src/tests/region_file.rs b/crates/server/src/tests/region_file.rs new file mode 100644 index 0000000..439cfb7 --- /dev/null +++ b/crates/server/src/tests/region_file.rs @@ -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(()) +} diff --git a/crates/server/src/tests/world_server.rs b/crates/server/src/tests/world_server.rs new file mode 100644 index 0000000..89650d4 --- /dev/null +++ b/crates/server/src/tests/world_server.rs @@ -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) -> 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()); +} diff --git a/crates/server/src/world_server.rs b/crates/server/src/world_server.rs index 4cff871..3d4738b 100644 --- a/crates/server/src/world_server.rs +++ b/crates/server/src/world_server.rs @@ -274,216 +274,5 @@ pub fn cylinder_chunks( } #[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) -> 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; diff --git a/crates/shared/src/protocol.rs b/crates/shared/src/protocol.rs index 570e998..a2b25b2 100644 --- a/crates/shared/src/protocol.rs +++ b/crates/shared/src/protocol.rs @@ -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; diff --git a/crates/shared/src/save/record.rs b/crates/shared/src/save/record.rs index aa13e49..78503de 100644 --- a/crates/shared/src/save/record.rs +++ b/crates/shared/src/save/record.rs @@ -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; diff --git a/crates/shared/src/save/region.rs b/crates/shared/src/save/region.rs index 1c64fa6..b670bd2 100644 --- a/crates/shared/src/save/region.rs +++ b/crates/shared/src/save/region.rs @@ -261,97 +261,5 @@ fn len_u32(len: usize) -> Result { } #[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; diff --git a/crates/shared/src/tests/chunk.rs b/crates/shared/src/tests/chunk.rs new file mode 100644 index 0000000..e73a244 --- /dev/null +++ b/crates/shared/src/tests/chunk.rs @@ -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); +} diff --git a/crates/shared/src/tests/chunk_data.rs b/crates/shared/src/tests/chunk_data.rs new file mode 100644 index 0000000..0ba7137 --- /dev/null +++ b/crates/shared/src/tests/chunk_data.rs @@ -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()); +} diff --git a/crates/shared/src/tests/coords.rs b/crates/shared/src/tests/coords.rs new file mode 100644 index 0000000..0378989 --- /dev/null +++ b/crates/shared/src/tests/coords.rs @@ -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); +} diff --git a/crates/shared/src/tests/entity.rs b/crates/shared/src/tests/entity.rs new file mode 100644 index 0000000..f940f34 --- /dev/null +++ b/crates/shared/src/tests/entity.rs @@ -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)); +} diff --git a/crates/shared/src/tests/protocol.rs b/crates/shared/src/tests/protocol.rs new file mode 100644 index 0000000..dee3794 --- /dev/null +++ b/crates/shared/src/tests/protocol.rs @@ -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) +} diff --git a/crates/shared/src/tests/record.rs b/crates/shared/src/tests/record.rs new file mode 100644 index 0000000..afbee95 --- /dev/null +++ b/crates/shared/src/tests/record.rs @@ -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(()) +} diff --git a/crates/shared/src/tests/region.rs b/crates/shared/src/tests/region.rs new file mode 100644 index 0000000..2873163 --- /dev/null +++ b/crates/shared/src/tests/region.rs @@ -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(()) +} diff --git a/crates/shared/src/world/chunk.rs b/crates/shared/src/world/chunk.rs index fb4385d..f62b328 100644 --- a/crates/shared/src/world/chunk.rs +++ b/crates/shared/src/world/chunk.rs @@ -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; diff --git a/crates/shared/src/world/chunk_data.rs b/crates/shared/src/world/chunk_data.rs index 6a23157..3941e42 100644 --- a/crates/shared/src/world/chunk_data.rs +++ b/crates/shared/src/world/chunk_data.rs @@ -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; diff --git a/crates/shared/src/world/coords.rs b/crates/shared/src/world/coords.rs index fa00270..6bac1f3 100644 --- a/crates/shared/src/world/coords.rs +++ b/crates/shared/src/world/coords.rs @@ -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; diff --git a/crates/shared/src/world/entity.rs b/crates/shared/src/world/entity.rs index f9597b9..8c0c4a2 100644 --- a/crates/shared/src/world/entity.rs +++ b/crates/shared/src/world/entity.rs @@ -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;