diff --git a/openfut-adapter-fifa17/src/fut/item.rs b/openfut-adapter-fifa17/src/fut/item.rs index 054b620..614fcad 100644 --- a/openfut-adapter-fifa17/src/fut/item.rs +++ b/openfut-adapter-fifa17/src/fut/item.rs @@ -87,7 +87,11 @@ fn discard_value(rating: u8) -> i64 { /// identical by construction. `id` is the owned instance's resolved FIFA /// identity — pass the resolver's answer for *this* owned copy so two copies of /// one definition stay distinct on the wire. -pub fn shape_item(item: &CoreOwnedItem, id: Fifa17Identity, ent: &impl ReverseEntityResolver) -> Value { +pub fn shape_item( + item: &CoreOwnedItem, + id: Fifa17Identity, + ent: &impl ReverseEntityResolver, +) -> Value { let asset = id.asset_id; let league_id = ent.league_id(&item.league).unwrap_or(0); let team_id = ent.team_id(&item.club).unwrap_or(0); @@ -155,13 +159,19 @@ mod tests { let ent = entities(); let it = shape_item( &item("oc1", "card_ch_1", 86, "CDM"), - Fifa17Identity { item_id: 100000001, asset_id: 20801 }, + Fifa17Identity { + item_id: 100000001, + asset_id: 20801, + }, &ent, ); assert_eq!(it["id"], 100000001, "wire instance id"); assert_eq!(it["resourceId"], 20801); assert_eq!(it["assetId"], 20801); - assert_eq!(it["definitionId"], 20801, "version byte 0 => resourceId==assetId==definitionId"); + assert_eq!( + it["definitionId"], 20801, + "version byte 0 => resourceId==assetId==definitionId" + ); assert_eq!(it["rating"], 86); assert_eq!(it["preferredPosition"], "CDM"); assert_eq!(it["leagueId"], 13); @@ -180,16 +190,28 @@ mod tests { let ent = entities(); let a = shape_item( &item("oc-a", "fifa17_101490", 84, "ST"), - Fifa17Identity { item_id: 100000030, asset_id: 101490 }, + Fifa17Identity { + item_id: 100000030, + asset_id: 101490, + }, &ent, ); let b = shape_item( &item("oc-b", "fifa17_101490", 84, "ST"), - Fifa17Identity { item_id: 100000031, asset_id: 101490 }, + Fifa17Identity { + item_id: 100000031, + asset_id: 101490, + }, &ent, ); - assert_eq!(a["resourceId"], b["resourceId"], "same definition => same asset"); - assert_ne!(a["id"], b["id"], "distinct owned copies keep distinct wire ids"); + assert_eq!( + a["resourceId"], b["resourceId"], + "same definition => same asset" + ); + assert_ne!( + a["id"], b["id"], + "distinct owned copies keep distinct wire ids" + ); assert_eq!(a["id"], 100000030); assert_eq!(b["id"], 100000031); } diff --git a/openfut-adapter-fifa17/src/fut/mod.rs b/openfut-adapter-fifa17/src/fut/mod.rs index d4a43ff..4567d21 100644 --- a/openfut-adapter-fifa17/src/fut/mod.rs +++ b/openfut-adapter-fifa17/src/fut/mod.rs @@ -6,8 +6,8 @@ //! socket — a Rust UTAS host wires it to Core later. pub mod catalog; pub mod club_response; -pub mod item; pub mod entities; +pub mod item; pub mod owned_query; pub mod squad; pub mod squad_ext; diff --git a/openfut-adapter-fifa17/src/fut/squad_ext.rs b/openfut-adapter-fifa17/src/fut/squad_ext.rs index 447dbed..c034c3f 100644 --- a/openfut-adapter-fifa17/src/fut/squad_ext.rs +++ b/openfut-adapter-fifa17/src/fut/squad_ext.rs @@ -51,7 +51,10 @@ pub struct WireItemRef { impl From<&SquadEntityRef> for WireItemRef { fn from(r: &SquadEntityRef) -> Self { - WireItemRef { id: r.id, dream: r.dream } + WireItemRef { + id: r.id, + dream: r.dream, + } } } @@ -135,7 +138,10 @@ impl Fifa17SquadExtensionV1 { .iter() .map(|k| KicktakerRef { index: k.index, - item: WireItemRef { id: k.id, dream: k.dream }, + item: WireItemRef { + id: k.id, + dream: k.dream, + }, }) .collect(), client_reported: ClientReportedSquadEval { @@ -213,16 +219,23 @@ pub fn build_squad_write( ) -> Result { let canonical = crate::fut::squad::to_proposed(put, resolver); if !canonical.unresolved_wire_ids.is_empty() { - return Err(SquadBuildError::UnresolvedWireIds(canonical.unresolved_wire_ids.clone())); + return Err(SquadBuildError::UnresolvedWireIds( + canonical.unresolved_wire_ids.clone(), + )); } let mut seen = std::collections::HashSet::new(); for slot in &canonical.slots { if !seen.insert(slot.owned_card_id.as_str()) { - return Err(SquadBuildError::DuplicateOwnedItem(slot.owned_card_id.clone())); + return Err(SquadBuildError::DuplicateOwnedItem( + slot.owned_card_id.clone(), + )); } } let extension = Fifa17SquadExtensionV1::from_put(put, &canonical); - Ok(SquadWriteBuild { canonical, extension }) + Ok(SquadWriteBuild { + canonical, + extension, + }) } #[cfg(test)] @@ -266,7 +279,8 @@ mod tests { let ext = built().extension; assert_eq!(ext.custom, put.custom, "opaque custom carried verbatim"); // survives a serialize/parse cycle unchanged. - let back = Fifa17SquadExtensionV1::from_payload(EXT_SCHEMA_VERSION, &ext.to_payload()).unwrap(); + let back = + Fifa17SquadExtensionV1::from_payload(EXT_SCHEMA_VERSION, &ext.to_payload()).unwrap(); assert_eq!(back.custom, put.custom); } @@ -285,14 +299,24 @@ mod tests { let ext = built().extension; assert_eq!( ext.client_reported, - ClientReportedSquadEval { chemistry: Some(52), rating: Some(90), star_rating: Some(90) } + ClientReportedSquadEval { + chemistry: Some(52), + rating: Some(90), + star_rating: Some(90) + } ); } #[test] fn manager_and_kicktakers_preserved_opaquely() { let ext = built().extension; - assert_eq!(ext.manager, vec![WireItemRef { id: 100000427, dream: false }]); + assert_eq!( + ext.manager, + vec![WireItemRef { + id: 100000427, + dream: false + }] + ); assert_eq!(ext.kicktakers.len(), 5); // All five reference the same wire id in this capture; carried verbatim, // NEVER normalized to the captain even though they coincide here. @@ -341,7 +365,10 @@ mod tests { m.insert(100000003, "oc-dup".to_string()); m.insert(100000004, "oc-dup".to_string()); // collide onto same owned id let err = build_squad_write(&put, &MapResolver(m)).unwrap_err(); - assert_eq!(err, SquadBuildError::DuplicateOwnedItem("oc-dup".to_string())); + assert_eq!( + err, + SquadBuildError::DuplicateOwnedItem("oc-dup".to_string()) + ); } #[test] @@ -350,6 +377,10 @@ mod tests { assert_eq!(build.canonical.slots.len(), 11, "whole squad, not a diff"); assert_eq!(build.canonical.formation.as_deref(), Some("f442")); // No FIFA wire integer survives into the canonical slots. - assert!(build.canonical.slots.iter().all(|s| s.owned_card_id.starts_with("oc-"))); + assert!(build + .canonical + .slots + .iter() + .all(|s| s.owned_card_id.starts_with("oc-"))); } } diff --git a/openfut-adapter-fifa17/src/fut/squad_projection.rs b/openfut-adapter-fifa17/src/fut/squad_projection.rs index 441caae..2bfc472 100644 --- a/openfut-adapter-fifa17/src/fut/squad_projection.rs +++ b/openfut-adapter-fifa17/src/fut/squad_projection.rs @@ -109,7 +109,10 @@ impl std::fmt::Display for SquadProjectError { write!(f, "projection input missing owned item {id}") } SquadProjectError::NoFifaIdentity(id) => { - write!(f, "owned item {id} has no real FIFA asset identity (cannot render)") + write!( + f, + "owned item {id} has no real FIFA asset identity (cannot render)" + ) } } } @@ -139,10 +142,9 @@ pub fn project_squad( for index in 0..FIFA17_SQUAD_SLOTS { match by_index.get(&index) { Some(slot) => { - let item = input - .owned - .get(&slot.owned_card_id) - .ok_or_else(|| SquadProjectError::MissingOwnedItem(slot.owned_card_id.clone()))?; + let item = input.owned.get(&slot.owned_card_id).ok_or_else(|| { + SquadProjectError::MissingOwnedItem(slot.owned_card_id.clone()) + })?; let id = ident .resolve(item) .ok_or_else(|| SquadProjectError::NoFifaIdentity(slot.owned_card_id.clone()))?; @@ -150,7 +152,11 @@ pub fn project_squad( captain_wire = id.item_id as i64; } // kit follows the player: look it up by owned id, never by index. - let kit = ext.kit_numbers.get(&slot.owned_card_id).copied().unwrap_or(0); + let kit = ext + .kit_numbers + .get(&slot.owned_card_id) + .copied() + .unwrap_or(0); players.push(json!({ "index": index, "itemData": shape_item(item, id, ent), @@ -278,14 +284,24 @@ mod tests { owned.insert("oc1".to_string(), owned_item("oc1", "card_x")); let ident = TableIdentity(HashMap::from([( "oc1".to_string(), - Fifa17Identity { item_id: 100000042, asset_id: 20801 }, + Fifa17Identity { + item_id: 100000042, + asset_id: 20801, + }, )])); let input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext())); let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else { panic!("expected Projected"); }; - assert_eq!(v["players"].as_array().unwrap().len(), 23, "fixed 23-slot array"); - assert_eq!(v["players"][0]["itemData"]["id"], 100000042, "wire id, not resourceId"); + assert_eq!( + v["players"].as_array().unwrap().len(), + 23, + "fixed 23-slot array" + ); + assert_eq!( + v["players"][0]["itemData"]["id"], 100000042, + "wire id, not resourceId" + ); assert_eq!(v["players"][0]["itemData"]["resourceId"], 20801); assert_eq!(v["players"][0]["kitNumber"], 9, "kit from ext by owned id"); assert_eq!(v["players"][1]["itemData"]["id"], 0, "empty slot"); @@ -301,7 +317,10 @@ mod tests { // A stale extension IS carried (host may log it) but must not be applied. let input = one_slot_input(&owned, SquadExtInput::Stale(fresh_ext())); let ident = TableIdentity(HashMap::new()); - assert_eq!(project_squad(&input, &ident, &ent()).unwrap(), SquadProjection::Stale); + assert_eq!( + project_squad(&input, &ident, &ent()).unwrap(), + SquadProjection::Stale + ); } #[test] @@ -309,7 +328,10 @@ mod tests { let owned = HashMap::new(); let input = one_slot_input(&owned, SquadExtInput::Missing); let ident = TableIdentity(HashMap::new()); - assert_eq!(project_squad(&input, &ident, &ent()).unwrap(), SquadProjection::Missing); + assert_eq!( + project_squad(&input, &ident, &ent()).unwrap(), + SquadProjection::Missing + ); } #[test] @@ -332,21 +354,46 @@ mod tests { owned.insert("oc-a".to_string(), owned_item("oc-a", "fifa17_101490")); owned.insert("oc-b".to_string(), owned_item("oc-b", "fifa17_101490")); let ident = TableIdentity(HashMap::from([ - ("oc-a".to_string(), Fifa17Identity { item_id: 100000030, asset_id: 101490 }), - ("oc-b".to_string(), Fifa17Identity { item_id: 100000031, asset_id: 101490 }), + ( + "oc-a".to_string(), + Fifa17Identity { + item_id: 100000030, + asset_id: 101490, + }, + ), + ( + "oc-b".to_string(), + Fifa17Identity { + item_id: 100000031, + asset_id: 101490, + }, + ), ])); let mut kit = std::collections::BTreeMap::new(); kit.insert("oc-a".to_string(), 7); kit.insert("oc-b".to_string(), 19); - let ext = Fifa17SquadExtensionV1 { kit_numbers: kit, ..fresh_ext() }; + let ext = Fifa17SquadExtensionV1 { + kit_numbers: kit, + ..fresh_ext() + }; let owned_ref = &owned; let input = SquadProjectionInput { fifa_squad_id: 0, name: "OpenFUT".into(), formation: "f442".into(), slots: vec![ - ProjectionSlot { owned_card_id: "oc-a".into(), index: 0, is_captain: false, is_on_bench: false }, - ProjectionSlot { owned_card_id: "oc-b".into(), index: 1, is_captain: false, is_on_bench: false }, + ProjectionSlot { + owned_card_id: "oc-a".into(), + index: 0, + is_captain: false, + is_on_bench: false, + }, + ProjectionSlot { + owned_card_id: "oc-b".into(), + index: 1, + is_captain: false, + is_on_bench: false, + }, ], ext: SquadExtInput::Fresh(ext), owned: owned_ref, @@ -356,9 +403,18 @@ mod tests { }; let a = &v["players"][0]["itemData"]; let b = &v["players"][1]["itemData"]; - assert_eq!(a["resourceId"], b["resourceId"], "same definition => same asset"); - assert_ne!(a["id"], b["id"], "distinct owned copies keep distinct wire ids"); + assert_eq!( + a["resourceId"], b["resourceId"], + "same definition => same asset" + ); + assert_ne!( + a["id"], b["id"], + "distinct owned copies keep distinct wire ids" + ); assert_eq!(v["players"][0]["kitNumber"], 7); - assert_eq!(v["players"][1]["kitNumber"], 19, "kit stays with the instance"); + assert_eq!( + v["players"][1]["kitNumber"], 19, + "kit stays with the instance" + ); } } diff --git a/openfut-adapter-fifa17/tests/squad_projection.rs b/openfut-adapter-fifa17/tests/squad_projection.rs index 10571dd..97a576e 100644 --- a/openfut-adapter-fifa17/tests/squad_projection.rs +++ b/openfut-adapter-fifa17/tests/squad_projection.rs @@ -60,9 +60,15 @@ impl ItemIdentityResolver for TableIdentity { /// projector round-trip asserts item *identity* (wire id + asset), not entity ids. struct NoEntities; impl openfut_adapter_fifa17::fut::entities::ReverseEntityResolver for NoEntities { - fn league_id(&self, _: &str) -> Option { None } - fn team_id(&self, _: &str) -> Option { None } - fn nation_id(&self, _: &str) -> Option { None } + fn league_id(&self, _: &str) -> Option { + None + } + fn team_id(&self, _: &str) -> Option { + None + } + fn nation_id(&self, _: &str) -> Option { + None + } } /// Build the owned-item map + identity table from the persisted read oracle, so @@ -99,7 +105,13 @@ fn oracle_tables() -> (HashMap, TableIdentity) { attributes: [attrs[0], attrs[1], attrs[2], attrs[3], attrs[4], attrs[5]], }, ); - ident.insert(oc, Fifa17Identity { item_id: wire as u32, asset_id: asset }); + ident.insert( + oc, + Fifa17Identity { + item_id: wire as u32, + asset_id: asset, + }, + ); } (owned, TableIdentity(ident)) } @@ -111,8 +123,10 @@ fn project_put( owned: &HashMap, ident: &TableIdentity, ) -> Value { - let SquadWriteBuild { canonical, extension } = - build_squad_write(put, &OcResolver).expect("build must succeed for a full valid squad"); + let SquadWriteBuild { + canonical, + extension, + } = build_squad_write(put, &OcResolver).expect("build must succeed for a full valid squad"); let slots: Vec = canonical .slots .iter() @@ -148,7 +162,10 @@ fn occupied(v: &Value) -> HashMap { .map(|p| { ( p["index"].as_i64().unwrap(), - (p["itemData"]["id"].as_i64().unwrap(), p["kitNumber"].as_i64().unwrap()), + ( + p["itemData"]["id"].as_i64().unwrap(), + p["kitNumber"].as_i64().unwrap(), + ), ) }) .collect() @@ -163,11 +180,18 @@ fn baseline_projects_the_known_squad_round_trip() { // Fixed 23-slot array; 11 occupied at 0..=10. assert_eq!(projected["players"].as_array().unwrap().len(), 23); let put_v: Value = serde_json::from_str(PUT_BASELINE).unwrap(); - assert_eq!(occupied(&projected), occupied(&put_v), "wire id + kit per index round-trip"); + assert_eq!( + occupied(&projected), + occupied(&put_v), + "wire id + kit per index round-trip" + ); // CANONICAL: formation verbatim, captain follows the semantic player. assert_eq!(projected["formation"], "f442"); - assert_eq!(projected["captain"], 100000001, "captain is the player's WIRE id"); + assert_eq!( + projected["captain"], 100000001, + "captain is the player's WIRE id" + ); // EXTENSION: custom byte-identical, manager + squadType preserved. assert_eq!(projected["custom"], put_v["custom"]); assert_eq!(projected["manager"], put_v["manager"]); @@ -184,12 +208,20 @@ fn swap_moves_two_players_with_their_kits_and_round_trips() { // build(swap) must reproduce the swap wire exactly, and the affected players' // kit numbers must have travelled with them (kit follows the player). let (owned, ident) = oracle_tables(); - let projected = project_put(&parse_squad_put(PUT_SWAP.as_bytes()).unwrap(), &owned, &ident); + let projected = project_put( + &parse_squad_put(PUT_SWAP.as_bytes()).unwrap(), + &owned, + &ident, + ); let swap_v: Value = serde_json::from_str(PUT_SWAP).unwrap(); let base_v: Value = serde_json::from_str(PUT_BASELINE).unwrap(); // CANONICAL: the projected occupancy per index matches the swap PUT exactly. - assert_eq!(occupied(&projected), occupied(&swap_v), "player+kit per index round-trip"); + assert_eq!( + occupied(&projected), + occupied(&swap_v), + "player+kit per index round-trip" + ); // The swap is real: at least two indices carry a different player than baseline. let (proj_occ, base_occ) = (occupied(&projected), occupied(&base_v)); @@ -198,7 +230,10 @@ fn swap_moves_two_players_with_their_kits_and_round_trips() { .filter(|(idx, pair)| base_occ.get(idx).map(|b| b.0) != Some(pair.0)) .map(|(idx, _)| *idx) .collect(); - assert!(moved.len() >= 2, "a swap changes at least two slots, got {moved:?}"); + assert!( + moved.len() >= 2, + "a swap changes at least two slots, got {moved:?}" + ); // kit follows the PLAYER: for every player, its kit in baseline == its kit // in the swap projection, regardless of which slot it now occupies. @@ -211,8 +246,14 @@ fn swap_moves_two_players_with_their_kits_and_round_trips() { "each player kept its kit number through the swap" ); - assert_eq!(projected["captain"], 100000001, "captain follows the semantic player"); - assert_eq!(projected["custom"], swap_v["custom"], "opaque custom unchanged by the swap"); + assert_eq!( + projected["captain"], 100000001, + "captain follows the semantic player" + ); + assert_eq!( + projected["custom"], swap_v["custom"], + "opaque custom unchanged by the swap" + ); // SHADOW: the client-reported chemistry from THIS PUT (58) is round-tripped // as-is — never reconciled to a server recompute. assert_eq!(projected["chemistry"], 58); @@ -225,7 +266,9 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() { // evidence, then project and require the read back — the strongest fidelity // check across all four ownership classes. use openfut_adapter_fifa17::fut::squad::ClientReportedSquadEval; - use openfut_adapter_fifa17::fut::squad_ext::{Fifa17SquadExtensionV1, KicktakerRef, WireItemRef}; + use openfut_adapter_fifa17::fut::squad_ext::{ + Fifa17SquadExtensionV1, KicktakerRef, WireItemRef, + }; use std::collections::BTreeMap; let oracle: Value = serde_json::from_str(READ_ORACLE).unwrap(); @@ -250,7 +293,8 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() { }); } let manager: Vec = serde_json::from_value(oracle["manager"].clone()).unwrap(); - let kicktakers: Vec = serde_json::from_value(oracle["kicktakers"].clone()).unwrap(); + let kicktakers: Vec = + serde_json::from_value(oracle["kicktakers"].clone()).unwrap(); let ext = Fifa17SquadExtensionV1 { custom: oracle["custom"].as_str().map(str::to_string), squad_type: oracle["squadType"].as_str().map(str::to_string), @@ -277,14 +321,32 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() { }; // CANONICAL + DERIVED: identity and placement per slot match the read. - assert_eq!(occupied(&projected), occupied(&oracle), "player+kit per index"); + assert_eq!( + occupied(&projected), + occupied(&oracle), + "player+kit per index" + ); assert_eq!(projected["captain"], oracle["captain"]); assert_eq!(projected["formation"], oracle["formation"]); - for (pp, op) in projected["players"].as_array().unwrap().iter().zip(oracle["players"].as_array().unwrap()) { - assert_eq!(pp["itemData"]["id"], op["itemData"]["id"], "wire id per slot"); - assert_eq!(pp["itemData"]["resourceId"], op["itemData"]["resourceId"], "asset id per slot"); + for (pp, op) in projected["players"] + .as_array() + .unwrap() + .iter() + .zip(oracle["players"].as_array().unwrap()) + { + assert_eq!( + pp["itemData"]["id"], op["itemData"]["id"], + "wire id per slot" + ); + assert_eq!( + pp["itemData"]["resourceId"], op["itemData"]["resourceId"], + "asset id per slot" + ); assert_eq!(pp["itemData"]["rating"], op["itemData"]["rating"]); - assert_eq!(pp["itemData"]["preferredPosition"], op["itemData"]["preferredPosition"]); + assert_eq!( + pp["itemData"]["preferredPosition"], + op["itemData"]["preferredPosition"] + ); } // EXTENSION + SHADOW: sourced from the read, so they round-trip identically. assert_eq!(projected["custom"], oracle["custom"]); @@ -299,8 +361,16 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() { #[test] fn formation_change_reindexes_without_deriving_slots_and_kit_follows_player() { let (owned, ident) = oracle_tables(); - let swap = project_put(&parse_squad_put(PUT_SWAP.as_bytes()).unwrap(), &owned, &ident); - let f433 = project_put(&parse_squad_put(PUT_F433.as_bytes()).unwrap(), &owned, &ident); + let swap = project_put( + &parse_squad_put(PUT_SWAP.as_bytes()).unwrap(), + &owned, + &ident, + ); + let f433 = project_put( + &parse_squad_put(PUT_F433.as_bytes()).unwrap(), + &owned, + &ident, + ); assert_eq!(f433["formation"], "f433"); assert_eq!(swap["formation"], "f442"); @@ -311,29 +381,50 @@ fn formation_change_reindexes_without_deriving_slots_and_kit_follows_player() { ids.sort(); ids }; - assert_eq!(set(&swap), set(&f433), "same 11 players survive the formation change"); + assert_eq!( + set(&swap), + set(&f433), + "same 11 players survive the formation change" + ); // The captain (wire 100000001) moved from index 8 (f442) to index 10 (f433) — // proof indices are round-tripped, not derived from the formation. let idx_of = |v: &Value, wire: i64| -> i64 { - occupied(v).into_iter().find(|(_, (id, _))| *id == wire).unwrap().0 + occupied(v) + .into_iter() + .find(|(_, (id, _))| *id == wire) + .unwrap() + .0 }; assert_eq!(idx_of(&swap, 100000001), 8); assert_eq!(idx_of(&f433, 100000001), 10); // kit follows the PLAYER, not the slot: captain keeps kit 8 across the reindex. let kit_of = |v: &Value, wire: i64| -> i64 { - occupied(v).into_iter().find(|(_, (id, _))| *id == wire).unwrap().1 .1 + occupied(v) + .into_iter() + .find(|(_, (id, _))| *id == wire) + .unwrap() + .1 + .1 }; assert_eq!(kit_of(&swap, 100000001), 8); - assert_eq!(kit_of(&f433, 100000001), 8, "kit stayed with the player despite the reindex"); + assert_eq!( + kit_of(&f433, 100000001), + 8, + "kit stayed with the player despite the reindex" + ); assert_eq!(f433["captain"], 100000001, "captain still the same player"); } #[test] fn one_projector_serves_every_endpoint_no_divergence() { let (owned, ident) = oracle_tables(); - let projected = project_put(&parse_squad_put(PUT_SWAP.as_bytes()).unwrap(), &owned, &ident); + let projected = project_put( + &parse_squad_put(PUT_SWAP.as_bytes()).unwrap(), + &owned, + &ident, + ); // userMassInfo.squad = the projected object + session envelope. let ummi = user_mass_info_squad(projected.clone(), 33068179); @@ -346,8 +437,18 @@ fn one_projector_serves_every_endpoint_no_divergence() { // squad/list = a summary SUBSET of the SAME object, not a second projection. let list = squad_list(&projected); let entry = &list["squad"][0]; - for k in ["id", "squadName", "formation", "squadType", "rating", "chemistry"] { - assert_eq!(entry[k], projected[k], "summary field {k} derived from the one projection"); + for k in [ + "id", + "squadName", + "formation", + "squadType", + "rating", + "chemistry", + ] { + assert_eq!( + entry[k], projected[k], + "summary field {k} derived from the one projection" + ); } // The summary carries only those six keys — no divergent squad shape. assert_eq!(entry.as_object().unwrap().len(), 6); diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index b55cb20..74b6438 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -164,8 +164,14 @@ pub struct CoreSquadSlot { /// when Stale — the host decides policy). #[derive(Debug, Clone)] pub enum CoreExtState { - Fresh { schema_version: i64, payload: String }, - Stale { schema_version: i64, payload: String }, + Fresh { + schema_version: i64, + payload: String, + }, + Stale { + schema_version: i64, + payload: String, + }, Missing, } @@ -299,7 +305,11 @@ impl CoreAccess for HttpCoreClient { } let v: Value = resp.json().map_err(|e| CoreError::Parse(e.to_string()))?; Ok(CoreReplaceResult { - squad_id: v.get("squad_id").and_then(|x| x.as_str()).unwrap_or("").to_string(), + squad_id: v + .get("squad_id") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(), canonical_fingerprint: v .get("canonical_fingerprint") .and_then(|x| x.as_str()) @@ -343,8 +353,14 @@ pub fn replace_request_body(req: &CoreReplaceRequest) -> Value { /// Parse Core's `GET /squad/ext` response into a [`CoreSquadRead`]. pub fn parse_core_squad_read(v: &Value) -> Result { - let squad = v.get("squad").ok_or_else(|| CoreError::Parse("missing `squad`".into()))?; - let name = squad.get("name").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let squad = v + .get("squad") + .ok_or_else(|| CoreError::Parse("missing `squad`".into()))?; + let name = squad + .get("name") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(); let formation = squad .get("formation") .and_then(|x| x.as_str()) @@ -360,25 +376,56 @@ pub fn parse_core_squad_read(v: &Value) -> Result { Some(CoreSquadSlot { owned_card_id: p.get("owned_card_id")?.as_str()?.to_string(), index: p.get("position_index")?.as_i64()?, - is_captain: p.get("is_captain").and_then(|x| x.as_bool()).unwrap_or(false), - is_on_bench: p.get("is_on_bench").and_then(|x| x.as_bool()).unwrap_or(false), + is_captain: p + .get("is_captain") + .and_then(|x| x.as_bool()) + .unwrap_or(false), + is_on_bench: p + .get("is_on_bench") + .and_then(|x| x.as_bool()) + .unwrap_or(false), }) }) .collect(); - let ext_v = v.get("extension").ok_or_else(|| CoreError::Parse("missing `extension`".into()))?; + let ext_v = v + .get("extension") + .ok_or_else(|| CoreError::Parse("missing `extension`".into()))?; let ext = match ext_v.get("state").and_then(|x| x.as_str()) { Some("fresh") => CoreExtState::Fresh { - schema_version: ext_v.get("schema_version").and_then(|x| x.as_i64()).unwrap_or(0), - payload: ext_v.get("payload").and_then(|x| x.as_str()).unwrap_or("").to_string(), + schema_version: ext_v + .get("schema_version") + .and_then(|x| x.as_i64()) + .unwrap_or(0), + payload: ext_v + .get("payload") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(), }, Some("stale") => CoreExtState::Stale { - schema_version: ext_v.get("schema_version").and_then(|x| x.as_i64()).unwrap_or(0), - payload: ext_v.get("payload").and_then(|x| x.as_str()).unwrap_or("").to_string(), + schema_version: ext_v + .get("schema_version") + .and_then(|x| x.as_i64()) + .unwrap_or(0), + payload: ext_v + .get("payload") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(), }, Some("missing") => CoreExtState::Missing, - other => return Err(CoreError::Parse(format!("unknown extension state {other:?}"))), + other => { + return Err(CoreError::Parse(format!( + "unknown extension state {other:?}" + ))) + } }; - Ok(CoreSquadRead { name, formation, slots, ext }) + Ok(CoreSquadRead { + name, + formation, + slots, + ext, + }) } /// Parse Core's `/collection` response `{ "collection": [...], "total": n }` into @@ -655,7 +702,10 @@ fn project_active_squad(deps: &SquadDeps<'_>) -> HostProjection { Err(e) => return HostProjection::Error(e.to_string()), }; let ext = match &read.ext { - CoreExtState::Fresh { schema_version, payload } => { + CoreExtState::Fresh { + schema_version, + payload, + } => { match Fifa17SquadExtensionV1::from_payload(*schema_version, payload) { Ok(e) => e, // Fresh but the payload does not parse as our schema: corruption, @@ -670,8 +720,10 @@ fn project_active_squad(deps: &SquadDeps<'_>) -> HostProjection { Ok(v) => v, Err(e) => return HostProjection::Error(e.to_string()), }; - let owned_by_id: std::collections::HashMap = - owned.into_iter().map(|i| (i.owned_card_id.clone(), i)).collect(); + let owned_by_id: std::collections::HashMap = owned + .into_iter() + .map(|i| (i.owned_card_id.clone(), i)) + .collect(); let slots: Vec = read .slots .iter() @@ -718,7 +770,10 @@ pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, Squ Err(e) => { return ( error_response(400, "parse_error"), - SquadLog { outcome: "parse_error", detail: e.to_string() }, + SquadLog { + outcome: "parse_error", + detail: e.to_string(), + }, ) } }; @@ -729,13 +784,19 @@ pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, Squ Err(SquadBuildError::UnresolvedWireIds(ids)) => { return ( error_response(400, "unresolved_wire_ids"), - SquadLog { outcome: "unresolved_wire_ids", detail: format!("{ids:?}") }, + SquadLog { + outcome: "unresolved_wire_ids", + detail: format!("{ids:?}"), + }, ) } Err(SquadBuildError::DuplicateOwnedItem(id)) => { return ( error_response(400, "duplicate_owned_item"), - SquadLog { outcome: "duplicate_owned_item", detail: id }, + SquadLog { + outcome: "duplicate_owned_item", + detail: id, + }, ) } }; @@ -747,7 +808,10 @@ pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, Squ Err(e) => { return ( error_response(502, "core_error"), - SquadLog { outcome: "core_error", detail: e.to_string() }, + SquadLog { + outcome: "core_error", + detail: e.to_string(), + }, ) } }; @@ -755,7 +819,10 @@ pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, Squ if !owned_set.contains(&slot.owned_card_id) { return ( error_response(403, "not_owned"), - SquadLog { outcome: "unauthorized_item", detail: slot.owned_card_id.clone() }, + SquadLog { + outcome: "unauthorized_item", + detail: slot.owned_card_id.clone(), + }, ); } } @@ -786,11 +853,17 @@ pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, Squ match deps.core.replace_squad(&req) { Ok(_) => ( json_response(&save_ack(put.id)), - SquadLog { outcome: "ok", detail: String::new() }, + SquadLog { + outcome: "ok", + detail: String::new(), + }, ), Err(e) => ( error_response(502, "core_error"), - SquadLog { outcome: "core_error", detail: e.to_string() }, + SquadLog { + outcome: "core_error", + detail: e.to_string(), + }, ), } } @@ -802,19 +875,31 @@ pub fn handle_squad_list(deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) { match project_active_squad(deps) { HostProjection::Squad(v) => ( json_response(&squad_list(&v)), - SquadLog { outcome: "ok", detail: String::new() }, + SquadLog { + outcome: "ok", + detail: String::new(), + }, ), HostProjection::Stale => ( json_response(&json!({ "squad": [] })), - SquadLog { outcome: "stale_integrity", detail: "stale extension not applied".into() }, + SquadLog { + outcome: "stale_integrity", + detail: "stale extension not applied".into(), + }, ), HostProjection::Missing => ( json_response(&json!({ "squad": [] })), - SquadLog { outcome: "missing_integrity", detail: "no extension stored".into() }, + SquadLog { + outcome: "missing_integrity", + detail: "no extension stored".into(), + }, ), HostProjection::Error(e) => ( json_response(&json!({ "squad": [] })), - SquadLog { outcome: "core_error", detail: e }, + SquadLog { + outcome: "core_error", + detail: e, + }, ), } } @@ -841,8 +926,10 @@ fn set_json_body(resp: &mut WireResponse, body: Vec) { && !k.eq_ignore_ascii_case("content-type") && !k.eq_ignore_ascii_case("transfer-encoding") }); - resp.headers.push(("Content-Type".to_string(), "application/json".to_string())); - resp.headers.push(("Content-Length".to_string(), body.len().to_string())); + resp.headers + .push(("Content-Type".to_string(), "application/json".to_string())); + resp.headers + .push(("Content-Length".to_string(), body.len().to_string())); resp.body = body; } @@ -862,44 +949,83 @@ pub fn handle_user_mass_info( Err(e) => { return ( error_response(502, "upstream_unavailable"), - SquadLog { outcome: "python_unreachable", detail: e.to_string() }, + SquadLog { + outcome: "python_unreachable", + detail: e.to_string(), + }, ) } }; // Only a successful JSON object carrying `.squad` is overlaid; anything else // is returned verbatim (we never invent a squad into an unrelated response). if !(200..300).contains(&resp.status) { - return (resp, SquadLog { outcome: "python_non_2xx_passthrough", detail: String::new() }); + return ( + resp, + SquadLog { + outcome: "python_non_2xx_passthrough", + detail: String::new(), + }, + ); } let mut root: Value = match serde_json::from_slice::(&resp.body) { Ok(v) if v.is_object() => v, - _ => return (resp, SquadLog { outcome: "python_body_unusable_passthrough", detail: String::new() }), + _ => { + return ( + resp, + SquadLog { + outcome: "python_body_unusable_passthrough", + detail: String::new(), + }, + ) + } }; if root.get("squad").is_none() { - return (resp, SquadLog { outcome: "python_no_squad_passthrough", detail: String::new() }); + return ( + resp, + SquadLog { + outcome: "python_no_squad_passthrough", + detail: String::new(), + }, + ); } // Preserve the client's persona from Python's own response. let persona = root["squad"] .get("personaId") .and_then(|x| x.as_i64()) - .or_else(|| root.get("userInfo").and_then(|u| u.get("personaId")).and_then(|x| x.as_i64())) + .or_else(|| { + root.get("userInfo") + .and_then(|u| u.get("personaId")) + .and_then(|x| x.as_i64()) + }) .unwrap_or(0); let (squad_val, log) = match project_active_squad(deps) { HostProjection::Squad(v) => ( user_mass_info_squad(v, persona), - SquadLog { outcome: "ok", detail: String::new() }, + SquadLog { + outcome: "ok", + detail: String::new(), + }, ), HostProjection::Stale => ( empty_squad_overlay(persona), - SquadLog { outcome: "stale_integrity", detail: "stale extension not applied".into() }, + SquadLog { + outcome: "stale_integrity", + detail: "stale extension not applied".into(), + }, ), HostProjection::Missing => ( empty_squad_overlay(persona), - SquadLog { outcome: "missing_integrity", detail: "no extension stored".into() }, + SquadLog { + outcome: "missing_integrity", + detail: "no extension stored".into(), + }, ), HostProjection::Error(e) => ( empty_squad_overlay(persona), - SquadLog { outcome: "core_error", detail: e }, + SquadLog { + outcome: "core_error", + detail: e, + }, ), }; root["squad"] = squad_val; diff --git a/openfut-utas-host/tests/host_test.rs b/openfut-utas-host/tests/host_test.rs index 8ce1488..6cece42 100644 --- a/openfut-utas-host/tests/host_test.rs +++ b/openfut-utas-host/tests/host_test.rs @@ -13,10 +13,9 @@ use openfut_adapter_fifa17::fut::club_response::{CoreOwnedItem, ItemIdentityReso use openfut_adapter_fifa17::fut::entities::Fifa17Entities; use openfut_identity::JsonIdentityStore; use openfut_utas_host::{ - classify, handle_put_squad, handle_squad_list, handle_user_mass_info, read_request, - CoreAccess, CoreError, CoreExtState, CorePage, CoreReplaceRequest, - CoreReplaceResult, CoreSquadRead, CoreSquadSlot, Fifa17IdentityResolver, HttpCoreClient, - PassClient, Route, Server, SquadDeps, + classify, handle_put_squad, handle_squad_list, handle_user_mass_info, read_request, CoreAccess, + CoreError, CoreExtState, CorePage, CoreReplaceRequest, CoreReplaceResult, CoreSquadRead, + CoreSquadSlot, Fifa17IdentityResolver, HttpCoreClient, PassClient, Route, Server, SquadDeps, }; use parking_lot::Mutex; use serde_json::Value; @@ -53,13 +52,23 @@ struct FakeCore { impl FakeCore { fn new(items: Vec, total: i64) -> Self { - FakeCore { items, total, ..Default::default() } + FakeCore { + items, + total, + ..Default::default() + } } fn forbidden() -> Self { - FakeCore { panic_if_called: true, ..Default::default() } + FakeCore { + panic_if_called: true, + ..Default::default() + } } fn erroring() -> Self { - FakeCore { return_err: true, ..Default::default() } + FakeCore { + return_err: true, + ..Default::default() + } } /// Preset the stored squad read (for read-path tests without a prior PUT). fn with_squad(self, read: CoreSquadRead) -> Self { @@ -85,7 +94,10 @@ impl FakeCore { impl CoreAccess for FakeCore { fn query_owned(&self, params: &[(&str, String)]) -> Result { - assert!(!self.panic_if_called, "Core must NOT be called on this path"); + assert!( + !self.panic_if_called, + "Core must NOT be called on this path" + ); self.calls.fetch_add(1, Ordering::SeqCst); if self.return_err { return Err(CoreError::Status(500)); @@ -94,11 +106,17 @@ impl CoreAccess for FakeCore { .iter() .map(|(k, v)| (k.to_string(), v.clone())) .collect(); - Ok(CorePage { items: self.items.clone(), total: self.total }) + Ok(CorePage { + items: self.items.clone(), + total: self.total, + }) } fn read_squad_ext(&self, _namespace: &str) -> Result { - assert!(!self.panic_if_called, "Core must NOT be called on this path"); + assert!( + !self.panic_if_called, + "Core must NOT be called on this path" + ); self.read_calls.fetch_add(1, Ordering::SeqCst); if self.return_err { return Err(CoreError::Status(500)); @@ -107,7 +125,10 @@ impl CoreAccess for FakeCore { } fn replace_squad(&self, req: &CoreReplaceRequest) -> Result { - assert!(!self.panic_if_called, "Core must NOT be called on this path"); + assert!( + !self.panic_if_called, + "Core must NOT be called on this path" + ); self.replace_calls.fetch_add(1, Ordering::SeqCst); if self.return_err { return Err(CoreError::Status(500)); @@ -117,7 +138,14 @@ impl CoreAccess for FakeCore { slots: req .slots .iter() - .map(|s| (s.owned_card_id.clone(), s.index, s.is_captain, s.is_on_bench)) + .map(|s| { + ( + s.owned_card_id.clone(), + s.index, + s.is_captain, + s.is_on_bench, + ) + }) .collect(), ext_payload: req.ext_payload.clone(), chemistry: req.client_reported.chemistry, @@ -634,7 +662,6 @@ fn club_end_to_end_through_real_resolver_and_sends_game_header() { ); } - // ── Squad host: routing, PUT pipeline, authorization, coupled read-after-write ─ use serde_json::json; @@ -691,10 +718,26 @@ fn put_body(formation: &str, captain: i64, players: &[(i64, i64, i64)], custom: } fn gk() -> CoreOwnedItem { - item("oc-a", "card_a", 87, "GK", "Argentina", "Premier League", "Chelsea") + item( + "oc-a", + "card_a", + 87, + "GK", + "Argentina", + "Premier League", + "Chelsea", + ) } fn st() -> CoreOwnedItem { - item("oc-b", "card_b", 90, "ST", "Argentina", "Premier League", "Chelsea") + item( + "oc-b", + "card_b", + 90, + "ST", + "Argentina", + "Premier League", + "Chelsea", + ) } const ASSETS: &[(&str, u32)] = &[("card_a", 20801), ("card_b", 158023)]; @@ -714,7 +757,8 @@ fn spawn_mock_python_json(body: Value) -> (String, Recorded) { }; let mut r = BufReader::new(s.try_clone().unwrap()); if let Ok(Some(req)) = read_request(&mut r) { - rec2.lock().push((req.method.clone(), req.target.clone(), req.body.clone())); + rec2.lock() + .push((req.method.clone(), req.target.clone(), req.body.clone())); let head = format!( "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", text.len() @@ -731,16 +775,40 @@ fn spawn_mock_python_json(body: Value) -> (String, Recorded) { #[test] fn classify_squad_and_usermassinfo_routes() { - assert_eq!(classify("PUT", "/ut/game/fifa17/squad/0"), Route::SquadReplace); - assert_eq!(classify("PUT", "/ut/game/fifa17/squad/3"), Route::SquadReplace); - assert_eq!(classify("GET", "/ut/game/fifa17/squad/list"), Route::SquadList); - assert_eq!(classify("GET", "/ut/game/fifa17/userMassInfo"), Route::UserMassInfo); + assert_eq!( + classify("PUT", "/ut/game/fifa17/squad/0"), + Route::SquadReplace + ); + assert_eq!( + classify("PUT", "/ut/game/fifa17/squad/3"), + Route::SquadReplace + ); + assert_eq!( + classify("GET", "/ut/game/fifa17/squad/list"), + Route::SquadList + ); + assert_eq!( + classify("GET", "/ut/game/fifa17/userMassInfo"), + Route::UserMassInfo + ); // /squad/active stays with Python (not numeric); GET squad/ is NOT a Rust // read route; a squad PUT is never a GET. - assert_eq!(classify("PUT", "/ut/game/fifa17/squad/active"), Route::Passthrough); - assert_eq!(classify("GET", "/ut/game/fifa17/squad/active"), Route::Passthrough); - assert_eq!(classify("GET", "/ut/game/fifa17/squad/0"), Route::Passthrough); - assert_eq!(classify("PUT", "/ut/game/fifa17/squad/list"), Route::Passthrough); + assert_eq!( + classify("PUT", "/ut/game/fifa17/squad/active"), + Route::Passthrough + ); + assert_eq!( + classify("GET", "/ut/game/fifa17/squad/active"), + Route::Passthrough + ); + assert_eq!( + classify("GET", "/ut/game/fifa17/squad/0"), + Route::Passthrough + ); + assert_eq!( + classify("PUT", "/ut/game/fifa17/squad/list"), + Route::Passthrough + ); assert_eq!(classify("GET", "/ut/game/fifa17/club"), Route::Club); } @@ -752,8 +820,17 @@ fn put_full_replacement_commits_canonical_and_extension_and_acks_id0() { let (resolver, w) = resolver_with_wires(&items, ASSETS); let core = FakeCore::new(items.clone(), 2); let ent = entities(); - let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent }; - let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1), (1, w["oc-b"], 9)], "[1,2,3]"); + let deps = SquadDeps { + core: &core, + resolver: &resolver, + entities: &ent, + }; + let body = put_body( + "f442", + w["oc-a"], + &[(0, w["oc-a"], 1), (1, w["oc-b"], 9)], + "[1,2,3]", + ); let (resp, log) = handle_put_squad(&body, &deps); assert_eq!(resp.status, 200, "{log:?}"); @@ -782,8 +859,17 @@ fn put_rejects_wire_id_owned_by_another_profile_core_unchanged() { let (resolver, w) = resolver_with_wires(&items, ASSETS); let core = FakeCore::new(vec![gk()], 1); // only oc-a owned let ent = entities(); - let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent }; - let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1), (1, w["oc-b"], 9)], "[]"); + let deps = SquadDeps { + core: &core, + resolver: &resolver, + entities: &ent, + }; + let body = put_body( + "f442", + w["oc-a"], + &[(0, w["oc-a"], 1), (1, w["oc-b"], 9)], + "[]", + ); let (resp, log) = handle_put_squad(&body, &deps); assert_eq!(resp.status, 403, "resolvable != authorized"); @@ -798,9 +884,18 @@ fn put_rejects_unknown_wire_id() { let (resolver, w) = resolver_with_wires(&items, ASSETS); let core = FakeCore::new(items.clone(), 2); let ent = entities(); - let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent }; + let deps = SquadDeps { + core: &core, + resolver: &resolver, + entities: &ent, + }; // 999_999_999 was never allocated → unresolvable. - let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1), (1, 999_999_999, 9)], "[]"); + let body = put_body( + "f442", + w["oc-a"], + &[(0, w["oc-a"], 1), (1, 999_999_999, 9)], + "[]", + ); let (resp, log) = handle_put_squad(&body, &deps); assert_eq!(resp.status, 400); assert_eq!(log.outcome, "unresolved_wire_ids"); @@ -813,8 +908,17 @@ fn put_rejects_duplicate_owned_item() { let (resolver, w) = resolver_with_wires(&items, ASSETS); let core = FakeCore::new(items.clone(), 2); let ent = entities(); - let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent }; - let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1), (1, w["oc-a"], 9)], "[]"); + let deps = SquadDeps { + core: &core, + resolver: &resolver, + entities: &ent, + }; + let body = put_body( + "f442", + w["oc-a"], + &[(0, w["oc-a"], 1), (1, w["oc-a"], 9)], + "[]", + ); let (resp, log) = handle_put_squad(&body, &deps); assert_eq!(resp.status, 400); assert_eq!(log.outcome, "duplicate_owned_item"); @@ -828,7 +932,11 @@ fn put_core_failure_returns_error_never_python() { let (resolver, w) = resolver_with_wires(&items, ASSETS); let core = FakeCore::erroring(); let ent = entities(); - let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent }; + let deps = SquadDeps { + core: &core, + resolver: &resolver, + entities: &ent, + }; let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[]"); let (resp, log) = handle_put_squad(&body, &deps); assert_eq!(resp.status, 502); @@ -841,8 +949,17 @@ fn repeated_identical_put_is_idempotent() { let (resolver, w) = resolver_with_wires(&items, ASSETS); let core = FakeCore::new(items.clone(), 2); let ent = entities(); - let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent }; - let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1), (1, w["oc-b"], 9)], "[1,2,3]"); + let deps = SquadDeps { + core: &core, + resolver: &resolver, + entities: &ent, + }; + let body = put_body( + "f442", + w["oc-a"], + &[(0, w["oc-a"], 1), (1, w["oc-b"], 9)], + "[1,2,3]", + ); let (r1, _) = handle_put_squad(&body, &deps); let (r2, _) = handle_put_squad(&body, &deps); assert_eq!(r1.body, br#"{"id":0}"#); @@ -851,7 +968,10 @@ fn repeated_identical_put_is_idempotent() { assert_eq!(recs.len(), 2); assert_eq!(recs[0].slots, recs[1].slots, "canonical converges"); assert_eq!(recs[0].formation, recs[1].formation); - assert_eq!(recs[0].ext_payload, recs[1].ext_payload, "extension converges"); + assert_eq!( + recs[0].ext_payload, recs[1].ext_payload, + "extension converges" + ); } // ── Coupled read-after-write ───────────────────────────────────────────────── @@ -864,8 +984,17 @@ fn coupled_read_after_write_list_and_usermassinfo_agree() { let (resolver, w) = resolver_with_wires(&items, ASSETS); let core = FakeCore::new(items.clone(), 2); let ent = entities(); - let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent }; - let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1), (1, w["oc-b"], 9)], "[1,2,3]"); + let deps = SquadDeps { + core: &core, + resolver: &resolver, + entities: &ent, + }; + let body = put_body( + "f442", + w["oc-a"], + &[(0, w["oc-a"], 1), (1, w["oc-b"], 9)], + "[1,2,3]", + ); let (put, _) = handle_put_squad(&body, &deps); assert_eq!(put.status, 200); @@ -888,8 +1017,14 @@ fn coupled_read_after_write_list_and_usermassinfo_agree() { }); let (py_url, _rec) = spawn_mock_python_json(py_body); let pass = PassClient::new(&py_url); - let (umi_resp, umi_log) = - handle_user_mass_info("GET", "/ut/game/fifa17/userMassInfo", &[], b"", &deps, &pass); + let (umi_resp, umi_log) = handle_user_mass_info( + "GET", + "/ut/game/fifa17/userMassInfo", + &[], + b"", + &deps, + &pass, + ); assert_eq!(umi_log.outcome, "ok"); let umi: Value = serde_json::from_slice(&umi_resp.body).unwrap(); @@ -903,7 +1038,10 @@ fn coupled_read_after_write_list_and_usermassinfo_agree() { let sq = &umi["squad"]; assert_eq!(sq["personaId"], 42, "persona preserved from Python"); assert_eq!(sq["formation"], "f442"); - assert_eq!(sq["captain"], w["oc-a"], "captain is the wire id, not resourceId"); + assert_eq!( + sq["captain"], w["oc-a"], + "captain is the wire id, not resourceId" + ); let occ: Vec<&Value> = sq["players"] .as_array() .unwrap() @@ -919,8 +1057,14 @@ fn coupled_read_after_write_list_and_usermassinfo_agree() { let p1 = occ.iter().find(|p| p["index"] == 1).unwrap(); assert_eq!(p1["itemData"]["resourceId"], 158023); assert_eq!(p1["kitNumber"], 9); - assert!(umi["squad"]["players"].as_array().unwrap().iter().all(|p| p["itemData"]["id"] != 777), - "no Python squad content survives"); + assert!( + umi["squad"]["players"] + .as_array() + .unwrap() + .iter() + .all(|p| p["itemData"]["id"] != 777), + "no Python squad content survives" + ); // Content-Length matches the rewritten body. let cl = umi_resp @@ -928,7 +1072,11 @@ fn coupled_read_after_write_list_and_usermassinfo_agree() { .iter() .find(|(k, _)| k.eq_ignore_ascii_case("content-length")) .map(|(_, v)| v.parse::().unwrap()); - assert_eq!(cl, Some(umi_resp.body.len()), "Content-Length fixed after overlay"); + assert_eq!( + cl, + Some(umi_resp.body.len()), + "Content-Length fixed after overlay" + ); } #[test] @@ -937,15 +1085,32 @@ fn read_path_is_bounded_no_per_slot_lookup() { let (resolver, w) = resolver_with_wires(&items, ASSETS); let core = FakeCore::new(items.clone(), 2); let ent = entities(); - let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent }; - let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1), (1, w["oc-b"], 9)], "[]"); + let deps = SquadDeps { + core: &core, + resolver: &resolver, + entities: &ent, + }; + let body = put_body( + "f442", + w["oc-a"], + &[(0, w["oc-a"], 1), (1, w["oc-b"], 9)], + "[]", + ); handle_put_squad(&body, &deps); let reads_before = core.read_calls(); let owned_before = core.calls(); let (_r, log) = handle_squad_list(&deps); assert_eq!(log.outcome, "ok"); - assert_eq!(core.read_calls() - reads_before, 1, "exactly one squad read"); - assert_eq!(core.calls() - owned_before, 1, "exactly one batch owned fetch (no per-slot)"); + assert_eq!( + core.read_calls() - reads_before, + 1, + "exactly one squad read" + ); + assert_eq!( + core.calls() - owned_before, + 1, + "exactly one batch owned fetch (no per-slot)" + ); } // ── Stale / Missing integrity ──────────────────────────────────────────────── @@ -968,52 +1133,84 @@ fn read_with_ext(ext: CoreExtState) -> CoreSquadRead { fn stale_extension_is_not_applied_on_reads() { let items = vec![gk()]; let (resolver, _w) = resolver_with_wires(&items, ASSETS); - let core = FakeCore::new(items.clone(), 1) - .with_squad(read_with_ext(CoreExtState::Stale { schema_version: 1, payload: "{}".into() })); + let core = FakeCore::new(items.clone(), 1).with_squad(read_with_ext(CoreExtState::Stale { + schema_version: 1, + payload: "{}".into(), + })); let ent = entities(); - let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent }; + let deps = SquadDeps { + core: &core, + resolver: &resolver, + entities: &ent, + }; let (resp, log) = handle_squad_list(&deps); assert_eq!(log.outcome, "stale_integrity"); let v: Value = serde_json::from_slice(&resp.body).unwrap(); - assert_eq!(v["squad"].as_array().unwrap().len(), 0, "stale never projected"); + assert_eq!( + v["squad"].as_array().unwrap().len(), + 0, + "stale never projected" + ); } #[test] fn missing_extension_is_explicit_on_reads() { let items = vec![gk()]; let (resolver, _w) = resolver_with_wires(&items, ASSETS); - let core = - FakeCore::new(items.clone(), 1).with_squad(read_with_ext(CoreExtState::Missing)); + let core = FakeCore::new(items.clone(), 1).with_squad(read_with_ext(CoreExtState::Missing)); let ent = entities(); - let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent }; + let deps = SquadDeps { + core: &core, + resolver: &resolver, + entities: &ent, + }; let (resp, log) = handle_squad_list(&deps); assert_eq!(log.outcome, "missing_integrity"); let v: Value = serde_json::from_slice(&resp.body).unwrap(); - assert_eq!(v["squad"].as_array().unwrap().len(), 0, "missing never fabricated"); + assert_eq!( + v["squad"].as_array().unwrap().len(), + 0, + "missing never fabricated" + ); } #[test] fn usermassinfo_never_serves_python_squad_on_integrity_failure() { let items = vec![gk()]; let (resolver, _w) = resolver_with_wires(&items, ASSETS); - let core = - FakeCore::new(items.clone(), 1).with_squad(read_with_ext(CoreExtState::Missing)); + let core = FakeCore::new(items.clone(), 1).with_squad(read_with_ext(CoreExtState::Missing)); let ent = entities(); - let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent }; + let deps = SquadDeps { + core: &core, + resolver: &resolver, + entities: &ent, + }; let py_body = json!({ "userInfo": {"personaId": 7}, "squad": {"id": 0, "players": [{"index": 0, "itemData": {"id": 777}, "kitNumber": 5}]}, }); let (py_url, _rec) = spawn_mock_python_json(py_body); let pass = PassClient::new(&py_url); - let (resp, log) = - handle_user_mass_info("GET", "/ut/game/fifa17/userMassInfo", &[], b"", &deps, &pass); + let (resp, log) = handle_user_mass_info( + "GET", + "/ut/game/fifa17/userMassInfo", + &[], + b"", + &deps, + &pass, + ); assert_eq!(log.outcome, "missing_integrity"); let v: Value = serde_json::from_slice(&resp.body).unwrap(); - assert_eq!(v["userInfo"]["personaId"], 7, "unrelated fields still Python"); + assert_eq!( + v["userInfo"]["personaId"], 7, + "unrelated fields still Python" + ); // Rust owns squad: Python's squad (id 777) must NOT survive. assert!(v["squad"]["players"].as_array().unwrap().is_empty()); - assert_eq!(v["squad"]["personaId"], 7, "persona preserved, squad emptied"); + assert_eq!( + v["squad"]["personaId"], 7, + "persona preserved, squad emptied" + ); } #[test] @@ -1023,7 +1220,10 @@ fn unrelated_route_still_reaches_python() { let server = build_server(core, &py_url, None); let resp = server.handle("POST", "/ut/game/fifa17/packs/purchase", &[], b"{}"); assert_eq!(resp.status, 200); - assert!(resp.headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("x-from-python"))); + assert!(resp + .headers + .iter() + .any(|(k, _)| k.eq_ignore_ascii_case("x-from-python"))); assert_eq!(rec.lock().len(), 1, "reached Python"); } @@ -1031,23 +1231,54 @@ fn unrelated_route_still_reaches_python() { fn duplicate_definition_instances_stay_distinct_through_host() { // Two owned copies of ONE definition (card_a): distinct owned ids and wire // ids, one shared resourceId — must not collapse anywhere in the host. - let a = item("oc-a", "card_a", 87, "GK", "Argentina", "Premier League", "Chelsea"); - let c = item("oc-c", "card_a", 87, "ST", "Argentina", "Premier League", "Chelsea"); + let a = item( + "oc-a", + "card_a", + 87, + "GK", + "Argentina", + "Premier League", + "Chelsea", + ); + let c = item( + "oc-c", + "card_a", + 87, + "ST", + "Argentina", + "Premier League", + "Chelsea", + ); let items = vec![a, c]; let (resolver, w) = resolver_with_wires(&items, &[("card_a", 20801)]); assert_ne!(w["oc-a"], w["oc-c"], "two copies get distinct wire ids"); let core = FakeCore::new(items.clone(), 2); let ent = entities(); - let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent }; - let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1), (1, w["oc-c"], 7)], "[]"); + let deps = SquadDeps { + core: &core, + resolver: &resolver, + entities: &ent, + }; + let body = put_body( + "f442", + w["oc-a"], + &[(0, w["oc-a"], 1), (1, w["oc-c"], 7)], + "[]", + ); let (put, _) = handle_put_squad(&body, &deps); assert_eq!(put.status, 200); let py = json!({"userInfo": {"personaId": 1}, "squad": {"players": []}}); let (url, _r) = spawn_mock_python_json(py); let pass = PassClient::new(&url); - let (umi, _) = - handle_user_mass_info("GET", "/ut/game/fifa17/userMassInfo", &[], b"", &deps, &pass); + let (umi, _) = handle_user_mass_info( + "GET", + "/ut/game/fifa17/userMassInfo", + &[], + b"", + &deps, + &pass, + ); let v: Value = serde_json::from_slice(&umi.body).unwrap(); let occ: Vec<&Value> = v["squad"]["players"] .as_array() @@ -1063,4 +1294,4 @@ fn duplicate_definition_instances_stay_distinct_through_host() { for p in &occ { assert_eq!(p["itemData"]["resourceId"], 20801, "shared asset id"); } -} \ No newline at end of file +}