feat(engine): T-1131 browse data proxy — six entity kinds, index+detail, one wire envelope (D-254 SS4)

browse_reader.rs: BrowseReader on the CityContextReader::open() pattern
— six index + six detail reads against systems.db (bodies filterable by
containing system; system/corporation/commodity details fold their join
partners). browse_proxy.rs: BrowseRequest{browse, kind, query} /
BrowseResponse{kind, status, index, detail} wire types + dispatcher;
BrowseIndexRow{id, primary, secondary} generic across kinds;
BrowseDetail a per-kind enum of field-exhaustive structs.

Demux: Inbound::BrowseRequest is the FIFTH map shape — deliberately the
last; the doc's four-shape ceiling is re-pinned at five with rationale
(six kinds x two forms folded into ONE envelope whose internal enums
pick sub-behavior, the AtlasLayerRequest.up_to precedent, instead of
twelve top-level shapes) and a hard rule that a sixth shape must
migrate to the D-225 tagged-envelope framing. Served for BOTH roles,
connection-tagged 1:1 in-order like atlas/starmap/citynames.
serve_browse_requests pub so integration tests drive the true
end-to-end pipeline. Wire-only per D-254 (T-949 precedent); v1
exclusions (cascade geometry, event logs) respected.

30 unit tests + 7 bridge_tcp integration tests (six-kind round-trip
over real TCP, reader-can-browse, no crossed responses between two
readers, unknown-id NotFound, empty-table Ready); 2 pre-existing tests
updated for the new receive_bridge_inputs parameter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 10:01:21 +02:00
co-authored by Claude Fable 5
parent edde411fb6
commit d48d72fd31
9 changed files with 3013 additions and 20 deletions
+499 -6
View File
@@ -1,9 +1,12 @@
//! Integration tests for TcpBridge over TCP localhost (D-030 Layer 2: IPC roundtrip).
use settled_reach_server::atlas::browse_proxy::{
BrowseEntityKind, BrowseQuery, BrowseRequest, BrowseResponse, BrowseStatus,
};
use settled_reach_server::bridge::framing::{read_framed, write_framed};
use settled_reach_server::bridge::tcp::TcpBridge;
use settled_reach_server::bridge::types::*;
use settled_reach_server::bridge::{Inbound, SimBridge};
use settled_reach_server::bridge::{BridgeResource, Inbound, SimBridge};
use settled_reach_server::simulation::time::{DayPhase, TickRate};
use std::net::{TcpListener, TcpStream};
use std::thread;
@@ -338,8 +341,8 @@ fn single_tick_drains_all_ready_inbound_frames() {
use settled_reach_server::atlas::cascade::CascadeLayer;
use settled_reach_server::atlas::layer_proxy::AtlasLayerRequest;
use settled_reach_server::bridge::{
receive_bridge_inputs, AtlasRequestBuffer, BridgeResource, CityNamesRequestBuffer,
HandshakeState, ServerRunning, StarMapRequestBuffer,
receive_bridge_inputs, AtlasRequestBuffer, BridgeResource, BrowseRequestBuffer,
CityNamesRequestBuffer, HandshakeState, ServerRunning, StarMapRequestBuffer,
};
use settled_reach_server::simulation::input::InputQueue;
@@ -395,6 +398,7 @@ fn single_tick_drains_all_ready_inbound_frames() {
world.init_resource::<AtlasRequestBuffer>();
world.init_resource::<StarMapRequestBuffer>();
world.init_resource::<CityNamesRequestBuffer>();
world.init_resource::<BrowseRequestBuffer>();
world
.run_system_once(receive_bridge_inputs)
@@ -489,9 +493,10 @@ fn drive_accept_loop_until(
/// buffers), bound to a fresh OS-assigned port.
fn new_multi_connection_world() -> (bevy_ecs::world::World, std::net::SocketAddr) {
use settled_reach_server::bridge::{
AtlasRequestBuffer, AtlasResponseBuffer, BridgeResource, CityNamesRequestBuffer,
CityNamesResponseBuffer, ConnectionListener, HandshakeState, PendingConnections,
ServerRunning, SnapshotBuffer, StarMapRequestBuffer, StarMapResponseBuffer,
AtlasRequestBuffer, AtlasResponseBuffer, BridgeResource, BrowseRequestBuffer,
BrowseResponseBuffer, CityNamesRequestBuffer, CityNamesResponseBuffer, ConnectionListener,
HandshakeState, PendingConnections, ServerRunning, SnapshotBuffer, StarMapRequestBuffer,
StarMapResponseBuffer,
};
use settled_reach_server::simulation::input::InputQueue;
@@ -515,6 +520,8 @@ fn new_multi_connection_world() -> (bevy_ecs::world::World, std::net::SocketAddr
world.init_resource::<StarMapResponseBuffer>();
world.init_resource::<CityNamesRequestBuffer>();
world.init_resource::<CityNamesResponseBuffer>();
world.init_resource::<BrowseRequestBuffer>();
world.init_resource::<BrowseResponseBuffer>();
world.init_resource::<SnapshotBuffer>();
(world, addr)
}
@@ -898,6 +905,492 @@ fn second_player_attempt_is_cleanly_rejected() {
);
}
// ─────────────────────────────────────────────────────────────────────────
// T-1131: data browser (D-254 §4) end-to-end over the real TCP wire.
//
// These tests exercise the FULL pipeline — demux (decode_inbound) ->
// receive_bridge_inputs (drain + connection-tag) -> BrowseRequestBuffer ->
// serve_browse_requests (the atlas-plugin proxy system) -> BrowseResponseBuffer
// -> send_browse_responses -> the socket — not just the buffer-push shortcut
// `reader_receives_tagged_star_map_response` uses above, since T-1131's own
// ticket text calls for the connection-tagging proof specifically over
// requests that actually round-trip through the demux.
// ─────────────────────────────────────────────────────────────────────────
/// Build a fixture `systems.db`-shaped file with one row in each of the six
/// v1 browse tables, wired as a `BrowseReaderResource` into `world`. A
/// self-contained minimal fixture local to THIS file — `browse_reader`'s own
/// (larger, column-exhaustive) fixture lives behind `#[cfg(test)]` in the
/// library crate, which is only compiled for `cargo test --lib`, not for a
/// separate integration-test binary linking against the built library (a
/// cross-crate `#[cfg(test)]` visibility boundary — confirmed by attempting
/// the reuse first). This file only needs to prove the WIRE plumbing (demux
/// -> serve -> send), not the SQL correctness `browse_reader`'s own unit
/// tests already verify column-by-column, so a minimal one-row-per-table
/// fixture is the right scope here, not a duplicate of the exhaustive one.
fn wire_browse_reader(world: &mut bevy_ecs::world::World) {
use settled_reach_server::atlas::browse_reader::{BrowseReader, BrowseReaderResource};
let db_path = std::env::temp_dir().join(format!(
"sr_browse_tcp_fixture_{}_{}.db",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_file(&db_path);
{
let conn = rusqlite::Connection::open(&db_path).expect("create fixture db");
conn.execute_batch(
"CREATE TABLE star_systems (
system_id TEXT PRIMARY KEY, proper_name TEXT, system_name TEXT,
star_type TEXT, spectral_class TEXT, dist_ly REAL,
geographic_sector TEXT, geographic_band TEXT, political_zone TEXT,
habitable_planet_count INTEGER, inhabited_planet_count INTEGER,
asteroid_belt INTEGER, gas_giant INTEGER, habitability_profile TEXT,
earth_alignment TEXT, earth_proximity TEXT, earth_tension TEXT,
stability_index INTEGER, system_volatility TEXT, cultural_corridor TEXT,
currency_zone TEXT
);
CREATE TABLE system_economy (
system_id TEXT PRIMARY KEY, economic_tier INTEGER, population INTEGER,
economic_base_primary TEXT, economic_base_secondary TEXT
);
CREATE TABLE system_factions (
system_id TEXT PRIMARY KEY, governance_type TEXT, dominant_faction TEXT
);
CREATE TABLE system_culture (
system_id TEXT PRIMARY KEY, cultural_register TEXT,
atmospheric_tone TEXT, primary_archetype TEXT
);
CREATE TABLE bodies (
body_id TEXT PRIMARY KEY, system_id TEXT NOT NULL, parent_body_id TEXT,
body_type TEXT NOT NULL, orbit_index INTEGER, proper_name TEXT,
mass_class TEXT, atmosphere TEXT, surface_gravity REAL,
orbital_period_days REAL, rotation_period_hours REAL, planet_class TEXT,
hydrosphere TEXT, biosphere_class TEXT, inhabited INTEGER NOT NULL DEFAULT 0,
population INTEGER, economic_role TEXT, founding_age_years INTEGER,
settlement_pattern TEXT, cultural_corridor TEXT, industrial_corridor TEXT,
body_radius_km REAL, axial_tilt_deg REAL
);
CREATE TABLE stations (
station_id TEXT PRIMARY KEY, system_id TEXT NOT NULL, orbits_body_id TEXT,
station_type TEXT NOT NULL, proper_name TEXT, population INTEGER,
economic_role TEXT, governance_type TEXT, docking_class TEXT,
has_gate_infrastructure INTEGER DEFAULT 0, district_count INTEGER
);
CREATE TABLE corporations (
corp_id TEXT PRIMARY KEY, proper_name TEXT NOT NULL, corp_type TEXT NOT NULL,
scope TEXT, headquarters_system TEXT, headquarters_body TEXT,
specialization TEXT, parent_corp TEXT, notes TEXT, behavioral_archetype TEXT,
supply_chain_role TEXT, shadow_economy_access INTEGER DEFAULT 0,
corp_specialization TEXT, hq_placement TEXT
);
CREATE TABLE corp_presence (
corp_id TEXT NOT NULL, location_id TEXT NOT NULL, location_type TEXT NOT NULL,
primary_operation TEXT, PRIMARY KEY (corp_id, location_id)
);
CREATE TABLE corp_financial_state (corp_id TEXT PRIMARY KEY, health_metric REAL NOT NULL DEFAULT 1.0);
CREATE TABLE commodities (
commodity_id TEXT PRIMARY KEY, name TEXT NOT NULL, tier TEXT NOT NULL,
elasticity TEXT NOT NULL, base_price REAL NOT NULL, bulk_class TEXT,
unit TEXT, production_ubiquity TEXT, demand_model TEXT,
commission_certifiable INTEGER DEFAULT 0, compact_contested INTEGER DEFAULT 0,
shadow_viable INTEGER DEFAULT 0, panic_threshold_weeks INTEGER DEFAULT 0, description TEXT
);
CREATE TABLE production_chains (
chain_id TEXT PRIMARY KEY, output_commodity_id TEXT NOT NULL,
output_quantity REAL NOT NULL DEFAULT 1.0, location_bound INTEGER DEFAULT 0, description TEXT
);
CREATE TABLE chain_inputs (
chain_id TEXT NOT NULL, input_commodity_id TEXT NOT NULL, quantity REAL NOT NULL,
PRIMARY KEY (chain_id, input_commodity_id)
);
CREATE TABLE trait_templates (
tag TEXT PRIMARY KEY, label TEXT NOT NULL, cultural_description TEXT,
corridor_pool TEXT NOT NULL DEFAULT 'baseline', geographic_sector TEXT,
bulk_class_gate TEXT, production_ubiquity_gate TEXT,
min_prosperity_bps INTEGER NOT NULL DEFAULT 0, base_weight INTEGER NOT NULL DEFAULT 10000,
weight_mods TEXT, zone_affinity TEXT, allow_tags TEXT, block_tags TEXT,
era_scope TEXT, visual_bundle TEXT
);",
)
.expect("create fixture tables");
conn.execute(
"INSERT INTO star_systems (system_id, proper_name, star_type) VALUES ('GJ-1', 'Aldren', 'M')",
[],
)
.expect("insert star system");
conn.execute(
"INSERT INTO bodies (body_id, system_id, body_type, proper_name, inhabited)
VALUES ('GJ1c', 'GJ-1', 'planet', 'Aldren Prime', 1)",
[],
)
.expect("insert body");
conn.execute(
"INSERT INTO stations (station_id, system_id, station_type, proper_name)
VALUES ('GJ1c-S1', 'GJ-1', 'commercial', 'Aldren Orbital')",
[],
)
.expect("insert station");
conn.execute(
"INSERT INTO corporations (corp_id, proper_name, corp_type, headquarters_system)
VALUES ('gate-corporation', 'Gate Corporation', 'corporation', 'GJ-1')",
[],
)
.expect("insert corp");
conn.execute(
"INSERT INTO commodities (commodity_id, name, tier, elasticity, base_price)
VALUES ('fusion_fuel', 'Fusion Fuel', 'intermediate', 'inelastic', 42.5)",
[],
)
.expect("insert commodity");
conn.execute(
"INSERT INTO trait_templates (tag, label) VALUES ('frontier_utilitarian', 'Frontier Utilitarian')",
[],
)
.expect("insert trait template");
}
let reader = BrowseReader::open(&db_path).expect("open fixture browse db");
world.insert_resource(BrowseReaderResource(reader));
}
/// Drive one full server tick's worth of browse plumbing: `receive_bridge_inputs`
/// (drains the socket into `BrowseRequestBuffer`, connection-tagged),
/// `serve_browse_requests` (the atlas-plugin proxy — reads `BrowseReaderResource`,
/// fills `BrowseResponseBuffer`), and `send_browse_responses` (flushes back
/// out to the originating connection). Matches how `BridgePlugin` +
/// `GenerationPlugin` actually schedule these three systems in `PreInput`/
/// `PostSnapshot`, just run directly rather than through a full `App`.
fn drive_browse_tick(world: &mut bevy_ecs::world::World) {
use bevy_ecs::system::RunSystemOnce;
use settled_reach_server::atlas::plugin::serve_browse_requests;
use settled_reach_server::bridge::{receive_bridge_inputs, send_browse_responses};
world
.run_system_once(receive_bridge_inputs)
.expect("receive_bridge_inputs failed to run");
world
.run_system_once(serve_browse_requests)
.expect("serve_browse_requests failed to run");
world
.run_system_once(send_browse_responses)
.expect("send_browse_responses failed to run");
}
/// Drive `drive_browse_tick` repeatedly until a framed `BrowseResponse`
/// arrives on `stream`, or a wall-clock deadline expires (this file's
/// established hardening pattern — see `drive_accept_loop_until`/
/// `collect_input_batches` — rather than a single tick or a fixed sleep).
///
/// A single `drive_browse_tick` call races the client thread's write
/// actually landing in the server's non-blocking socket buffer before
/// `receive_bridge_inputs` polls it — `stream` here is a genuinely blocking
/// client-side socket (only the SERVER's accepted connections are toggled
/// non-blocking, by `TcpBridge::from_connected_stream`/D-254 §2's
/// accept-loop design), so `set_read_timeout` + a bounded retry loop is the
/// correct fix, not a longer single wait.
fn drive_browse_tick_until_response(
world: &mut bevy_ecs::world::World,
stream: &mut TcpStream,
) -> BrowseResponse {
stream
.set_read_timeout(Some(std::time::Duration::from_millis(50)))
.expect("failed to set read timeout");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
drive_browse_tick(world);
match read_framed(stream) {
Ok(Some(payload)) => {
return rmp_serde::from_slice(&payload).expect("failed to decode BrowseResponse");
}
Ok(None) => panic!("unexpected EOF reading browse response"),
Err(e)
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::TimedOut =>
{
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for a browse response"
);
}
Err(e) => panic!("failed to read browse response frame: {e}"),
}
}
}
fn send_browse_index_request(stream: &mut TcpStream, kind: BrowseEntityKind) {
let req = BrowseRequest {
browse: true,
kind,
query: BrowseQuery::Index {
filter_system_id: None,
},
};
let payload = rmp_serde::to_vec_named(&req).expect("failed to encode BrowseRequest");
write_framed(stream, &payload).expect("failed to write BrowseRequest");
}
fn send_browse_detail_request(stream: &mut TcpStream, kind: BrowseEntityKind, id: &str) {
let req = BrowseRequest {
browse: true,
kind,
query: BrowseQuery::Detail { id: id.to_string() },
};
let payload = rmp_serde::to_vec_named(&req).expect("failed to encode BrowseRequest");
write_framed(stream, &payload).expect("failed to write BrowseRequest");
}
/// T-1131: index + detail round-trip for all six D-254 §4 v1 entity kinds,
/// over one Reader connection, through the full demux->serve->send pipeline.
/// Parameterized-style — one test iterating all six kinds, per the ticket's
/// own suggested test shape.
#[test]
fn browse_index_and_detail_round_trip_for_all_six_kinds_over_tcp() {
let (mut world, addr) = new_multi_connection_world();
wire_browse_reader(&mut world);
let client_handle = thread::spawn(move || {
let stream = TcpStream::connect(addr).expect("failed to connect");
client_handshake(stream, ConnectionRole::Reader)
});
drive_accept_loop_until(&mut world, |w| {
w.resource::<BridgeResource>().reader_count() == 1
});
let mut stream = client_handle.join().expect("client thread panicked");
let cases: &[(BrowseEntityKind, &str)] = &[
(BrowseEntityKind::StarSystem, "GJ-1"),
(BrowseEntityKind::Body, "GJ1c"),
(BrowseEntityKind::Station, "GJ1c-S1"),
(BrowseEntityKind::Corporation, "gate-corporation"),
(BrowseEntityKind::Commodity, "fusion_fuel"),
(BrowseEntityKind::TraitTemplate, "frontier_utilitarian"),
];
for &(kind, id) in cases {
send_browse_index_request(&mut stream, kind);
let index_resp = drive_browse_tick_until_response(&mut world, &mut stream);
assert_eq!(index_resp.kind, kind, "index response kind echo");
assert_eq!(index_resp.status, BrowseStatus::Ready, "index({kind:?})");
let rows = index_resp.index.expect("index populated");
assert!(
rows.iter().any(|r| r.id == id),
"index({kind:?}) should list {id}"
);
send_browse_detail_request(&mut stream, kind, id);
let detail_resp = drive_browse_tick_until_response(&mut world, &mut stream);
assert_eq!(detail_resp.kind, kind, "detail response kind echo");
assert_eq!(detail_resp.status, BrowseStatus::Ready, "detail({kind:?})");
assert!(
detail_resp.detail.is_some(),
"detail({kind:?}) should be populated"
);
}
}
/// T-1131: a Reader (not just a Player) can send `BrowseRequest` and get a
/// `Ready` response — proves the permitted-message-matrix row (D-254 §2:
/// atlas/star-map/city-names/browse all say "yes" for Reader) actually holds
/// for the new request type specifically, not just by code inspection.
#[test]
fn reader_can_browse() {
let (mut world, addr) = new_multi_connection_world();
wire_browse_reader(&mut world);
let client_handle = thread::spawn(move || {
let stream = TcpStream::connect(addr).expect("failed to connect");
client_handshake(stream, ConnectionRole::Reader)
});
drive_accept_loop_until(&mut world, |w| {
w.resource::<BridgeResource>().reader_count() == 1
});
let mut stream = client_handle.join().expect("client thread panicked");
send_browse_index_request(&mut stream, BrowseEntityKind::Commodity);
let resp = drive_browse_tick_until_response(&mut world, &mut stream);
assert_eq!(resp.status, BrowseStatus::Ready);
}
/// T-1131: a Player (not just a Reader) can also browse — the permitted-
/// message-matrix row says "yes" for both roles, and this is the other half
/// of that proof.
#[test]
fn player_can_browse() {
let (mut world, addr) = new_multi_connection_world();
wire_browse_reader(&mut world);
let client_handle = thread::spawn(move || {
let stream = TcpStream::connect(addr).expect("failed to connect");
client_handshake(stream, ConnectionRole::Player)
});
drive_accept_loop_until(&mut world, |w| w.resource::<BridgeResource>().has_player());
let mut stream = client_handle.join().expect("client thread panicked");
send_browse_index_request(&mut stream, BrowseEntityKind::TraitTemplate);
let resp = drive_browse_tick_until_response(&mut world, &mut stream);
assert_eq!(resp.status, BrowseStatus::Ready);
}
/// T-1131: two concurrently-connected Readers each get back only their OWN
/// browse response — the connection-tagging plumbing D-254 §2 established
/// for atlas/star-map/city-names must hold for browse too, proven with two
/// simultaneously-live sockets asking for DIFFERENT things so a crossed wire
/// would be immediately visible (not just "a response arrived", but "the
/// WRONG response arrived").
#[test]
fn two_readers_do_not_cross_browse_responses() {
let (mut world, addr) = new_multi_connection_world();
wire_browse_reader(&mut world);
let reader_a_handle = thread::spawn(move || {
let stream = TcpStream::connect(addr).expect("failed to connect (reader A)");
client_handshake(stream, ConnectionRole::Reader)
});
drive_accept_loop_until(&mut world, |w| {
w.resource::<BridgeResource>().reader_count() == 1
});
let mut stream_a = reader_a_handle.join().expect("reader A thread panicked");
let reader_b_handle = thread::spawn(move || {
let stream = TcpStream::connect(addr).expect("failed to connect (reader B)");
client_handshake(stream, ConnectionRole::Reader)
});
drive_accept_loop_until(&mut world, |w| {
w.resource::<BridgeResource>().reader_count() == 2
});
let mut stream_b = reader_b_handle.join().expect("reader B thread panicked");
// A asks about star systems, B asks about commodities — deliberately
// different kinds so a crossed response is unmistakable (not just "wrong
// data", but "wrong KIND"). Both requests are in flight before any
// response is expected, so `drive_browse_tick` is retried until BOTH
// streams have something readable (rather than draining/reading one
// stream to completion before the other's request has even arrived).
send_browse_index_request(&mut stream_a, BrowseEntityKind::StarSystem);
send_browse_index_request(&mut stream_b, BrowseEntityKind::Commodity);
stream_a
.set_read_timeout(Some(std::time::Duration::from_millis(50)))
.expect("failed to set read timeout (A)");
stream_b
.set_read_timeout(Some(std::time::Duration::from_millis(50)))
.expect("failed to set read timeout (B)");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
let mut resp_a: Option<BrowseResponse> = None;
let mut resp_b: Option<BrowseResponse> = None;
while resp_a.is_none() || resp_b.is_none() {
drive_browse_tick(&mut world);
if resp_a.is_none() {
if let Ok(Some(payload)) = read_framed(&mut stream_a) {
resp_a = Some(rmp_serde::from_slice(&payload).expect("decode A"));
}
}
if resp_b.is_none() {
if let Ok(Some(payload)) = read_framed(&mut stream_b) {
resp_b = Some(rmp_serde::from_slice(&payload).expect("decode B"));
}
}
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for both readers' browse responses (A={}, B={})",
resp_a.is_some(),
resp_b.is_some()
);
}
let resp_a = resp_a.expect("resp_a set by loop exit condition");
let resp_b = resp_b.expect("resp_b set by loop exit condition");
assert_eq!(
resp_a.kind,
BrowseEntityKind::StarSystem,
"reader A must get back ITS OWN request's kind, not reader B's"
);
assert_eq!(
resp_b.kind,
BrowseEntityKind::Commodity,
"reader B must get back ITS OWN request's kind, not reader A's"
);
}
/// T-1131: a `Detail` request for an id that doesn't exist in that kind's
/// table comes back `NotFound` over the wire (not an error, not a hang, not
/// silently dropped).
#[test]
fn browse_detail_unknown_id_is_not_found_over_tcp() {
let (mut world, addr) = new_multi_connection_world();
wire_browse_reader(&mut world);
let client_handle = thread::spawn(move || {
let stream = TcpStream::connect(addr).expect("failed to connect");
client_handshake(stream, ConnectionRole::Reader)
});
drive_accept_loop_until(&mut world, |w| {
w.resource::<BridgeResource>().reader_count() == 1
});
let mut stream = client_handle.join().expect("client thread panicked");
send_browse_detail_request(&mut stream, BrowseEntityKind::Corporation, "no-such-corp");
let resp = drive_browse_tick_until_response(&mut world, &mut stream);
assert_eq!(resp.status, BrowseStatus::NotFound);
assert!(resp.detail.is_none());
}
/// T-1131: an `Index` request against a kind with zero rows still comes back
/// `Ready` with an empty list, matching the existing city-names convention
/// (`CityNamesStatus`'s "unknown body -> Ready, empty" pattern) — over the
/// real wire, not just at the reader layer (see
/// `browse_reader::tests::index_stations_empty_table_is_empty_vec` for that
/// half of the proof).
#[test]
fn browse_index_empty_table_is_ready_with_empty_list_over_tcp() {
use settled_reach_server::atlas::browse_reader::{BrowseReader, BrowseReaderResource};
let (mut world, addr) = new_multi_connection_world();
// A fixture db with the stations table present but genuinely empty — a
// distinct db from wire_browse_reader's shared fixture (which always has
// one station), built directly here so this test controls the "empty"
// precondition explicitly rather than relying on incidental fixture state.
let db_path =
std::env::temp_dir().join(format!("sr_browse_tcp_empty_{}.db", std::process::id()));
let _ = std::fs::remove_file(&db_path);
{
let conn = rusqlite::Connection::open(&db_path).expect("create empty fixture db");
conn.execute_batch(
"CREATE TABLE stations (
station_id TEXT PRIMARY KEY, system_id TEXT NOT NULL, orbits_body_id TEXT,
station_type TEXT NOT NULL, proper_name TEXT, population INTEGER,
economic_role TEXT, governance_type TEXT, docking_class TEXT,
has_gate_infrastructure INTEGER DEFAULT 0, district_count INTEGER
);",
)
.expect("create empty stations table");
}
let reader = BrowseReader::open(&db_path).expect("open empty fixture db");
world.insert_resource(BrowseReaderResource(reader));
let client_handle = thread::spawn(move || {
let stream = TcpStream::connect(addr).expect("failed to connect");
client_handshake(stream, ConnectionRole::Reader)
});
drive_accept_loop_until(&mut world, |w| {
w.resource::<BridgeResource>().reader_count() == 1
});
let mut stream = client_handle.join().expect("client thread panicked");
send_browse_index_request(&mut stream, BrowseEntityKind::Station);
let resp = drive_browse_tick_until_response(&mut world, &mut stream);
assert_eq!(resp.status, BrowseStatus::Ready);
assert_eq!(resp.index.unwrap().len(), 0);
let _ = std::fs::remove_file(&db_path);
}
/// Minimal `ObserverSnapshot` for the tests above — same shape as
/// `snapshot_roundtrip_over_tcp`'s at the top of this file, parameterized
/// only by `tick` (the one field these tests assert on).