//! Profile switch orchestration, as a plan rather than a procedure. //! //! Switching profiles tears down and rebuilds nearly everything the app holds: //! the player and its queue, the sync queue drain, the session poller, the //! lockscreen metadata, the repository handle. The *ordering* of that teardown //! is a correctness invariant, not an implementation detail — a straggler that //! reports after the active user has flipped attributes one account's viewing to //! another, which is silent, plausible-looking, and unrecoverable. //! //! So the ordering lives here as a pure function returning a list of steps, and //! the command layer executes them. That is the only way this gets tested: an //! end-to-end switch needs two real accounts on a real server, which CI does not //! have and never will. The plan needs nothing. //! //! Two hazards worth remembering while executing a plan, both already paid for //! elsewhere in this codebase (see CLAUDE.md): never call a blocking API from a //! player event callback, and never hold a lock across a `match` scrutinee. A //! teardown reaches every one of those paths at once, from a new direction. //! //! TRACES: UR-082 | DR-270 /// One executable step of a switch. /// /// TRACES: UR-082 | DR-270 #[derive(Debug, Clone, PartialEq, Eq)] pub enum SwitchStep { /// Stop playback and drop the queue. The queue cannot outlive its owner. StopPlayback, /// Flush what the outgoing profile changed while offline, so it is not /// replayed under the incoming profile's token. ParkSyncQueue { user_id: String, }, StopSessionPoller, /// Clear OS media metadata so the lockscreen does not show the outgoing /// profile's episode to whoever just took over the device. ClearLockscreenMetadata, DestroyRepository, /// The point of no return: after this, writes land under the new profile. SetActiveUser { user_id: String, }, BuildRepository { user_id: String, }, StartSessionPoller, /// Re-derive what the server currently lets this profile see. Only possible /// online; offline the cached view stays as it was, which is stale-permissive /// by design. RefreshVisibility { user_id: String, }, EmitSwitched { user_id: String, }, } /// Build the ordered plan for moving from `from` to `to`. /// /// Switching to the profile that is already active is not a no-op — it is how an /// idle re-lock is dismissed — but it must not tear down playback, or unlocking /// your own screen would stop the music. Only the emit survives. /// /// TRACES: UR-082 | DR-270 pub fn plan(from: Option<&str>, to: &str, online: bool) -> Vec { if from == Some(to) { return vec![SwitchStep::EmitSwitched { user_id: to.to_string(), }]; } let mut steps = Vec::new(); if let Some(outgoing) = from { steps.push(SwitchStep::StopPlayback); steps.push(SwitchStep::ParkSyncQueue { user_id: outgoing.to_string(), }); steps.push(SwitchStep::StopSessionPoller); steps.push(SwitchStep::ClearLockscreenMetadata); steps.push(SwitchStep::DestroyRepository); } steps.push(SwitchStep::SetActiveUser { user_id: to.to_string(), }); steps.push(SwitchStep::BuildRepository { user_id: to.to_string(), }); steps.push(SwitchStep::StartSessionPoller); if online { steps.push(SwitchStep::RefreshVisibility { user_id: to.to_string(), }); } steps.push(SwitchStep::EmitSwitched { user_id: to.to_string(), }); steps } #[cfg(test)] mod tests { use super::*; fn index_of(steps: &[SwitchStep], want: &SwitchStep) -> usize { steps .iter() .position(|s| s == want) .unwrap_or_else(|| panic!("step {:?} missing from plan {:?}", want, steps)) } /// UT: the invariant that prevents misattributed playback reports — /// everything belonging to the outgoing profile is torn down *before* the /// active user flips. #[test] fn teardown_precedes_the_flip() { let steps = plan(Some("dad"), "kid", true); let flip = index_of( &steps, &SwitchStep::SetActiveUser { user_id: "kid".to_string(), }, ); assert!(index_of(&steps, &SwitchStep::StopPlayback) < flip); assert!( index_of( &steps, &SwitchStep::ParkSyncQueue { user_id: "dad".to_string() } ) < flip ); assert!(index_of(&steps, &SwitchStep::StopSessionPoller) < flip); assert!(index_of(&steps, &SwitchStep::DestroyRepository) < flip); } /// UT: the outgoing profile's queued offline mutations are parked under /// *its* id, never the incoming one's. #[test] fn sync_queue_is_parked_for_the_outgoing_profile() { let steps = plan(Some("dad"), "kid", true); assert!(steps.contains(&SwitchStep::ParkSyncQueue { user_id: "dad".to_string() })); assert!(!steps.contains(&SwitchStep::ParkSyncQueue { user_id: "kid".to_string() })); } /// UT: the repository is rebuilt only after the flip, so it cannot be /// constructed against a user id that is about to change. #[test] fn repository_is_rebuilt_after_the_flip() { let steps = plan(Some("dad"), "kid", true); let flip = index_of( &steps, &SwitchStep::SetActiveUser { user_id: "kid".to_string(), }, ); assert!( index_of( &steps, &SwitchStep::BuildRepository { user_id: "kid".to_string() } ) > flip ); } /// UT: the switch is announced last, so nothing observing the event can /// catch the app mid-teardown. #[test] fn switch_is_announced_last() { let steps = plan(Some("dad"), "kid", true); assert_eq!( steps.last(), Some(&SwitchStep::EmitSwitched { user_id: "kid".to_string() }) ); } /// UT: first sign-in has nothing to tear down. #[test] fn cold_start_only_builds_up() { let steps = plan(None, "kid", true); assert!(!steps.contains(&SwitchStep::StopPlayback)); assert!(!steps.contains(&SwitchStep::DestroyRepository)); assert_eq!( steps.first(), Some(&SwitchStep::SetActiveUser { user_id: "kid".to_string() }) ); } /// UT: offline, visibility cannot be re-derived — the server is not there to /// say what this profile may see, and guessing would be worse than stale. #[test] fn offline_skips_visibility_refresh() { let steps = plan(Some("dad"), "kid", false); assert!(!steps .iter() .any(|s| matches!(s, SwitchStep::RefreshVisibility { .. }))); } /// UT: dismissing an idle re-lock on your own profile must not stop the /// music you were listening to. #[test] fn unlocking_the_same_profile_does_not_disturb_playback() { let steps = plan(Some("dad"), "dad", true); assert_eq!( steps, vec![SwitchStep::EmitSwitched { user_id: "dad".to_string() }] ); } }