From 22724ee51cacbb21160ab6d885e59a4fa0b5ca32 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 24 Feb 2026 12:15:29 +0100 Subject: [PATCH] test(simulation): protocol versioning tests (#232) Version field round-trip, mismatch detection, serde_default migration pattern, all TellCategory and VerbKind variant coverage. Co-Authored-By: Claude Opus 4.6 --- server/tests/serialization.rs | 223 ++++++++++++++++++++++++++++++++++ 1 file changed, 223 insertions(+) diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index e3bdb38e7..48af4ebdb 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -1364,6 +1364,229 @@ fn v10_payload_deserializes_into_v11_struct() { ); } +// --------------------------------------------------------------------------- +// #232: Protocol versioning scheme tests +// --------------------------------------------------------------------------- + +/// A snapshot serialized without newer optional fields (simulating an older server) +/// must deserialize with serde defaults — core migration pattern (#232). +/// +/// Strategy: construct JSON that omits `#[serde(default)]` fields, then verify +/// they fill in as their zero/None values on deserialization. +#[test] +fn serde_default_fields_fill_in_when_missing_from_wire() { + // JSON with only the required fields (simulating a minimal old snapshot). + // `tell_state`, `follow_state`, `rng_seed`, `zone_id`, `object_type`, etc. + // are all `#[serde(default)]` — they must default to None/empty when absent. + let minimal_json = serde_json::json!({ + "version": 13, + "tick": 42, + "game_time": { + "day": 0, + "time_of_day": 0, + "day_phase": "Morning", + "tick_rate": "Full" + }, + "player_facing": "North", + "player_stance": "Walk", + "player_inventory": [], + "entities": [{ + "entity_id": 1, + "x": 5.0, + "y": 5.0, + "z": 0, + "kind": "Npc", + "visibility": "Forward", + "relationship": "Unknown", + "observation": "Visible" + // "tell_state" intentionally absent + }], + "visible_tiles": [], + "nearby_interactions": [], + "current_monologue": null, + "pending_recognitions": [], + "dialogue_response": null, + "blocked_entities": [], + "scan_events": [], + "sound_events": [], + "conversation_events": [], + "conversation_ended": [], + "follow_state": null, + "rng_seed": null + }); + + let decoded: ObserverSnapshot = + serde_json::from_value(minimal_json).expect("minimal JSON must deserialize"); + + // Version and required fields present + assert_eq!(decoded.version, PROTOCOL_VERSION); + assert_eq!(decoded.tick, 42); + assert_eq!(decoded.entities.len(), 1); + + // `#[serde(default, skip_serializing_if = "Option::is_none")]` field + // defaults to None when absent from the wire + assert_eq!( + decoded.entities[0].tell_state, None, + "tell_state must default to None when absent from wire" + ); + assert_eq!( + decoded.rng_seed, None, + "rng_seed must default to None when absent from wire" + ); + assert!( + decoded.follow_state.is_none(), + "follow_state must default to None when absent from wire" + ); +} + +/// A snapshot with version != PROTOCOL_VERSION can be detected by checking +/// the version field after deserialization (#232 compatibility checking). +#[test] +fn snapshot_version_mismatch_is_detectable() { + let mut snapshot = test_snapshot(0, vec![]); + let future_version: u8 = PROTOCOL_VERSION + 1; + snapshot.version = future_version; + + let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); + let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize"); + + // The version field faithfully preserves the value — caller detects mismatch + assert_eq!( + decoded.version, future_version, + "version field must survive round-trip unchanged" + ); + assert_ne!( + decoded.version, PROTOCOL_VERSION, + "client should detect this as a version mismatch" + ); +} + +/// tell_state=None is skipped in msgpack serialization (skip_serializing_if). +/// A snapshot with tell_state=None produces fewer bytes than one with +/// tell_state=Some(Nervous) — demonstrates the skip_serializing_if contract. +#[test] +fn tell_state_none_is_omitted_from_wire() { + let entity_no_tell = VisibleEntity { + entity_id: 1, + x: 0.0, + y: 0.0, + z: 0, + kind: EntityKind::Npc, + visibility: VisibilitySector::Forward, + relationship: RelationshipState::Unknown, + observation: EntityVisibility::Visible, + tell_state: None, + }; + let entity_with_tell = VisibleEntity { + tell_state: Some(settled_reach_server::npc::tell_state::TellCategory::Nervous), + ..entity_no_tell.clone() + }; + + let bytes_no_tell = + rmp_serde::to_vec_named(&entity_no_tell).expect("serialize without tell_state"); + let bytes_with_tell = + rmp_serde::to_vec_named(&entity_with_tell).expect("serialize with tell_state"); + + assert!( + bytes_no_tell.len() < bytes_with_tell.len(), + "tell_state=None should produce fewer bytes (skip_serializing_if contract)" + ); +} + +/// All 5 TellCategory variants survive MessagePack round-trip in VisibleEntity. +/// Closing coverage gap for v13 tell_state field (#90, D-024). +#[test] +fn all_tell_category_variants_roundtrip() { + use settled_reach_server::npc::tell_state::TellCategory; + + let categories = [ + TellCategory::Nervous, + TellCategory::Angry, + TellCategory::Friendly, + TellCategory::Guarded, + TellCategory::RoutineDeviation, + ]; + + for category in categories { + let entity = VisibleEntity { + entity_id: 1, + x: 3.0, + y: 4.0, + z: 0, + kind: EntityKind::Npc, + visibility: VisibilitySector::Forward, + relationship: RelationshipState::Unknown, + observation: EntityVisibility::Visible, + tell_state: Some(category), + }; + let bytes = rmp_serde::to_vec_named(&entity).expect("serialize"); + let decoded: VisibleEntity = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert_eq!( + decoded.tell_state, + Some(category), + "TellCategory::{:?} did not survive round-trip", + category + ); + } +} + +/// All VerbKind variants survive MessagePack round-trip in NearbyInteraction (#232). +/// Closes a coverage gap — not all VerbKind variants were previously verified. +#[test] +fn all_verb_kind_variants_roundtrip_v232() { + let all_verbs = [ + VerbKind::ExamineNpc, + VerbKind::Talk, + VerbKind::Observe, + VerbKind::Read, + VerbKind::Open, + VerbKind::Close, + VerbKind::Search, + VerbKind::Use, + VerbKind::Take, + VerbKind::Sit, + VerbKind::Follow, + VerbKind::Confront, + VerbKind::ExamineObject, + ]; + + for kind in all_verbs { + let interaction = NearbyInteraction { + entity_id: 1, + entity_type: EntityKind::Npc, + distance: 1, + verbs: vec![VerbOption { + kind, + label: "Test".into(), + priority: 1, + available: true, + }], + object_type: None, + contradicted: false, + }; + let bytes = rmp_serde::to_vec_named(&interaction).expect("serialize"); + let decoded: NearbyInteraction = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert_eq!( + decoded.verbs[0].kind, kind, + "VerbKind::{:?} did not survive round-trip", + kind + ); + } +} + +/// PROTOCOL_VERSION u8 type fits in one byte — wire overhead is minimal (#232). +/// This guards against accidental widening of the version type. +#[test] +fn protocol_version_fits_in_u8() { + // u8 max is 255 — enough for ~242 more protocol iterations. + // If PROTOCOL_VERSION ever reaches 200, consider migrating to u16. + assert!( + PROTOCOL_VERSION <= 200, + "PROTOCOL_VERSION={} is approaching u8 saturation; consider widening the type", + PROTOCOL_VERSION + ); +} + /// NearbyInteraction.object_type round-trips through MessagePack (#422). /// Verifies object_type=Some(Container) survives the wire. #[test]