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}