jellytau_lib/profiles/switch.rs
1//! Profile switch orchestration, as a plan rather than a procedure.
2//!
3//! Switching profiles tears down and rebuilds nearly everything the app holds:
4//! the player and its queue, the sync queue drain, the session poller, the
5//! lockscreen metadata, the repository handle. The *ordering* of that teardown
6//! is a correctness invariant, not an implementation detail — a straggler that
7//! reports after the active user has flipped attributes one account's viewing to
8//! another, which is silent, plausible-looking, and unrecoverable.
9//!
10//! So the ordering lives here as a pure function returning a list of steps, and
11//! the command layer executes them. That is the only way this gets tested: an
12//! end-to-end switch needs two real accounts on a real server, which CI does not
13//! have and never will. The plan needs nothing.
14//!
15//! Two hazards worth remembering while executing a plan, both already paid for
16//! elsewhere in this codebase (see CLAUDE.md): never call a blocking API from a
17//! player event callback, and never hold a lock across a `match` scrutinee. A
18//! teardown reaches every one of those paths at once, from a new direction.
19//!
20//! TRACES: UR-082 | DR-270
21
22/// One executable step of a switch.
23///
24/// TRACES: UR-082 | DR-270
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum SwitchStep {
27 /// Stop playback and drop the queue. The queue cannot outlive its owner.
28 StopPlayback,
29 /// Flush what the outgoing profile changed while offline, so it is not
30 /// replayed under the incoming profile's token.
31 ParkSyncQueue {
32 user_id: String,
33 },
34 StopSessionPoller,
35 /// Clear OS media metadata so the lockscreen does not show the outgoing
36 /// profile's episode to whoever just took over the device.
37 ClearLockscreenMetadata,
38 DestroyRepository,
39 /// The point of no return: after this, writes land under the new profile.
40 SetActiveUser {
41 user_id: String,
42 },
43 BuildRepository {
44 user_id: String,
45 },
46 StartSessionPoller,
47 /// Re-derive what the server currently lets this profile see. Only possible
48 /// online; offline the cached view stays as it was, which is stale-permissive
49 /// by design.
50 RefreshVisibility {
51 user_id: String,
52 },
53 EmitSwitched {
54 user_id: String,
55 },
56}
57
58/// Build the ordered plan for moving from `from` to `to`.
59///
60/// Switching to the profile that is already active is not a no-op — it is how an
61/// idle re-lock is dismissed — but it must not tear down playback, or unlocking
62/// your own screen would stop the music. Only the emit survives.
63///
64/// TRACES: UR-082 | DR-270
65pub fn plan(from: Option<&str>, to: &str, online: bool) -> Vec<SwitchStep> {
66 if from == Some(to) {
67 return vec![SwitchStep::EmitSwitched {
68 user_id: to.to_string(),
69 }];
70 }
71
72 let mut steps = Vec::new();
73
74 if let Some(outgoing) = from {
75 steps.push(SwitchStep::StopPlayback);
76 steps.push(SwitchStep::ParkSyncQueue {
77 user_id: outgoing.to_string(),
78 });
79 steps.push(SwitchStep::StopSessionPoller);
80 steps.push(SwitchStep::ClearLockscreenMetadata);
81 steps.push(SwitchStep::DestroyRepository);
82 }
83
84 steps.push(SwitchStep::SetActiveUser {
85 user_id: to.to_string(),
86 });
87 steps.push(SwitchStep::BuildRepository {
88 user_id: to.to_string(),
89 });
90 steps.push(SwitchStep::StartSessionPoller);
91
92 if online {
93 steps.push(SwitchStep::RefreshVisibility {
94 user_id: to.to_string(),
95 });
96 }
97
98 steps.push(SwitchStep::EmitSwitched {
99 user_id: to.to_string(),
100 });
101
102 steps
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108
109 fn index_of(steps: &[SwitchStep], want: &SwitchStep) -> usize {
110 steps
111 .iter()
112 .position(|s| s == want)
113 .unwrap_or_else(|| panic!("step {:?} missing from plan {:?}", want, steps))
114 }
115
116 /// UT: the invariant that prevents misattributed playback reports —
117 /// everything belonging to the outgoing profile is torn down *before* the
118 /// active user flips.
119 #[test]
120 fn teardown_precedes_the_flip() {
121 let steps = plan(Some("dad"), "kid", true);
122 let flip = index_of(
123 &steps,
124 &SwitchStep::SetActiveUser {
125 user_id: "kid".to_string(),
126 },
127 );
128
129 assert!(index_of(&steps, &SwitchStep::StopPlayback) < flip);
130 assert!(
131 index_of(
132 &steps,
133 &SwitchStep::ParkSyncQueue {
134 user_id: "dad".to_string()
135 }
136 ) < flip
137 );
138 assert!(index_of(&steps, &SwitchStep::StopSessionPoller) < flip);
139 assert!(index_of(&steps, &SwitchStep::DestroyRepository) < flip);
140 }
141
142 /// UT: the outgoing profile's queued offline mutations are parked under
143 /// *its* id, never the incoming one's.
144 #[test]
145 fn sync_queue_is_parked_for_the_outgoing_profile() {
146 let steps = plan(Some("dad"), "kid", true);
147 assert!(steps.contains(&SwitchStep::ParkSyncQueue {
148 user_id: "dad".to_string()
149 }));
150 assert!(!steps.contains(&SwitchStep::ParkSyncQueue {
151 user_id: "kid".to_string()
152 }));
153 }
154
155 /// UT: the repository is rebuilt only after the flip, so it cannot be
156 /// constructed against a user id that is about to change.
157 #[test]
158 fn repository_is_rebuilt_after_the_flip() {
159 let steps = plan(Some("dad"), "kid", true);
160 let flip = index_of(
161 &steps,
162 &SwitchStep::SetActiveUser {
163 user_id: "kid".to_string(),
164 },
165 );
166 assert!(
167 index_of(
168 &steps,
169 &SwitchStep::BuildRepository {
170 user_id: "kid".to_string()
171 }
172 ) > flip
173 );
174 }
175
176 /// UT: the switch is announced last, so nothing observing the event can
177 /// catch the app mid-teardown.
178 #[test]
179 fn switch_is_announced_last() {
180 let steps = plan(Some("dad"), "kid", true);
181 assert_eq!(
182 steps.last(),
183 Some(&SwitchStep::EmitSwitched {
184 user_id: "kid".to_string()
185 })
186 );
187 }
188
189 /// UT: first sign-in has nothing to tear down.
190 #[test]
191 fn cold_start_only_builds_up() {
192 let steps = plan(None, "kid", true);
193 assert!(!steps.contains(&SwitchStep::StopPlayback));
194 assert!(!steps.contains(&SwitchStep::DestroyRepository));
195 assert_eq!(
196 steps.first(),
197 Some(&SwitchStep::SetActiveUser {
198 user_id: "kid".to_string()
199 })
200 );
201 }
202
203 /// UT: offline, visibility cannot be re-derived — the server is not there to
204 /// say what this profile may see, and guessing would be worse than stale.
205 #[test]
206 fn offline_skips_visibility_refresh() {
207 let steps = plan(Some("dad"), "kid", false);
208 assert!(!steps
209 .iter()
210 .any(|s| matches!(s, SwitchStep::RefreshVisibility { .. })));
211 }
212
213 /// UT: dismissing an idle re-lock on your own profile must not stop the
214 /// music you were listening to.
215 #[test]
216 fn unlocking_the_same_profile_does_not_disturb_playback() {
217 let steps = plan(Some("dad"), "dad", true);
218 assert_eq!(
219 steps,
220 vec![SwitchStep::EmitSwitched {
221 user_id: "dad".to_string()
222 }]
223 );
224 }
225}