Protocol handshake (#555): HandshakeMessage as first IPC frame, HandshakeState resource, forward-compatible input handling. State serialization (#96): serialize_npc_to_frozen/deserialize with full D-024 axis coverage (10 new optional fields on NpcSaveState). Scope tags (#98): ScopeTagKind enum, ScopePinned marker, automatic assignment from KnowledgeGraph and RelationshipGraph. Timestamp eviction (#97): LastInteractionTick, SimSpacePressure, BinaryHeap LRU eviction respecting ScopePinned entities. Save/load (#553): save_to_file/load_from_file via MessagePack, SaveGame/LoadGame IPC commands, SaveLoadResultWire on snapshot. Test infrastructure (#200): Layer 3 integration test entry point, three-layer architecture documented per D-030. Information boundary tests (#272): 4 negative tests proving no passive KG leakage, LOS fog holds, tier boundary holds, save isolation per NPC. 1063 tests passing, 0 failures. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
145 lines
5.4 KiB
Rust
145 lines
5.4 KiB
Rust
//! Layer 3 integration test: real subprocess IPC (D-030)
|
|
//!
|
|
//! Spawns the server binary as a child process with --test-mode --port 0,
|
|
//! parses the LISTENING:{port} handshake from stdout, connects via TCP,
|
|
//! sends a PlayerInput, and reads back an ObserverSnapshot.
|
|
//!
|
|
//! This is the highest-fidelity test layer: no mocks, no in-process bridge.
|
|
//! The server runs as a separate OS process, exactly as it does in production.
|
|
//!
|
|
//! Spec references: D-020 (subprocess IPC), D-030 (Layer 3 integration tests)
|
|
|
|
use settled_reach_server::bridge::framing::{read_framed, write_framed};
|
|
use settled_reach_server::bridge::types::*;
|
|
use std::io::{BufRead, BufReader, BufWriter};
|
|
use std::net::TcpStream;
|
|
use std::process::{Command, Stdio};
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// Timeout for the server to emit LISTENING:{port} on stdout.
|
|
const LISTEN_TIMEOUT: Duration = Duration::from_secs(15);
|
|
|
|
/// Timeout for the client to receive a snapshot after sending input.
|
|
const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10);
|
|
|
|
#[test]
|
|
fn server_subprocess_sends_snapshot_on_connect() {
|
|
// 1. Spawn server binary with --test-mode --port 0
|
|
let server_bin = env!("CARGO_BIN_EXE_settled-reach-server");
|
|
let mut child = Command::new(server_bin)
|
|
.args(["--test-mode", "--port", "0"])
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.expect("failed to spawn server binary");
|
|
|
|
let stdout = child.stdout.take().expect("stdout not captured");
|
|
let mut stdout_reader = BufReader::new(stdout);
|
|
|
|
// 2. Parse LISTENING:{port} from stdout
|
|
let port = {
|
|
let deadline = Instant::now() + LISTEN_TIMEOUT;
|
|
let mut line = String::new();
|
|
loop {
|
|
line.clear();
|
|
match stdout_reader.read_line(&mut line) {
|
|
Ok(0) => panic!("server stdout closed before LISTENING signal"),
|
|
Ok(_) => {
|
|
let trimmed = line.trim();
|
|
if let Some(port_str) = trimmed.strip_prefix("LISTENING:") {
|
|
break port_str
|
|
.parse::<u16>()
|
|
.unwrap_or_else(|e| panic!("invalid port '{}': {}", port_str, e));
|
|
}
|
|
}
|
|
Err(e) => panic!("failed to read server stdout: {}", e),
|
|
}
|
|
assert!(
|
|
Instant::now() < deadline,
|
|
"timed out waiting for LISTENING signal"
|
|
);
|
|
}
|
|
};
|
|
|
|
// 3. Connect to the server via TCP
|
|
let addr = format!("127.0.0.1:{}", port);
|
|
let stream = TcpStream::connect(&addr)
|
|
.unwrap_or_else(|e| panic!("failed to connect to server at {}: {}", addr, e));
|
|
stream
|
|
.set_read_timeout(Some(SNAPSHOT_TIMEOUT))
|
|
.expect("set read timeout");
|
|
|
|
let mut reader = BufReader::new(stream.try_clone().expect("clone stream for reader"));
|
|
let mut writer = BufWriter::new(stream);
|
|
|
|
// 4. Read the protocol handshake (first framed message, #555)
|
|
let handshake_frame = read_framed(&mut reader)
|
|
.expect("read handshake frame")
|
|
.expect("server closed connection before sending handshake");
|
|
let handshake: HandshakeMessage =
|
|
rmp_serde::from_slice(&handshake_frame).expect("deserialize HandshakeMessage");
|
|
assert_eq!(
|
|
handshake.protocol_version, PROTOCOL_VERSION,
|
|
"handshake protocol_version mismatch: got {}, expected {}",
|
|
handshake.protocol_version, PROTOCOL_VERSION
|
|
);
|
|
|
|
// 5. Send one PlayerInput (idle tick 0)
|
|
let inputs = vec![PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::MoveNorth,
|
|
}];
|
|
let payload = rmp_serde::to_vec_named(&inputs).expect("serialize PlayerInput");
|
|
write_framed(&mut writer, &payload).expect("send PlayerInput to server");
|
|
|
|
// 6. Read one ObserverSnapshot
|
|
let response = read_framed(&mut reader)
|
|
.expect("read snapshot frame")
|
|
.expect("server closed connection before sending snapshot");
|
|
let snapshot: ObserverSnapshot =
|
|
rmp_serde::from_slice(&response).expect("deserialize ObserverSnapshot");
|
|
|
|
// 7. Assert protocol correctness (D-020)
|
|
assert_eq!(
|
|
snapshot.version, PROTOCOL_VERSION,
|
|
"protocol version mismatch: got {}, expected {}",
|
|
snapshot.version, PROTOCOL_VERSION
|
|
);
|
|
assert!(
|
|
snapshot.entities.len() > 0,
|
|
"snapshot should contain at least one entity (the player), got 0"
|
|
);
|
|
|
|
// The proof room has a player + NPCs. Verify the player entity exists.
|
|
let has_player = snapshot
|
|
.entities
|
|
.iter()
|
|
.any(|e| matches!(e.kind, EntityKind::Player));
|
|
assert!(has_player, "snapshot must contain a Player entity");
|
|
|
|
// 8. Clean up: drop connection so the server exits its game loop
|
|
drop(reader);
|
|
drop(writer);
|
|
|
|
// Wait for child to exit (with timeout)
|
|
let exit_deadline = Instant::now() + Duration::from_secs(5);
|
|
loop {
|
|
match child.try_wait() {
|
|
Ok(Some(_status)) => break,
|
|
Ok(None) => {
|
|
if Instant::now() > exit_deadline {
|
|
child.kill().ok();
|
|
child.wait().ok();
|
|
break;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
Err(e) => {
|
|
eprintln!("error waiting for server process: {}", e);
|
|
child.kill().ok();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|