//! Adapter-level squad read-after-write / round-trip fidelity, driven entirely by //! committed sanitized capture evidence. //! //! Pipeline exercised end to end, with NO database, socket, or Core: //! //! ```text //! captured PUT ─parse─▶ Fifa17SquadPut //! ─build─▶ ProposedSquad (canonical) + Fifa17SquadExtensionV1 //! (simulate committed canonical state: the ProposedSquad IS what Core stored) //! ─project─▶ FIFA 17 squad wire object //! ``` //! //! Fidelity is asserted by ownership class: //! CANONICAL player instance per index, formation, captain, bench split //! EXTENSION custom, kicktakers, manager, kit numbers (by player), squadType //! SHADOW chemistry/rating/starRating (client-reported, round-tripped as-is) //! DERIVED correct FIFA 17 item identity (wire id + resourceId) //! //! We assert *semantic wire fidelity*, not byte equality: the read oracle was //! produced by the pre-migration Python backend, whose display-only shadow fields //! (`untradeable`, `discardValue`) and server-*recomputed* chemistry are not the //! adapter's to reproduce. use std::collections::HashMap; use openfut_adapter_fifa17::fut::item::{CoreOwnedItem, Fifa17Identity, ItemIdentityResolver}; use openfut_adapter_fifa17::fut::squad::{parse_squad_put, Fifa17SquadPut, SquadWireResolver}; use openfut_adapter_fifa17::fut::squad_ext::{build_squad_write, SquadWriteBuild}; use openfut_adapter_fifa17::fut::squad_projection::{ project_squad, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput, SquadProjection, SquadProjectionInput, }; use serde_json::Value; const PUT_BASELINE: &str = include_str!("../fixtures/utas/squad_put_f442.json"); const PUT_SWAP: &str = include_str!("../fixtures/utas/squad_put_swap_f442.json"); const PUT_F433: &str = include_str!("../fixtures/utas/squad_put_f433.json"); const READ_ORACLE: &str = include_str!("../fixtures/utas/squad_read_usermassinfo.json"); // ---- host-role stand-ins (identity resolution, entity resolver) ------------- /// Wire→owned reverse map. In production the host builds this from the identity /// store; here every occupied wire id maps to a stable `oc-`. struct OcResolver; impl SquadWireResolver for OcResolver { fn owned_id_for_wire(&self, wire: i64) -> Option { Some(format!("oc-{wire}")) } } /// owned_card_id → FIFA identity, so two copies of one definition stay distinct. struct TableIdentity(HashMap); impl ItemIdentityResolver for TableIdentity { fn resolve(&self, it: &CoreOwnedItem) -> Option { self.0.get(&it.owned_card_id).copied() } } /// Neutral entity resolver — badge/flag ids are covered by `fut::item` tests; the /// 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 } } /// Build the owned-item map + identity table from the persisted read oracle, so /// every wire id used by the captures resolves to its real FIFA asset id/rating. /// Keyed by `oc-` to match `OcResolver`. fn oracle_tables() -> (HashMap, TableIdentity) { let oracle: Value = serde_json::from_str(READ_ORACLE).unwrap(); let mut owned = HashMap::new(); let mut ident = HashMap::new(); for p in oracle["players"].as_array().unwrap() { let it = &p["itemData"]; let wire = it["id"].as_i64().unwrap(); if wire == 0 { continue; // empty slot } let oc = format!("oc-{wire}"); let asset = it["resourceId"].as_u64().unwrap() as u32; let attrs: Vec = it["attributeList"] .as_array() .unwrap() .iter() .map(|a| a["value"].as_u64().unwrap() as u8) .collect(); owned.insert( oc.clone(), CoreOwnedItem { owned_card_id: oc.clone(), card_id: format!("def-{asset}"), rating: it["rating"].as_u64().unwrap() as u8, position: it["preferredPosition"].as_str().unwrap().to_string(), nation: String::new(), league: String::new(), club: String::new(), 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 }); } (owned, TableIdentity(ident)) } /// The full pipeline: parse a captured PUT, build the canonical + extension, then /// project — treating the just-built canonical squad as Core's committed state. fn project_put( put: &Fifa17SquadPut, owned: &HashMap, ident: &TableIdentity, ) -> Value { let SquadWriteBuild { canonical, extension } = build_squad_write(put, &OcResolver).expect("build must succeed for a full valid squad"); let slots: Vec = canonical .slots .iter() .map(|s| ProjectionSlot { owned_card_id: s.owned_card_id.clone(), index: s.index, is_captain: s.is_captain, is_on_bench: s.is_on_bench, }) .collect(); let input = SquadProjectionInput { fifa_squad_id: canonical.squad_id, name: canonical.name.clone().unwrap_or_default(), formation: canonical.formation.clone().unwrap(), slots, ext: SquadExtInput::Fresh(extension), owned, }; match project_squad(&input, ident, &NoEntities).unwrap() { SquadProjection::Projected(v) => v, other => panic!("expected Projected, got {other:?}"), } } /// Map FIFA-array index → (wire item id, kit number) for the occupied slots of a /// projected or captured squad object. fn occupied(v: &Value) -> HashMap { v["players"] .as_array() .unwrap() .iter() .filter(|p| p["itemData"]["id"].as_i64().unwrap() != 0) .map(|p| { ( p["index"].as_i64().unwrap(), (p["itemData"]["id"].as_i64().unwrap(), p["kitNumber"].as_i64().unwrap()), ) }) .collect() } #[test] fn baseline_projects_the_known_squad_round_trip() { let (owned, ident) = oracle_tables(); let put = parse_squad_put(PUT_BASELINE.as_bytes()).unwrap(); let projected = project_put(&put, &owned, &ident); // 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"); // 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"); // EXTENSION: custom byte-identical, manager + squadType preserved. assert_eq!(projected["custom"], put_v["custom"]); assert_eq!(projected["manager"], put_v["manager"]); assert_eq!(projected["squadType"], "REGULAR_SQUAD"); // SHADOW: client-reported values carried as-is (baseline chemistry 52). assert_eq!(projected["chemistry"], 52); assert_eq!(projected["rating"], 90); assert_eq!(projected["starRating"], 90); } #[test] fn swap_moves_two_players_with_their_kits_and_round_trips() { // The swap PUT is baseline with two players rotated between slots. Projecting // 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 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"); // The swap is real: at least two indices carry a different player than baseline. let (proj_occ, base_occ) = (occupied(&projected), occupied(&base_v)); let moved: Vec = proj_occ .iter() .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:?}"); // kit follows the PLAYER: for every player, its kit in baseline == its kit // in the swap projection, regardless of which slot it now occupies. let kit_by_player = |occ: &HashMap| -> HashMap { occ.values().map(|(id, kit)| (*id, *kit)).collect() }; assert_eq!( kit_by_player(&proj_occ), kit_by_player(&base_occ), "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"); // 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); } #[test] fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() { // Simulate Core's committed state for the persisted (post-relaunch) squad by // reconstructing the canonical slots + FIFA extension straight from the read // 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 std::collections::BTreeMap; let oracle: Value = serde_json::from_str(READ_ORACLE).unwrap(); let (owned, ident) = oracle_tables(); let captain = oracle["captain"].as_i64().unwrap(); let mut slots = Vec::new(); let mut kit_numbers = BTreeMap::new(); for p in oracle["players"].as_array().unwrap() { let wire = p["itemData"]["id"].as_i64().unwrap(); if wire == 0 { continue; } let index = p["index"].as_i64().unwrap(); let oc = format!("oc-{wire}"); kit_numbers.insert(oc.clone(), p["kitNumber"].as_i64().unwrap()); slots.push(ProjectionSlot { owned_card_id: oc, index, is_captain: wire == captain, is_on_bench: index >= 11, }); } let manager: Vec = serde_json::from_value(oracle["manager"].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), kit_numbers, manager, kicktakers, client_reported: ClientReportedSquadEval { chemistry: oracle["chemistry"].as_i64(), rating: oracle["rating"].as_i64(), star_rating: oracle["starRating"].as_i64(), }, }; let input = SquadProjectionInput { fifa_squad_id: oracle["id"].as_i64().unwrap(), name: oracle["squadName"].as_str().unwrap().to_string(), formation: oracle["formation"].as_str().unwrap().to_string(), slots, ext: SquadExtInput::Fresh(ext), owned: &owned, }; let SquadProjection::Projected(projected) = project_squad(&input, &ident, &NoEntities).unwrap() else { panic!("expected Projected"); }; // CANONICAL + DERIVED: identity and placement per slot match the read. 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"); assert_eq!(pp["itemData"]["rating"], op["itemData"]["rating"]); 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"]); assert_eq!(projected["manager"], oracle["manager"]); assert_eq!(projected["kicktakers"], oracle["kicktakers"]); assert_eq!(projected["squadType"], oracle["squadType"]); assert_eq!(projected["chemistry"], oracle["chemistry"]); assert_eq!(projected["rating"], oracle["rating"]); assert_eq!(projected["starRating"], oracle["starRating"]); } #[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); assert_eq!(f433["formation"], "f433"); assert_eq!(swap["formation"], "f442"); // Same 11 starters (same set of wire ids), reindexed. let set = |v: &Value| { let mut ids: Vec = occupied(v).values().map(|(id, _)| *id).collect(); ids.sort(); ids }; 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 }; 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 }; assert_eq!(kit_of(&swap, 100000001), 8); 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); // userMassInfo.squad = the projected object + session envelope. let ummi = user_mass_info_squad(projected.clone(), 33068179); assert_eq!(ummi["personaId"], 33068179); assert_eq!(ummi["changed"], 0); assert!(ummi["actives"].is_array()); assert_eq!(ummi["players"], projected["players"], "same projected body"); assert_eq!(ummi["formation"], projected["formation"]); // 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"); } // The summary carries only those six keys — no divergent squad shape. assert_eq!(entry.as_object().unwrap().len(), 6); }