Skip to main content

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}