//! Data browser wire proxy (D-254 §4, T-1131) — one demux shape serving all //! six v1 browse entity kinds (star systems, bodies, stations, corporations, //! commodities, trait templates). //! //! **Why one envelope, not six/twelve request types.** `crate::bridge`'s //! inbound demux (`decode_inbound`) is documented as topping out at four //! hand-sniffed frame shapes ("four shapes is the practical limit of this //! hand-rolled sniffing... do not add a fifth probe"). Six entity kinds x //! two forms (index/detail) would be up to twelve new top-level shapes — //! far past that ceiling. Instead this module follows the SAME pattern //! `AtlasLayerRequest{body_id, up_to: CascadeLayer}` already established: //! one wire message, an internal enum (here, two: [`BrowseEntityKind`] and //! [`BrowseQuery`]) picks the sub-behavior. `decode_inbound` gains exactly //! one new probe (`browse: bool`, the same mandatory-discriminator //! convention as `star_map`/`city_names`) — the fifth and, per that //! module's own ceiling note, deliberately the last one this hand-rolled //! scheme should ever carry; a sixth inbound shape must migrate to the //! tagged-envelope framing D-225 already deferred. //! //! **D-254 §4 v1 scope — six registry-tier tables, index + detail per //! kind:** //! //! 1. Star systems (`star_systems` + `system_economy`/`system_factions`/ //! `system_culture` folded into the detail screen) //! 2. Bodies (`bodies`, index filterable by system) //! 3. Stations (`stations`) //! 4. Corporations (`corporations` + `corp_presence`/`corp_financial_state` //! folded in) //! 5. Commodities (`commodities` + `production_chains`/`chain_inputs`) //! 6. Trait catalog (`trait_templates`) //! //! Deliberately excluded from v1 (D-254 §4): cascade-derived atlas geometry //! tables (`atlas_cities`/`atlas_roads`/…, partially populated mid-Phase-4) //! and event-log tables (`corp_lifecycle_events`/`system_history`/ //! `historical_events`, wrong UI shape for list+detail). //! //! **D-010 boundary note.** A Reader connection has no character and //! receives no `ObserverSnapshot` — there is no per-character fog to bound //! against here. What a Reader may see is bounded by connection class, and //! this proxy's six entities are registry-tier (identical across every //! save, cascade-independent) — the same "install-static/world-public" //! class `handle_star_map_request`/`handle_city_names_request`/ //! `handle_atlas_request` already serve to any connection. Browsing a //! specific save's diverged dynamic state is explicitly OUT of this //! proxy's scope (D-254 §4) — none of the six v1 tables carry that. use serde::{Deserialize, Serialize}; use crate::atlas::browse_reader::{ BodyDetailRow, BrowseReadError, BrowseReader, CommodityDetailRow, CorporationDetailRow, StarSystemDetailRow, StationDetailRow, TraitTemplateDetailRow, }; // --------------------------------------------------------------------------- // Wire request // --------------------------------------------------------------------------- /// The six v1 browse entity kinds (D-254 §4). Unit-variant-only enum — on /// the wire this is a bare MessagePack string (`"StarSystem"`, `"Body"`, …), /// same convention as `CascadeLayer` in `AtlasLayerRequest.up_to`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum BrowseEntityKind { StarSystem, Body, Station, Corporation, Commodity, TraitTemplate, } /// Index-vs-detail request form. Both variants carry fields, so on the wire /// each is a single-key MessagePack map (`{"Index": {...}}` / /// `{"Detail": {...}}`), same convention as `AtlasLayerStatus::Error(String)`. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum BrowseQuery { /// List form: id + display fields for every row of this kind. /// `filter_system_id` only means anything for [`BrowseEntityKind::Body`] /// (D-254 §4: "Bodies, filterable by system") — ignored for every other /// kind, which always return every row. Index { filter_system_id: Option }, /// Single full-row form. `id` is the entity's own primary key string /// (system_id / body_id / station_id / corp_id / commodity_id / trait /// tag) — the same string an `Index` response's `BrowseIndexRow::id` /// carries for that row. Detail { id: String }, } /// A client request for browse data (T-1131). `browse` is the Inbound /// discriminator (see `crate::bridge`'s demux doc): always `true`. Its /// presence, not its value, is what disambiguates this map shape from the /// other four. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BrowseRequest { pub browse: bool, pub kind: BrowseEntityKind, pub query: BrowseQuery, } // --------------------------------------------------------------------------- // Wire response // --------------------------------------------------------------------------- /// Status of a [`BrowseResponse`]. Same three-way shape (two unit variants + /// one data variant) as `AtlasLayerStatus`/`StarMapStatus`/`CityNamesStatus` /// minus `Pending` (nothing here is cascade-generated, so nothing is ever /// "come back later" — see module doc's D-010 boundary note). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum BrowseStatus { /// The request was served (`index` or `detail` is populated, /// matching the request's `query` form). Ready, /// `Detail`-only: the requested `id` does not exist in that kind's /// table. `Index` requests never produce `NotFound` — an /// empty/unfiltered table is `Ready` with an empty `index` list /// (matches `CityNamesStatus`'s "unknown body -> Ready with empty /// list" convention, not a distinct not-found case). NotFound, /// DB/IO failure (message for the client log). Error(String), } /// Generic index row — the SAME shape for all six kinds, matching D-254 /// §4's index-screen spec ("a scrollable list — name + one or two summary /// columns"). `id` is the primary key to pass back in a following `Detail` /// request. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct BrowseIndexRow { pub id: String, /// Display name (or a synthesized fallback when the row has none /// authored — see `browse_reader`'s per-kind index query). pub primary: String, /// One kind-specific summary field: star systems -> star_type; bodies /// -> body_type; stations -> station_type; corporations -> corp_type; /// commodities -> tier; trait templates -> corridor_pool. pub secondary: Option, } /// One variant per entity kind, each carrying every column the detail /// screen shows (D-254 §4: "showing every column the wire response /// carries for that one entity"). The wrapped `*DetailRow` structs /// (`browse_reader`) are `#[derive(Serialize, Deserialize)]`-free by /// design (DB-layer types); this proxy layer owns the wire encoding, so a /// thin field-for-field wire struct mirrors each one 1:1. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum BrowseDetail { StarSystem(StarSystemDetail), Body(BodyDetail), Station(StationDetail), Corporation(CorporationDetail), Commodity(CommodityDetail), TraitTemplate(TraitTemplateDetail), } /// A browse response: the index list or one detail row (matching the /// request's `query` form), or a non-`Ready` status (T-1131). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BrowseResponse { /// Echoes the request's `kind` — lets the client route the response /// without needing to correlate against its own outstanding-request /// state (the connection-tagging in `crate::bridge` already handles /// "whose request was this"; this is "which of my SIX outstanding /// concerns is this"). pub kind: BrowseEntityKind, pub status: BrowseStatus, /// Populated iff the request's `query` was `Index` and `status == /// Ready`. pub index: Option>, /// Populated iff the request's `query` was `Detail` and `status == /// Ready`. pub detail: Option, } // --------------------------------------------------------------------------- // Wire-shaped detail structs (1:1 field mirror of browse_reader's DB-layer // *DetailRow structs — see BrowseDetail doc for why this indirection exists) // --------------------------------------------------------------------------- #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct StarSystemDetail { pub system_id: String, pub proper_name: Option, pub system_name: Option, pub star_type: Option, pub spectral_class: Option, pub dist_ly: Option, pub geographic_sector: Option, pub geographic_band: Option, pub political_zone: Option, pub habitable_planet_count: Option, pub inhabited_planet_count: Option, pub asteroid_belt: Option, pub gas_giant: Option, pub habitability_profile: Option, pub earth_alignment: Option, pub earth_proximity: Option, pub earth_tension: Option, pub stability_index: Option, pub system_volatility: Option, pub cultural_corridor: Option, pub currency_zone: Option, pub economic_tier: Option, pub population: Option, pub economic_base_primary: Option, pub economic_base_secondary: Option, pub governance_type: Option, pub dominant_faction: Option, pub cultural_register: Option, pub atmospheric_tone: Option, pub primary_archetype: Option, } impl From for StarSystemDetail { fn from(r: StarSystemDetailRow) -> Self { Self { system_id: r.system_id, proper_name: r.proper_name, system_name: r.system_name, star_type: r.star_type, spectral_class: r.spectral_class, dist_ly: r.dist_ly, geographic_sector: r.geographic_sector, geographic_band: r.geographic_band, political_zone: r.political_zone, habitable_planet_count: r.habitable_planet_count, inhabited_planet_count: r.inhabited_planet_count, asteroid_belt: r.asteroid_belt, gas_giant: r.gas_giant, habitability_profile: r.habitability_profile, earth_alignment: r.earth_alignment, earth_proximity: r.earth_proximity, earth_tension: r.earth_tension, stability_index: r.stability_index, system_volatility: r.system_volatility, cultural_corridor: r.cultural_corridor, currency_zone: r.currency_zone, economic_tier: r.economic_tier, population: r.population, economic_base_primary: r.economic_base_primary, economic_base_secondary: r.economic_base_secondary, governance_type: r.governance_type, dominant_faction: r.dominant_faction, cultural_register: r.cultural_register, atmospheric_tone: r.atmospheric_tone, primary_archetype: r.primary_archetype, } } } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct BodyDetail { pub body_id: String, pub system_id: String, pub parent_body_id: Option, pub body_type: String, pub orbit_index: Option, pub proper_name: Option, pub mass_class: Option, pub atmosphere: Option, pub surface_gravity: Option, pub orbital_period_days: Option, pub rotation_period_hours: Option, pub planet_class: Option, pub hydrosphere: Option, pub biosphere_class: Option, pub inhabited: bool, pub population: Option, pub economic_role: Option, pub founding_age_years: Option, pub settlement_pattern: Option, pub cultural_corridor: Option, pub industrial_corridor: Option, pub body_radius_km: Option, pub axial_tilt_deg: Option, } impl From for BodyDetail { fn from(r: BodyDetailRow) -> Self { Self { body_id: r.body_id, system_id: r.system_id, parent_body_id: r.parent_body_id, body_type: r.body_type, orbit_index: r.orbit_index, proper_name: r.proper_name, mass_class: r.mass_class, atmosphere: r.atmosphere, surface_gravity: r.surface_gravity, orbital_period_days: r.orbital_period_days, rotation_period_hours: r.rotation_period_hours, planet_class: r.planet_class, hydrosphere: r.hydrosphere, biosphere_class: r.biosphere_class, inhabited: r.inhabited, population: r.population, economic_role: r.economic_role, founding_age_years: r.founding_age_years, settlement_pattern: r.settlement_pattern, cultural_corridor: r.cultural_corridor, industrial_corridor: r.industrial_corridor, body_radius_km: r.body_radius_km, axial_tilt_deg: r.axial_tilt_deg, } } } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct StationDetail { pub station_id: String, pub system_id: String, pub orbits_body_id: Option, pub station_type: String, pub proper_name: Option, pub population: Option, pub economic_role: Option, pub governance_type: Option, pub docking_class: Option, pub has_gate_infrastructure: bool, pub district_count: Option, } impl From for StationDetail { fn from(r: StationDetailRow) -> Self { Self { station_id: r.station_id, system_id: r.system_id, orbits_body_id: r.orbits_body_id, station_type: r.station_type, proper_name: r.proper_name, population: r.population, economic_role: r.economic_role, governance_type: r.governance_type, docking_class: r.docking_class, has_gate_infrastructure: r.has_gate_infrastructure, district_count: r.district_count, } } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct CorpPresenceEntry { pub location_id: String, pub location_type: String, pub primary_operation: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct CorporationDetail { pub corp_id: String, pub proper_name: String, pub corp_type: String, pub scope: Option, pub headquarters_system: Option, pub headquarters_body: Option, pub specialization: Option, pub parent_corp: Option, pub notes: Option, pub behavioral_archetype: Option, pub supply_chain_role: Option, pub shadow_economy_access: bool, pub corp_specialization: Option, pub hq_placement: Option, pub health_metric: Option, pub presence: Vec, } impl From for CorporationDetail { fn from(r: CorporationDetailRow) -> Self { Self { corp_id: r.corp_id, proper_name: r.proper_name, corp_type: r.corp_type, scope: r.scope, headquarters_system: r.headquarters_system, headquarters_body: r.headquarters_body, specialization: r.specialization, parent_corp: r.parent_corp, notes: r.notes, behavioral_archetype: r.behavioral_archetype, supply_chain_role: r.supply_chain_role, shadow_economy_access: r.shadow_economy_access, corp_specialization: r.corp_specialization, hq_placement: r.hq_placement, health_metric: r.health_metric, presence: r .presence .into_iter() .map(|p| CorpPresenceEntry { location_id: p.location_id, location_type: p.location_type, primary_operation: p.primary_operation, }) .collect(), } } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ChainInputEntry { pub input_commodity_id: String, pub quantity: f64, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ProductionChainEntry { pub chain_id: String, pub output_quantity: f64, pub location_bound: bool, pub description: Option, pub inputs: Vec, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct CommodityDetail { pub commodity_id: String, pub name: String, pub tier: String, pub elasticity: String, pub base_price: f64, pub bulk_class: Option, pub unit: Option, pub production_ubiquity: Option, pub demand_model: Option, pub commission_certifiable: bool, pub compact_contested: bool, pub shadow_viable: bool, pub panic_threshold_weeks: Option, pub description: Option, pub produced_by: Vec, } impl From for CommodityDetail { fn from(r: CommodityDetailRow) -> Self { Self { commodity_id: r.commodity_id, name: r.name, tier: r.tier, elasticity: r.elasticity, base_price: r.base_price, bulk_class: r.bulk_class, unit: r.unit, production_ubiquity: r.production_ubiquity, demand_model: r.demand_model, commission_certifiable: r.commission_certifiable, compact_contested: r.compact_contested, shadow_viable: r.shadow_viable, panic_threshold_weeks: r.panic_threshold_weeks, description: r.description, produced_by: r .produced_by .into_iter() .map(|c| ProductionChainEntry { chain_id: c.chain_id, output_quantity: c.output_quantity, location_bound: c.location_bound, description: c.description, inputs: c .inputs .into_iter() .map(|i| ChainInputEntry { input_commodity_id: i.input_commodity_id, quantity: i.quantity, }) .collect(), }) .collect(), } } } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct TraitTemplateDetail { pub tag: String, pub label: String, pub cultural_description: Option, pub corridor_pool: String, pub geographic_sector: Option, pub bulk_class_gate: Option, pub production_ubiquity_gate: Option, pub min_prosperity_bps: i64, pub base_weight: i64, pub weight_mods: Option, pub zone_affinity: Option, pub allow_tags: Option, pub block_tags: Option, pub era_scope: Option, pub visual_bundle: Option, } impl From for TraitTemplateDetail { fn from(r: TraitTemplateDetailRow) -> Self { Self { tag: r.tag, label: r.label, cultural_description: r.cultural_description, corridor_pool: r.corridor_pool, geographic_sector: r.geographic_sector, bulk_class_gate: r.bulk_class_gate, production_ubiquity_gate: r.production_ubiquity_gate, min_prosperity_bps: r.min_prosperity_bps, base_weight: r.base_weight, weight_mods: r.weight_mods, zone_affinity: r.zone_affinity, allow_tags: r.allow_tags, block_tags: r.block_tags, era_scope: r.era_scope, visual_bundle: r.visual_bundle, } } } // --------------------------------------------------------------------------- // Handler // --------------------------------------------------------------------------- fn error_response(kind: BrowseEntityKind, message: impl Into) -> BrowseResponse { BrowseResponse { kind, status: BrowseStatus::Error(message.into()), index: None, detail: None, } } fn db_error_response(kind: BrowseEntityKind, e: BrowseReadError) -> BrowseResponse { error_response(kind, e.to_string()) } /// Serve one browse request (T-1131): dispatch on `(kind, query)` to the /// matching `BrowseReader` read function, ignoring `filter_system_id` for /// every kind except `Body` (only `Body`'s `Index` query honors it, per /// D-254 §4 — see [`BrowseQuery::Index`] doc). /// /// `reader` absent (no DB opened at startup) is reported as `Error`, /// matching `handle_city_names_request`'s "no city context reader" /// convention. pub fn handle_browse_request(req: &BrowseRequest, reader: Option<&BrowseReader>) -> BrowseResponse { let Some(reader) = reader else { return error_response(req.kind, "browse reader unavailable"); }; match (req.kind, &req.query) { // ─── Star systems ─────────────────────────────────────────────── (BrowseEntityKind::StarSystem, BrowseQuery::Index { .. }) => { match reader.index_star_systems() { Ok(rows) => BrowseResponse { kind: req.kind, status: BrowseStatus::Ready, index: Some( rows.into_iter() .map(|r| BrowseIndexRow { id: r.system_id, primary: r.display_name, secondary: r.star_type, }) .collect(), ), detail: None, }, Err(e) => db_error_response(req.kind, e), } } (BrowseEntityKind::StarSystem, BrowseQuery::Detail { id }) => { match reader.detail_star_system(id) { Ok(Some(row)) => BrowseResponse { kind: req.kind, status: BrowseStatus::Ready, index: None, detail: Some(BrowseDetail::StarSystem(row.into())), }, Ok(None) => BrowseResponse { kind: req.kind, status: BrowseStatus::NotFound, index: None, detail: None, }, Err(e) => db_error_response(req.kind, e), } } // ─── Bodies ────────────────────────────────────────────────────── (BrowseEntityKind::Body, BrowseQuery::Index { filter_system_id }) => { match reader.index_bodies(filter_system_id.as_deref()) { Ok(rows) => BrowseResponse { kind: req.kind, status: BrowseStatus::Ready, index: Some( rows.into_iter() .map(|r| BrowseIndexRow { id: r.body_id, primary: r.display_name, secondary: Some(r.body_type), }) .collect(), ), detail: None, }, Err(e) => db_error_response(req.kind, e), } } (BrowseEntityKind::Body, BrowseQuery::Detail { id }) => match reader.detail_body(id) { Ok(Some(row)) => BrowseResponse { kind: req.kind, status: BrowseStatus::Ready, index: None, detail: Some(BrowseDetail::Body(row.into())), }, Ok(None) => BrowseResponse { kind: req.kind, status: BrowseStatus::NotFound, index: None, detail: None, }, Err(e) => db_error_response(req.kind, e), }, // ─── Stations ──────────────────────────────────────────────────── (BrowseEntityKind::Station, BrowseQuery::Index { .. }) => match reader.index_stations() { Ok(rows) => BrowseResponse { kind: req.kind, status: BrowseStatus::Ready, index: Some( rows.into_iter() .map(|r| BrowseIndexRow { id: r.station_id, primary: r.display_name, secondary: Some(r.station_type), }) .collect(), ), detail: None, }, Err(e) => db_error_response(req.kind, e), }, (BrowseEntityKind::Station, BrowseQuery::Detail { id }) => { match reader.detail_station(id) { Ok(Some(row)) => BrowseResponse { kind: req.kind, status: BrowseStatus::Ready, index: None, detail: Some(BrowseDetail::Station(row.into())), }, Ok(None) => BrowseResponse { kind: req.kind, status: BrowseStatus::NotFound, index: None, detail: None, }, Err(e) => db_error_response(req.kind, e), } } // ─── Corporations ──────────────────────────────────────────────── (BrowseEntityKind::Corporation, BrowseQuery::Index { .. }) => { match reader.index_corporations() { Ok(rows) => BrowseResponse { kind: req.kind, status: BrowseStatus::Ready, index: Some( rows.into_iter() .map(|r| BrowseIndexRow { id: r.corp_id, primary: r.proper_name, secondary: Some(r.corp_type), }) .collect(), ), detail: None, }, Err(e) => db_error_response(req.kind, e), } } (BrowseEntityKind::Corporation, BrowseQuery::Detail { id }) => { match reader.detail_corporation(id) { Ok(Some(row)) => BrowseResponse { kind: req.kind, status: BrowseStatus::Ready, index: None, detail: Some(BrowseDetail::Corporation(row.into())), }, Ok(None) => BrowseResponse { kind: req.kind, status: BrowseStatus::NotFound, index: None, detail: None, }, Err(e) => db_error_response(req.kind, e), } } // ─── Commodities ───────────────────────────────────────────────── (BrowseEntityKind::Commodity, BrowseQuery::Index { .. }) => { match reader.index_commodities() { Ok(rows) => BrowseResponse { kind: req.kind, status: BrowseStatus::Ready, index: Some( rows.into_iter() .map(|r| BrowseIndexRow { id: r.commodity_id, primary: r.name, secondary: Some(r.tier), }) .collect(), ), detail: None, }, Err(e) => db_error_response(req.kind, e), } } (BrowseEntityKind::Commodity, BrowseQuery::Detail { id }) => { match reader.detail_commodity(id) { Ok(Some(row)) => BrowseResponse { kind: req.kind, status: BrowseStatus::Ready, index: None, detail: Some(BrowseDetail::Commodity(row.into())), }, Ok(None) => BrowseResponse { kind: req.kind, status: BrowseStatus::NotFound, index: None, detail: None, }, Err(e) => db_error_response(req.kind, e), } } // ─── Trait templates ───────────────────────────────────────────── (BrowseEntityKind::TraitTemplate, BrowseQuery::Index { .. }) => { match reader.index_trait_templates() { Ok(rows) => BrowseResponse { kind: req.kind, status: BrowseStatus::Ready, index: Some( rows.into_iter() .map(|r| BrowseIndexRow { id: r.tag, primary: r.label, secondary: Some(r.corridor_pool), }) .collect(), ), detail: None, }, Err(e) => db_error_response(req.kind, e), } } (BrowseEntityKind::TraitTemplate, BrowseQuery::Detail { id }) => { match reader.detail_trait_template(id) { Ok(Some(row)) => BrowseResponse { kind: req.kind, status: BrowseStatus::Ready, index: None, detail: Some(BrowseDetail::TraitTemplate(row.into())), }, Ok(None) => BrowseResponse { kind: req.kind, status: BrowseStatus::NotFound, index: None, detail: None, }, Err(e) => db_error_response(req.kind, e), } } } } #[cfg(test)] mod tests { use super::*; use crate::atlas::browse_reader::tests::make_fixture_db; fn index_req(kind: BrowseEntityKind) -> BrowseRequest { BrowseRequest { browse: true, kind, query: BrowseQuery::Index { filter_system_id: None, }, } } fn detail_req(kind: BrowseEntityKind, id: &str) -> BrowseRequest { BrowseRequest { browse: true, kind, query: BrowseQuery::Detail { id: id.to_string() }, } } // ─── Wire encoding shape (pinned to stig-browser 2026-07-17: unit // variants of BrowseEntityKind/BrowseStatus are bare strings; the // data-carrying variants of BrowseQuery/BrowseStatus::Error are // single-key maps — same convention as CascadeLayer/AtlasLayerStatus) ── #[test] fn browse_entity_kind_encodes_as_bare_string() { // All six BrowseEntityKind variants are unit variants -> bare // MessagePack string, exactly like CascadeLayer::Topography does for // AtlasLayerRequest.up_to. Round-trip through serde_json (which makes // the shape human-inspectable) to assert this precisely, then confirm // the msgpack round-trip separately. let json = serde_json::to_string(&BrowseEntityKind::StarSystem).expect("encode"); assert_eq!(json, "\"StarSystem\""); } #[test] fn browse_query_variants_encode_as_single_key_maps() { // Both BrowseQuery variants carry fields -> single-key map on the // wire, matching AtlasLayerStatus::Error(String)'s convention. let index_json = serde_json::to_string(&BrowseQuery::Index { filter_system_id: Some("GJ-1".to_string()), }) .expect("encode"); assert_eq!(index_json, "{\"Index\":{\"filter_system_id\":\"GJ-1\"}}"); let detail_json = serde_json::to_string(&BrowseQuery::Detail { id: "GJ1c".to_string(), }) .expect("encode"); assert_eq!(detail_json, "{\"Detail\":{\"id\":\"GJ1c\"}}"); } #[test] fn browse_status_unit_variants_are_bare_strings_error_is_a_map() { assert_eq!( serde_json::to_string(&BrowseStatus::Ready).unwrap(), "\"Ready\"" ); assert_eq!( serde_json::to_string(&BrowseStatus::NotFound).unwrap(), "\"NotFound\"" ); assert_eq!( serde_json::to_string(&BrowseStatus::Error("boom".to_string())).unwrap(), "{\"Error\":\"boom\"}" ); } #[test] fn browse_request_round_trips_msgpack() { let req = index_req(BrowseEntityKind::Corporation); let bytes = rmp_serde::to_vec_named(&req).expect("encode"); let decoded: BrowseRequest = rmp_serde::from_slice(&bytes).expect("decode"); assert!(decoded.browse); assert_eq!(decoded.kind, BrowseEntityKind::Corporation); assert!(matches!(decoded.query, BrowseQuery::Index { .. })); let req = detail_req(BrowseEntityKind::Body, "GJ1c"); let bytes = rmp_serde::to_vec_named(&req).expect("encode"); let decoded: BrowseRequest = rmp_serde::from_slice(&bytes).expect("decode"); match decoded.query { BrowseQuery::Detail { id } => assert_eq!(id, "GJ1c"), other => panic!("expected Detail, got {other:?}"), } } #[test] fn browse_response_round_trips_msgpack() { let resp = BrowseResponse { kind: BrowseEntityKind::Commodity, status: BrowseStatus::Ready, index: Some(vec![BrowseIndexRow { id: "fusion_fuel".into(), primary: "Fusion Fuel".into(), secondary: Some("intermediate".into()), }]), detail: None, }; let bytes = rmp_serde::to_vec_named(&resp).expect("encode"); let decoded: BrowseResponse = rmp_serde::from_slice(&bytes).expect("decode"); assert_eq!(decoded.status, BrowseStatus::Ready); assert_eq!(decoded.index.unwrap()[0].primary, "Fusion Fuel"); } // ─── decode_inbound demux: BrowseRequest is disambiguated from the // other four shapes, and doesn't collide with CityNamesRequest despite // both being maps with no shared required field ────────────────────── #[test] fn decode_inbound_routes_browse_requests() { let req = detail_req(BrowseEntityKind::StarSystem, "GJ-1"); let frame = rmp_serde::to_vec_named(&req).unwrap(); let decoded = crate::bridge::decode_inbound(&frame).expect("decode"); assert!(matches!( decoded, crate::bridge::Inbound::BrowseRequest(r) if r.kind == BrowseEntityKind::StarSystem )); } #[test] fn decode_inbound_browse_does_not_collide_with_city_names() { // A BrowseRequest frame has no body_id/city_names/star_map/up_to keys // at all, so it must decode as BrowseRequest, never accidentally as // one of the other four shapes. let req = index_req(BrowseEntityKind::Station); let frame = rmp_serde::to_vec_named(&req).unwrap(); assert!( rmp_serde::from_slice::(&frame) .is_err() ); assert!( rmp_serde::from_slice::(&frame).is_err() ); } // ─── Handler dispatch (D-254 §4 six kinds x index/detail) ──────────── #[test] fn handle_browse_request_no_reader_is_error_for_every_kind() { for kind in [ BrowseEntityKind::StarSystem, BrowseEntityKind::Body, BrowseEntityKind::Station, BrowseEntityKind::Corporation, BrowseEntityKind::Commodity, BrowseEntityKind::TraitTemplate, ] { let resp = handle_browse_request(&index_req(kind), None); assert!( matches!(resp.status, BrowseStatus::Error(_)), "kind {kind:?} should error without a reader" ); assert!(resp.index.is_none()); assert!(resp.detail.is_none()); } } #[test] fn handle_browse_request_index_and_detail_for_every_kind() { let db = make_fixture_db(); let reader = BrowseReader::open(&db).expect("open"); // (kind, a known-good detail id) — proves both forms work for all // six entity kinds end-to-end through the handler, not just the // reader layer. 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 { let index_resp = handle_browse_request(&index_req(kind), Some(&reader)); assert_eq!(index_resp.status, BrowseStatus::Ready, "index({kind:?})"); assert_eq!(index_resp.kind, kind); let rows = index_resp.index.expect("index populated"); assert!(!rows.is_empty(), "index({kind:?}) should be non-empty"); assert!( rows.iter().any(|r| r.id == id), "index({kind:?}) should contain id {id}" ); let detail_resp = handle_browse_request(&detail_req(kind, id), Some(&reader)); assert_eq!(detail_resp.status, BrowseStatus::Ready, "detail({kind:?})"); assert_eq!(detail_resp.kind, kind); assert!( detail_resp.detail.is_some(), "detail({kind:?}) should be populated" ); } } #[test] fn handle_browse_request_body_index_honors_filter_system_id() { let db = make_fixture_db(); let reader = BrowseReader::open(&db).expect("open"); let req = BrowseRequest { browse: true, kind: BrowseEntityKind::Body, query: BrowseQuery::Index { filter_system_id: Some("GJ-1".to_string()), }, }; let resp = handle_browse_request(&req, Some(&reader)); let rows = resp.index.expect("index"); assert_eq!(rows.len(), 2, "only GJ-1's bodies"); assert!(rows.iter().all(|r| r.id != "GJ2b")); } #[test] fn handle_browse_request_detail_unknown_id_is_not_found_for_every_kind() { let db = make_fixture_db(); let reader = BrowseReader::open(&db).expect("open"); for kind in [ BrowseEntityKind::StarSystem, BrowseEntityKind::Body, BrowseEntityKind::Station, BrowseEntityKind::Corporation, BrowseEntityKind::Commodity, BrowseEntityKind::TraitTemplate, ] { let resp = handle_browse_request(&detail_req(kind, "no-such-id"), Some(&reader)); assert_eq!( resp.status, BrowseStatus::NotFound, "detail({kind:?}, unknown id) should be NotFound" ); assert!(resp.detail.is_none()); } } #[test] fn handle_browse_request_index_on_kind_with_no_rows_is_ready_empty() { // Fixture db's stations table has one row for every OTHER test, but a // freshly created db with the table present and zero rows must still // be Ready+empty, not an error — matches CityNamesStatus's // "unknown/empty -> Ready, not NotFound" convention (see // browse_reader::tests::index_stations_empty_table_is_empty_vec for // the reader-layer proof; this is the handler-layer proof). let db = make_fixture_db(); let reader = BrowseReader::open(&db).expect("open"); // trait_templates has exactly one row in the fixture; assert Ready // with that one row rather than empty, to also prove non-empty index // requests thread through status correctly (empty case already // covered at the reader layer). let resp = handle_browse_request(&index_req(BrowseEntityKind::TraitTemplate), Some(&reader)); assert_eq!(resp.status, BrowseStatus::Ready); assert_eq!(resp.index.unwrap().len(), 1); } #[test] fn browse_detail_kind_and_field_shape_matches_dispatched_kind() { // A StarSystem detail request must produce a BrowseDetail::StarSystem // variant carrying that system's actual data — proves the per-kind // BrowseDetail wrapping (not just the top-level status/kind) is wired // correctly, not just that SOME detail came back. let db = make_fixture_db(); let reader = BrowseReader::open(&db).expect("open"); let resp = handle_browse_request( &detail_req(BrowseEntityKind::StarSystem, "GJ-1"), Some(&reader), ); match resp.detail { Some(BrowseDetail::StarSystem(d)) => { assert_eq!(d.system_id, "GJ-1"); assert_eq!(d.proper_name.as_deref(), Some("Aldren")); } other => panic!("expected BrowseDetail::StarSystem, got {other:?}"), } } }