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