//! The launch sequence, as an explicit state machine. //! //! # Why this exists //! //! The launcher used to make the user perform OpenFUT's internal launch order by //! hand: start LSX, start autopatch, run pre-launch checks, "Arm client", then //! press a button called *Start Services & Launch Game*. Every one of those is an //! implementation detail of how FIFA 17 is persuaded to talk to OpenFUT, and //! getting the order wrong produced failures that surfaced much later as "the //! game crashed" — autopatch started before `ptrace_scope` was 0 silently does //! nothing at all. //! //! So the sequence lives here, once, and the UI renders it. One button. //! //! # Ordering, and where it deviates from the obvious //! //! Client preparation (`arm`) runs BEFORE autopatch, not after: autopatch writes //! `/proc//mem`, which Yama forbids until arming sets //! `kernel.yama.ptrace_scope=0`. Starting autopatch first would "succeed" and //! then quietly fail to patch anything. //! //! # Idempotence //! //! Every step asks what is already true before acting. A healthy service is //! reused, never restarted; client preparation is skipped when the checks it //! would repair already pass, which also avoids an unnecessary Polkit prompt. //! //! # Testability //! //! The effects — spawning services, elevating for arming, writing the hook //! config, starting the game — sit behind [`LaunchOps`]. [`run_sequence`] is //! therefore a pure decision procedure over observed state, and the sequencing //! rules that matter (don't launch after a failed step, don't restart healthy //! services, don't kill what we didn't start) are unit-testable without a FIFA //! install, a Polkit agent, or root. use std::sync::Arc; use crate::config::LauncherConfig; use crate::fifa17_capability::Fifa17ClientCapabilities; use crate::local_services::{ CapabilityWiring, Ensured, Service, ServiceRuntime, ServiceSupervisor, SpawnSpec, }; use crate::logs::LogBuffer; use crate::preflight::{self, Check, State}; use parking_lot::Mutex; /// Where the launch sequence is. Rendered directly by the UI; the UI never /// coordinates services itself. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Phase { /// Nothing in flight. Readiness still comes from observed state, not from /// having been here. #[default] Idle, /// Looking at the world: checks + service + hook state. Checking, /// Elevated client preparation in flight (this is what shows a password /// prompt). PreparingClient, StartingServices, /// Re-checking after repair, before committing to a launch. Validating, Launching, /// FIFA is up. Left when the process exits. Running, Failed, } impl Phase { /// Whether a launch is under way, i.e. the primary button must not start a /// second one. pub fn busy(self) -> bool { matches!( self, Phase::Checking | Phase::PreparingClient | Phase::StartingServices | Phase::Validating | Phase::Launching ) } } /// One step of the sequence, in execution order. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Step { Server, ClientFiles, ClientPreparation, Lsx, Autopatch, FinalChecks, Game, } impl Step { /// User-facing name. Deliberately not the internal vocabulary: "arm" is /// implementation terminology and never appears in the normal flow. pub fn label(self) -> &'static str { match self { Step::Server => "OpenFUT server", Step::ClientFiles => "Client files", Step::ClientPreparation => "Client preparation", Step::Lsx => "LSX", Step::Autopatch => "Autopatch", Step::FinalChecks => "Final checks", Step::Game => "FIFA 17", } } } /// How a step ended. `Skipped` is a success that did nothing — the state it /// would have produced was already true. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Outcome { Done(String), Skipped(String), Failed(String), } impl Outcome { pub fn ok(&self) -> bool { !matches!(self, Outcome::Failed(_)) } pub fn detail(&self) -> &str { match self { Outcome::Done(d) | Outcome::Skipped(d) | Outcome::Failed(d) => d, } } } /// Everything the UI needs to render the launch surface. #[derive(Debug, Clone, Default)] pub struct LaunchState { pub phase: Phase, /// Steps attempted by the most recent run, in order. pub steps: Vec<(Step, Outcome)>, /// One-line reason the run failed, for the top of the failure card. The /// per-step detail carries the specifics. pub failure: Option, /// The most recent preflight results and when they were taken. Cached /// because the checks open sockets with timeouts and cannot run per frame. pub checks: Option>, pub checks_age: Option, } impl LaunchState { fn begin(&mut self, phase: Phase) { self.phase = phase; self.steps.clear(); self.failure = None; } fn record(&mut self, step: Step, outcome: Outcome) { if let Outcome::Failed(reason) = &outcome { self.failure = Some(format!("{}: {reason}", step.label())); } self.steps.push((step, outcome)); } } /// The effects the sequence performs. Implemented for real by [`RealOps`] and /// substituted in tests. pub trait LaunchOps { /// Confirm the configured OpenFUT server is answering AND select the account /// for this session. The server is remote by design, so this is a network /// fact, never "is something local up". Returns a user-facing summary. fn connect_server(&mut self) -> Result; /// Version.dll + a readable openfut.cfg. `Err` is a hard stop: without them /// FIFA talks to EA, not OpenFUT. fn ensure_client_files(&mut self) -> Result; /// Which of the arming-repairable checks are currently failing. fn run_checks(&mut self) -> Vec; /// Elevated client preparation (`arm`). Returns what it changed. fn prepare_client(&mut self) -> Result, String>; fn ensure_service(&mut self, service: Service) -> Result; fn start_game(&mut self) -> Result<(), String>; } /// Checks that client preparation is able to repair. A failure in any of these /// means "prepare the client", not "give up". fn preparation_repairs(check: &Check) -> bool { const REPAIRABLE: [&str; 3] = [ "ptrace_scope (autopatch)", "EA redirector IP is redirected", "EA hostnames point at OpenFUT", ]; REPAIRABLE.contains(&check.name.as_str()) } /// Run the whole sequence, publishing progress into `state` as it goes. /// /// Returns whether FIFA was started. Stops at the first failed step: launching /// into a known-broken client produces a session that fails minutes later with /// no message naming the cause, which is precisely the failure mode this /// launcher exists to prevent. pub fn run_sequence(ops: &mut dyn LaunchOps, state: &Arc>) -> bool { macro_rules! step { ($phase:expr, $step:expr, $body:expr) => {{ state.lock().phase = $phase; let outcome: Outcome = $body; let ok = outcome.ok(); state.lock().record($step, outcome); if !ok { state.lock().phase = Phase::Failed; return false; } }}; } state.lock().begin(Phase::Checking); // ── The server, which is remote and not ours to start ──────────────────── step!(Phase::Checking, Step::Server, { match ops.connect_server() { Ok(detail) => Outcome::Done(detail), Err(e) => Outcome::Failed(e), } }); // ── The hook the game loads, reconciled with the current settings ──────── step!(Phase::Checking, Step::ClientFiles, { match ops.ensure_client_files() { Ok(detail) => Outcome::Done(detail), Err(e) => Outcome::Failed(e), } }); // ── Client preparation, only if something it repairs is broken ─────────── let checks = ops.run_checks(); let broken: Vec = checks .iter() .filter(|c| c.state == State::Fail && preparation_repairs(c)) .map(|c| c.name.clone()) .collect(); { let mut guard = state.lock(); guard.checks = Some(checks); guard.checks_age = Some(std::time::Instant::now()); } step!(Phase::PreparingClient, Step::ClientPreparation, { if broken.is_empty() { Outcome::Skipped("already prepared".into()) } else { match ops.prepare_client() { Ok(changes) => Outcome::Done(format!("{} change(s) applied", changes.len())), Err(e) => Outcome::Failed(e), } } }); // ── Companion services, in dependency order ───────────────────────────── for (service, step) in [ (Service::Lsx, Step::Lsx), (Service::Autopatch, Step::Autopatch), ] { step!(Phase::StartingServices, step, { match ops.ensure_service(service) { Ok(Ensured::Reused) => Outcome::Skipped("already running".into()), Ok(Ensured::Started) => Outcome::Done("started".into()), Err(e) => Outcome::Failed(e), } }); } // ── Validate what the repairs were supposed to fix ────────────────────── step!(Phase::Validating, Step::FinalChecks, { let checks = ops.run_checks(); let failed: Vec = checks .iter() .filter(|c| c.state == State::Fail) .map(|c| c.name.clone()) .collect(); { let mut guard = state.lock(); guard.checks = Some(checks); guard.checks_age = Some(std::time::Instant::now()); } if failed.is_empty() { Outcome::Done("all checks pass".into()) } else { Outcome::Failed(format!("still failing: {}", failed.join(", "))) } }); step!(Phase::Launching, Step::Game, { match ops.start_game() { Ok(()) => Outcome::Done("started".into()), Err(e) => Outcome::Failed(e), } }); state.lock().phase = Phase::Running; true } /// Observe the world without changing it, for the status rows on open and after /// a settings change. Shares [`run_sequence`]'s notion of what "ready" means so /// the two cannot drift apart. pub fn refresh_checks(ops: &mut dyn LaunchOps, state: &Arc>) { state.lock().phase = Phase::Checking; let checks = ops.run_checks(); let mut guard = state.lock(); guard.checks = Some(checks); guard.checks_age = Some(std::time::Instant::now()); guard.phase = Phase::Idle; } /// What happens to launcher-started services when FIFA exits. /// /// Exists so the answer is a stated policy rather than an oversight. The shipped /// value stops nothing: /// /// * The companion services are reusable across launches — LSX has to be holding /// :4216 before FIFA dials it, and the next launch would only start them again. /// * A service the launcher did NOT start is never in the stop list under any /// value of this policy. /// /// Client preparation is deliberately absent, and is never reverted: it is host /// state (`ptrace_scope`, a DNAT, `/etc/hosts`) that `client_arm.sh` also leaves /// set and that every subsequent launch needs. A flag for it would be a flag /// nothing honours. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub struct CleanupPolicy { pub stop_launcher_started_services: bool, } /// Which services cleanup is allowed to stop after `FIFA` exits: only ones this /// launcher started, and only if the policy says so. pub fn services_to_stop( policy: CleanupPolicy, runtimes: &[(Service, ServiceRuntime)], ) -> Vec { if !policy.stop_launcher_started_services { return Vec::new(); } runtimes .iter() .filter(|(_, r)| r.running && r.started_by_launcher) .map(|(s, _)| *s) .collect() } /// Summary of one dependency for the main card. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Readiness { Ready, Busy, Attention, /// Never looked, or the answer is stale. Never rendered as Ready. Unknown, } /// Client-integration readiness from the cached checks. `Unknown` until a run has /// actually happened: "we did not look" must not look like "we looked and it was /// fine". pub fn client_integration(state: &LaunchState) -> Readiness { if matches!(state.phase, Phase::PreparingClient) { return Readiness::Busy; } match &state.checks { None => Readiness::Unknown, Some(checks) => { let relevant: Vec<&Check> = checks.iter().filter(|c| preparation_repairs(c)).collect(); if relevant.iter().any(|c| c.state == State::Fail) { Readiness::Attention } else if relevant.iter().all(|c| c.state == State::Skipped) { // Nothing configured to check, so nothing was verified. Readiness::Unknown } else { Readiness::Ready } } } } /// Overall readiness for the card's headline pill. Anything short of every /// dependency being observed-good is not Ready. pub fn overall( phase: Phase, server: Readiness, integration: Readiness, services: Readiness, hook: Readiness, ) -> Readiness { if phase == Phase::Running { return Readiness::Ready; } if phase.busy() { return Readiness::Busy; } let parts = [server, integration, services, hook]; if parts.contains(&Readiness::Attention) { Readiness::Attention } else if parts.contains(&Readiness::Unknown) { Readiness::Unknown } else { Readiness::Ready } } /// [`LaunchOps`] against the actual machine. /// /// Holds a snapshot of the config: a launch must not change its mind halfway /// through because the user edited a field while it ran. pub struct RealOps { config: LauncherConfig, services: Arc>, logs: Arc>, caps: Arc>, state: Arc>, } impl RealOps { fn say(&self, message: impl Into) { self.logs.lock().push(message.into()); } } impl LaunchOps for RealOps { fn connect_server(&mut self) -> Result { self.config.validate_server()?; if preflight::backend_reachable(&self.config).state == State::Fail { return Err(format!( "{} is not answering — is the OpenFUT server running?", self.config.openfut_server_host )); } // Selecting the account is part of connecting: LSX and FIFA both // authenticate as this persona, and a launch with the wrong one produces // a session that looks fine and belongs to nobody. let account = crate::account_sync::sync(&self.config)?; self.say(format!( "[launcher] account synchronized: {}/{} FUT-coins={} unopened-packs={}", account.persona_id, account.persona_name, account.coins, account.unopened_packs )); Ok(format!( "{} · {}", self.config.openfut_server_host, account.persona_name )) } fn ensure_client_files(&mut self) -> Result { let game_dir = std::path::PathBuf::from(&self.config.fifa_game_dir); if !crate::setup::hook_dll_deployed(&game_dir) { return Err("network hook is not deployed — use Setup to deploy it".into()); } // The file the game reads is reconciled here, and only here: this is the // one moment it is guaranteed to agree with the settings on screen. let contents = self.config.hook_cfg_contents()?; crate::setup::update_hook_config(&game_dir, &contents).map_err(|e| { format!( "cannot write {} in {}: {e}", crate::setup::HOOK_CFG_FILE, self.config.fifa_game_dir ) })?; Ok(format!( "hook → {}:{}", self.config.openfut_server_host, self.config.openfut_https_port )) } fn run_checks(&mut self) -> Vec { preflight::run(&self.config) } fn prepare_client(&mut self) -> Result, String> { match crate::arm::arm(&self.config) { Ok(changes) => { for change in &changes { self.say(format!("[launcher] prepared: {change}")); } Ok(changes) } Err(e) => Err(e.to_string()), } } fn ensure_service(&mut self, service: Service) -> Result { let spec = SpawnSpec { persona_id: self.config.fut_persona_id, persona_name: self.config.fut_persona_name.clone(), // Only autopatch advertises the verified resolver guard, so only it // receives the shared capability sink. capability: match service { Service::Autopatch => Some(CapabilityWiring { server_host: self.config.openfut_server_host.clone(), account_sync_port: self.config.openfut_account_sync_port, sink: Arc::clone(&self.caps), }), Service::Lsx => None, }, }; self.services.lock().ensure_running(service, spec) } fn start_game(&mut self) -> Result<(), String> { // A new FIFA process starts with UNKNOWN capability: never inherit the // previous launch's. The autopatch stdout reader repopulates it. *self.caps.lock() = Default::default(); let state = Arc::clone(&self.state); let logs = Arc::clone(&self.logs); let services = Arc::clone(&self.services); let on_exit = move || { // Cleanup goes through the policy rather than through habit, so the // list can never include a service this launcher did not start. let runtimes: Vec<_> = { let mut supervisor = services.lock(); [Service::Lsx, Service::Autopatch] .into_iter() .map(|s| { let runtime = supervisor.observe(s); (s, runtime) }) .collect() }; for service in services_to_stop(CleanupPolicy::default(), &runtimes) { if let Err(e) = services.lock().stop(service) { logs.lock().push(format!("[launcher] cleanup: {e}")); } } state.lock().phase = Phase::Idle; logs.lock() .push("[launcher] FIFA exited; launcher back to Ready.".to_string()); }; // Prefer the native profile; fall back to the user's shell command so an // existing working setup keeps working after an upgrade. if self.config.game_profile.configured() { crate::game_launch::launch(&self.config.game_profile, &self.logs, on_exit) .map_err(|e| e.to_string()) } else { crate::setup::launch_game( &self.config.game_launch_command, &self.config.game_launch_workdir, Arc::clone(&self.logs), on_exit, ) .map_err(|e| e.to_string()) } } } /// Drives [`run_sequence`] on a worker thread. The UI thread never blocks on a /// socket, a Polkit prompt or a process spawn. pub struct Controller { pub state: Arc>, pub services: Arc>, } impl Controller { pub fn new(logs: Arc>) -> Self { Self { state: Arc::new(Mutex::new(LaunchState::default())), services: Arc::new(Mutex::new(ServiceSupervisor::new(logs))), } } pub fn snapshot(&self) -> LaunchState { self.state.lock().clone() } fn ops( &self, config: &LauncherConfig, logs: &Arc>, caps: &Arc>, ) -> RealOps { RealOps { config: config.clone(), services: Arc::clone(&self.services), logs: Arc::clone(logs), caps: Arc::clone(caps), state: Arc::clone(&self.state), } } /// Start the full sequence. Ignored while one is already in flight or the /// game is up — the button reflects that state rather than queueing work. pub fn launch( &self, config: &LauncherConfig, logs: &Arc>, caps: &Arc>, ) { { let phase = self.state.lock().phase; if phase.busy() || phase == Phase::Running { return; } } let mut ops = self.ops(config, logs, caps); let state = Arc::clone(&self.state); std::thread::spawn(move || { run_sequence(&mut ops, &state); }); } /// Re-observe without changing anything, for startup and after a settings /// change. Skipped while a launch owns the state. pub fn refresh( &self, config: &LauncherConfig, logs: &Arc>, caps: &Arc>, ) { { let phase = self.state.lock().phase; if phase.busy() || phase == Phase::Running { return; } } let mut ops = self.ops(config, logs, caps); let state = Arc::clone(&self.state); std::thread::spawn(move || { refresh_checks(&mut ops, &state); }); } } #[cfg(test)] mod tests { use super::*; /// Records what the sequence asked for, and answers however the test wants. #[derive(Default)] #[allow(clippy::type_complexity)] struct FakeOps { server_up: bool, client_files: Option>, checks: Vec, checks_after_prepare: Option>, prepare_result: Option, String>>, service_result: Vec<(Service, Result)>, game_result: Option>, // Observed calls prepared: usize, started: Vec, game_started: usize, check_runs: usize, } fn check(name: &str, state: State) -> Check { Check { name: name.into(), state, detail: String::new(), } } fn ready_ops() -> FakeOps { FakeOps { server_up: true, client_files: Some(Ok("deployed".into())), checks: vec![ check("ptrace_scope (autopatch)", State::Pass), check("EA redirector IP is redirected", State::Pass), check("EA hostnames point at OpenFUT", State::Pass), ], prepare_result: Some(Ok(vec!["one".into()])), game_result: Some(Ok(())), ..FakeOps::default() } } impl LaunchOps for FakeOps { fn connect_server(&mut self) -> Result { if self.server_up { Ok("connected".into()) } else { Err("not reachable — is the OpenFUT server running?".into()) } } fn ensure_client_files(&mut self) -> Result { self.client_files .clone() .unwrap_or_else(|| Err("no client-files result configured".into())) } fn run_checks(&mut self) -> Vec { self.check_runs += 1; match (&self.checks_after_prepare, self.prepared) { (Some(after), n) if n > 0 => after.clone(), _ => self.checks.clone(), } } fn prepare_client(&mut self) -> Result, String> { self.prepared += 1; self.prepare_result .clone() .unwrap_or_else(|| Err("no prepare configured".into())) } fn ensure_service(&mut self, service: Service) -> Result { self.started.push(service); self.service_result .iter() .find(|(s, _)| *s == service) .map(|(_, r)| r.clone()) .unwrap_or(Ok(Ensured::Started)) } fn start_game(&mut self) -> Result<(), String> { self.game_started += 1; self.game_result .clone() .unwrap_or_else(|| Err("no game result configured".into())) } } fn state() -> Arc> { Arc::new(Mutex::new(LaunchState::default())) } #[test] fn a_cold_client_is_prepared_and_started_in_dependency_order() { let mut ops = FakeOps { checks: vec![check("ptrace_scope (autopatch)", State::Fail)], checks_after_prepare: Some(vec![check("ptrace_scope (autopatch)", State::Pass)]), ..ready_ops() }; let st = state(); assert!(run_sequence(&mut ops, &st)); assert_eq!( ops.prepared, 1, "a failing repairable check must be repaired" ); // Preparation before autopatch: autopatch cannot write FIFA's memory // until arming has set ptrace_scope, and would silently no-op. assert_eq!(ops.started, vec![Service::Lsx, Service::Autopatch]); assert_eq!(ops.game_started, 1); assert_eq!(st.lock().phase, Phase::Running); } #[test] fn an_already_prepared_client_is_not_prepared_again() { let mut ops = ready_ops(); let st = state(); assert!(run_sequence(&mut ops, &st)); assert_eq!(ops.prepared, 0, "no password prompt for work already done"); let steps = &st.lock().steps; let prep = steps .iter() .find(|(s, _)| *s == Step::ClientPreparation) .expect("preparation step recorded") .1 .clone(); assert!(matches!(prep, Outcome::Skipped(_)), "{prep:?}"); } #[test] fn healthy_services_are_reused_rather_than_restarted() { let mut ops = FakeOps { service_result: vec![ (Service::Lsx, Ok(Ensured::Reused)), (Service::Autopatch, Ok(Ensured::Reused)), ], ..ready_ops() }; let st = state(); assert!(run_sequence(&mut ops, &st)); for step in [Step::Lsx, Step::Autopatch] { let outcome = st .lock() .steps .iter() .find(|(s, _)| *s == step) .expect("service step recorded") .1 .clone(); assert!( matches!(outcome, Outcome::Skipped(_)), "{step:?} {outcome:?}" ); } assert_eq!(ops.game_started, 1); } #[test] fn an_unreachable_server_stops_the_launch_before_anything_is_touched() { let mut ops = FakeOps { server_up: false, ..ready_ops() }; let st = state(); assert!(!run_sequence(&mut ops, &st)); assert_eq!(ops.prepared, 0); assert!(ops.started.is_empty(), "nothing may be started"); assert_eq!(ops.game_started, 0); assert_eq!(st.lock().phase, Phase::Failed); assert!(st.lock().failure.as_deref().unwrap().contains("server")); } #[test] fn a_service_that_fails_to_start_stops_the_launch() { let mut ops = FakeOps { service_result: vec![(Service::Autopatch, Err("autopatch: boom".into()))], ..ready_ops() }; let st = state(); assert!(!run_sequence(&mut ops, &st)); assert_eq!(ops.game_started, 0, "FIFA must not start without autopatch"); let failure = st.lock().failure.clone().unwrap(); assert!(failure.contains("Autopatch"), "{failure}"); } #[test] fn failed_client_preparation_stops_the_launch() { let mut ops = FakeOps { checks: vec![check("ptrace_scope (autopatch)", State::Fail)], prepare_result: Some(Err("pkexec: dismissed".into())), ..ready_ops() }; let st = state(); assert!(!run_sequence(&mut ops, &st)); assert!(ops.started.is_empty()); assert_eq!(ops.game_started, 0); } #[test] fn a_check_still_failing_after_repair_stops_the_launch() { // Preparation ran and claimed success, but the state it was supposed to // fix is still broken. Launching here is how a session dies later with // no message naming the cause. let mut ops = FakeOps { checks: vec![check("ptrace_scope (autopatch)", State::Fail)], checks_after_prepare: Some(vec![check("ptrace_scope (autopatch)", State::Fail)]), ..ready_ops() }; let st = state(); assert!(!run_sequence(&mut ops, &st)); assert_eq!(ops.game_started, 0); let failure = st.lock().failure.clone().unwrap(); assert!(failure.contains("still failing"), "{failure}"); } #[test] fn client_files_failure_stops_the_launch() { let mut ops = FakeOps { client_files: Some(Err("cannot write openfut.cfg".into())), ..ready_ops() }; let st = state(); assert!(!run_sequence(&mut ops, &st)); assert_eq!(ops.game_started, 0); assert!(ops.started.is_empty()); } #[test] fn cleanup_never_stops_a_service_the_launcher_did_not_start() { let foreign = ServiceRuntime { running: true, started_by_launcher: false, pid: Some(4242), detail: None, }; let ours = ServiceRuntime { running: true, started_by_launcher: true, pid: Some(99), detail: None, }; let runtimes = [(Service::Lsx, foreign), (Service::Autopatch, ours)]; // Even under the most aggressive policy, a foreign service is untouched. let aggressive = CleanupPolicy { stop_launcher_started_services: true, }; assert_eq!( services_to_stop(aggressive, &runtimes), vec![Service::Autopatch] ); // And the shipped policy keeps both alive for the next launch. assert!(services_to_stop(CleanupPolicy::default(), &runtimes).is_empty()); } #[test] fn readiness_is_never_green_while_a_dependency_is_not() { assert_eq!( overall( Phase::Idle, Readiness::Ready, Readiness::Ready, Readiness::Attention, Readiness::Ready ), Readiness::Attention ); // Never checked is not the same as checked and fine. assert_eq!( overall( Phase::Idle, Readiness::Ready, Readiness::Unknown, Readiness::Ready, Readiness::Ready ), Readiness::Unknown ); assert_eq!( overall( Phase::Idle, Readiness::Ready, Readiness::Ready, Readiness::Ready, Readiness::Ready ), Readiness::Ready ); // A running game reports Ready even though a launch is not in flight. assert_eq!( overall( Phase::Running, Readiness::Unknown, Readiness::Unknown, Readiness::Unknown, Readiness::Unknown ), Readiness::Ready ); } #[test] fn client_integration_is_unknown_until_checks_have_run() { let mut st = LaunchState::default(); assert_eq!(client_integration(&st), Readiness::Unknown); st.checks = Some(vec![check("ptrace_scope (autopatch)", State::Fail)]); assert_eq!(client_integration(&st), Readiness::Attention); st.checks = Some(vec![check("ptrace_scope (autopatch)", State::Pass)]); assert_eq!(client_integration(&st), Readiness::Ready); // Only skipped checks means nothing was actually verified. st.checks = Some(vec![check("ptrace_scope (autopatch)", State::Skipped)]); assert_eq!(client_integration(&st), Readiness::Unknown); } }