chore: restore product scaffold to rollback baseline
This commit is contained in:
@@ -4,17 +4,26 @@
|
||||
//! channel tree, client list, our own connection state. Owns the
|
||||
//! reducers that fold protocol events into a snapshot, and the
|
||||
//! deltas that the bridge publishes to Flutter view-models
|
||||
//! (per SAD §7.2 and SDD §5).
|
||||
//! (per SAD 7.2 and SDD 5).
|
||||
//!
|
||||
//! ## Status
|
||||
//! ## Reducer contract
|
||||
//!
|
||||
//! Scaffold only.
|
||||
//! Reducers mutate the caller-owned state passed by `&mut` and return a
|
||||
//! `Reduction` containing only the emitted `Delta` values. The caller
|
||||
//! (typically `chanora_core`) owns state storage, publishes deltas to the
|
||||
//! bridge, and executes any side effects.
|
||||
//!
|
||||
//! This design satisfies SRS-056 (deterministic deltas), SRS-057
|
||||
//! (per-connection ordering), and SRS-058 (reducer functions).
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod channel_join;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chanora_protocol::{ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ServerSnapshot};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors raised while reducing protocol events into state or
|
||||
@@ -24,8 +33,7 @@ pub enum StateError {
|
||||
/// Reducer received an event referencing an unknown entity.
|
||||
#[error("unknown entity: {0}")]
|
||||
Unknown(&'static str),
|
||||
/// A reducer invariant was violated (e.g. two clients claiming
|
||||
/// the same id).
|
||||
/// A reducer invariant was violated (e.g. two clients claiming the same id).
|
||||
#[error("state invariant: {0}")]
|
||||
Invariant(&'static str),
|
||||
}
|
||||
@@ -39,16 +47,773 @@ pub enum ConnectionState {
|
||||
Connecting,
|
||||
/// Initial state snapshot has been received; ready for use.
|
||||
Ready,
|
||||
/// Connection lost; reconnecting.
|
||||
Reconnecting,
|
||||
/// Connection lost; will not auto-reconnect at this stage.
|
||||
Lost,
|
||||
}
|
||||
|
||||
/// Authoritative mirror of a connected server's published state.
|
||||
///
|
||||
/// Constructed from a [`ServerSnapshot`] and updated through delta
|
||||
/// events. Owns the channel tree, client list, and server metadata.
|
||||
/// Satisfies SRS-054.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerState {
|
||||
/// Retained connection lifecycle state.
|
||||
pub connection_state: ConnectionState,
|
||||
/// Server name from the most recent snapshot.
|
||||
pub server_name: String,
|
||||
/// Welcome message / banner (server-provided markup).
|
||||
pub welcome_message: String,
|
||||
/// Server platform string.
|
||||
pub platform: String,
|
||||
/// Server version string.
|
||||
pub version: String,
|
||||
channels: HashMap<u64, ChannelInfo>,
|
||||
clients: HashMap<u64, ClientInfo>,
|
||||
channel_order: Vec<u64>,
|
||||
client_order: Vec<u64>,
|
||||
/// Our own client id, as the server reported it.
|
||||
pub own_client_id: u64,
|
||||
}
|
||||
|
||||
impl ServerState {
|
||||
/// Build state from the initial full snapshot. Satisfies SRS-055.
|
||||
pub fn from_snapshot(snapshot: ServerSnapshot) -> Self {
|
||||
let snapshot = normalize_snapshot(snapshot);
|
||||
let channels = snapshot
|
||||
.channels
|
||||
.iter()
|
||||
.map(|c| (c.id.0, c.clone()))
|
||||
.collect();
|
||||
let clients = snapshot
|
||||
.clients
|
||||
.iter()
|
||||
.map(|c| (c.id.0, c.clone()))
|
||||
.collect();
|
||||
let channel_order: Vec<u64> = snapshot.channels.iter().map(|c| c.id.0).collect();
|
||||
let mut client_order: Vec<u64> = snapshot.clients.iter().map(|c| c.id.0).collect();
|
||||
client_order.sort_unstable();
|
||||
client_order.dedup();
|
||||
Self {
|
||||
connection_state: ConnectionState::Ready,
|
||||
server_name: snapshot.server_name,
|
||||
welcome_message: snapshot.welcome_message,
|
||||
platform: snapshot.platform,
|
||||
version: snapshot.version,
|
||||
channels,
|
||||
clients,
|
||||
channel_order,
|
||||
client_order,
|
||||
own_client_id: snapshot.own_client_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace state with a fresh snapshot (post-reconnect). Satisfies SRS-059.
|
||||
pub fn replace_from_snapshot(&mut self, snapshot: ServerSnapshot) {
|
||||
*self = Self::from_snapshot(snapshot);
|
||||
}
|
||||
|
||||
/// Look up a channel by id.
|
||||
pub fn channel(&self, id: ChannelId) -> Option<&ChannelInfo> {
|
||||
self.channels.get(&id.0)
|
||||
}
|
||||
|
||||
/// Look up a client by id.
|
||||
pub fn client(&self, id: ClientId) -> Option<&ClientInfo> {
|
||||
self.clients.get(&id.0)
|
||||
}
|
||||
|
||||
/// All channels in protocol snapshot order. Live inserts are appended
|
||||
/// until the next full snapshot re-normalizes order.
|
||||
pub fn channels(&self) -> impl Iterator<Item = &ChannelInfo> {
|
||||
self.channel_order
|
||||
.iter()
|
||||
.filter_map(|id| self.channels.get(id))
|
||||
}
|
||||
|
||||
/// All clients in stable reducer order.
|
||||
///
|
||||
/// Full snapshots normalize clients by id to avoid inheriting upstream
|
||||
/// HashMap iteration order. Live inserts are appended in arrival order
|
||||
/// until the next full snapshot re-normalizes the list.
|
||||
pub fn clients(&self) -> impl Iterator<Item = &ClientInfo> {
|
||||
self.client_order
|
||||
.iter()
|
||||
.filter_map(|id| self.clients.get(id))
|
||||
}
|
||||
|
||||
/// Number of channels.
|
||||
pub fn channel_count(&self) -> usize {
|
||||
self.channels.len()
|
||||
}
|
||||
|
||||
/// Number of clients.
|
||||
pub fn client_count(&self) -> usize {
|
||||
self.clients.len()
|
||||
}
|
||||
|
||||
/// The channel our own client is currently in.
|
||||
pub fn own_channel(&self) -> Option<&ChannelInfo> {
|
||||
self.client(ClientId(self.own_client_id))
|
||||
.and_then(|c| self.channel(c.channel))
|
||||
}
|
||||
|
||||
/// Clients in a given channel.
|
||||
pub fn clients_in_channel(&self, channel_id: ChannelId) -> impl Iterator<Item = &ClientInfo> {
|
||||
self.client_order
|
||||
.iter()
|
||||
.filter_map(|id| self.clients.get(id))
|
||||
.filter(move |c| c.channel == channel_id)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_snapshot(snapshot: ServerSnapshot) -> ServerSnapshot {
|
||||
let mut channels = HashMap::new();
|
||||
let mut channel_order = Vec::new();
|
||||
for channel in snapshot.channels {
|
||||
if !channels.contains_key(&channel.id.0) {
|
||||
channel_order.push(channel.id.0);
|
||||
}
|
||||
channels.insert(channel.id.0, channel);
|
||||
}
|
||||
let mut clients = HashMap::new();
|
||||
for client in snapshot.clients {
|
||||
clients.insert(client.id.0, client);
|
||||
}
|
||||
let mut client_order: Vec<u64> = clients.keys().copied().collect();
|
||||
client_order.sort_unstable();
|
||||
|
||||
ServerSnapshot {
|
||||
server_name: snapshot.server_name,
|
||||
welcome_message: snapshot.welcome_message,
|
||||
platform: snapshot.platform,
|
||||
version: snapshot.version,
|
||||
channels: channel_order
|
||||
.into_iter()
|
||||
.filter_map(|id| channels.remove(&id))
|
||||
.collect(),
|
||||
clients: client_order
|
||||
.into_iter()
|
||||
.filter_map(|id| clients.remove(&id))
|
||||
.collect(),
|
||||
own_client_id: snapshot.own_client_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// A change to the server state that the bridge should publish to
|
||||
/// Flutter. Deltas are cheap to construct and carry only the
|
||||
/// information that changed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Delta {
|
||||
/// A full snapshot was applied; the entire UI should refresh.
|
||||
SnapshotApplied(ServerSnapshot),
|
||||
/// A channel was added or updated.
|
||||
ChannelUpserted(ChannelInfo),
|
||||
/// A channel was removed.
|
||||
ChannelRemoved(ChannelId),
|
||||
/// A client joined or was updated (includes moves).
|
||||
ClientUpserted(ClientInfo),
|
||||
/// A client left.
|
||||
ClientRemoved(ClientId),
|
||||
/// A chat message arrived.
|
||||
ChatMessage(ChatMessage),
|
||||
/// A client's speaking state changed.
|
||||
VoiceActivityChanged {
|
||||
/// Client whose voice activity changed.
|
||||
client_id: ClientId,
|
||||
/// Whether voice activity is currently observed.
|
||||
is_speaking: bool,
|
||||
},
|
||||
/// The connection state changed.
|
||||
ConnectionStateChanged(ConnectionState),
|
||||
}
|
||||
|
||||
/// Events that flow from the protocol layer into the state reducer.
|
||||
/// Each variant carries all data the reducer needs; no external
|
||||
/// lookups are required.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum StateEvent {
|
||||
/// Initial or post-reconnect snapshot arrived.
|
||||
Snapshot(ServerSnapshot),
|
||||
/// A new channel appeared or an existing channel was updated.
|
||||
ChannelChanged(ChannelInfo),
|
||||
/// A channel was deleted.
|
||||
ChannelDeleted(ChannelId),
|
||||
/// A client joined or was updated (including channel moves and
|
||||
/// mute/speaking state changes).
|
||||
ClientChanged(ClientInfo),
|
||||
/// A client left the server.
|
||||
ClientLeft(ClientId),
|
||||
/// A text message was received.
|
||||
ChatReceived(ChatMessage),
|
||||
/// A client's speaking state changed.
|
||||
VoiceActivityChanged {
|
||||
/// Client whose voice activity changed.
|
||||
client_id: ClientId,
|
||||
/// Whether voice activity is currently observed.
|
||||
is_speaking: bool,
|
||||
},
|
||||
/// Connection lifecycle transition.
|
||||
ConnectionChanged(ConnectionState),
|
||||
/// Reconnect started; stale state must be discarded.
|
||||
ReconnectStarted,
|
||||
}
|
||||
|
||||
/// Result of applying a single event through the reducer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Reduction {
|
||||
/// Deltas describing what changed, in application order.
|
||||
pub deltas: Vec<Delta>,
|
||||
}
|
||||
|
||||
/// Apply a [`StateEvent`] to optional [`ServerState`], returning
|
||||
/// the resulting [`Reduction`].
|
||||
///
|
||||
/// The state is taken by `&mut` so the caller retains ownership.
|
||||
/// When `state` is `None` (no active connection), most events are
|
||||
/// ignored except `ConnectionChanged` and `Snapshot` (which creates
|
||||
/// the state).
|
||||
///
|
||||
/// Deterministic: the same `(state, event)` pair always produces the
|
||||
/// same `Reduction`. Satisfies SRS-056.
|
||||
pub fn reduce(state: &mut Option<ServerState>, event: StateEvent) -> Reduction {
|
||||
match event {
|
||||
StateEvent::ConnectionChanged(cs) => {
|
||||
if let Some(s) = state {
|
||||
s.connection_state = cs;
|
||||
}
|
||||
Reduction {
|
||||
deltas: vec![Delta::ConnectionStateChanged(cs)],
|
||||
}
|
||||
}
|
||||
|
||||
StateEvent::ReconnectStarted => {
|
||||
*state = None;
|
||||
Reduction {
|
||||
deltas: vec![Delta::ConnectionStateChanged(ConnectionState::Reconnecting)],
|
||||
}
|
||||
}
|
||||
|
||||
StateEvent::Snapshot(snap) => {
|
||||
let normalized = normalize_snapshot(snap);
|
||||
*state = Some(ServerState::from_snapshot(normalized.clone()));
|
||||
Reduction {
|
||||
deltas: vec![
|
||||
Delta::ConnectionStateChanged(ConnectionState::Ready),
|
||||
Delta::SnapshotApplied(normalized),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
StateEvent::ChannelChanged(info) => match state {
|
||||
Some(s) if s.connection_state == ConnectionState::Ready => {
|
||||
if !s.channels.contains_key(&info.id.0) {
|
||||
s.channel_order.push(info.id.0);
|
||||
}
|
||||
s.channels.insert(info.id.0, info.clone());
|
||||
Reduction {
|
||||
deltas: vec![Delta::ChannelUpserted(info)],
|
||||
}
|
||||
}
|
||||
_ => Reduction { deltas: vec![] },
|
||||
},
|
||||
|
||||
StateEvent::ChannelDeleted(id) => match state {
|
||||
Some(s) if s.connection_state == ConnectionState::Ready => {
|
||||
let removed_clients: Vec<u64> = s
|
||||
.client_order
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|client_id| {
|
||||
s.clients
|
||||
.get(client_id)
|
||||
.is_some_and(|client| client.channel == id)
|
||||
})
|
||||
.collect();
|
||||
for client_id in &removed_clients {
|
||||
s.clients.remove(client_id);
|
||||
}
|
||||
s.client_order
|
||||
.retain(|existing| !removed_clients.contains(existing));
|
||||
let removed_channel = s.channels.remove(&id.0).is_some();
|
||||
s.channel_order.retain(|existing| *existing != id.0);
|
||||
let mut deltas: Vec<Delta> = removed_clients
|
||||
.into_iter()
|
||||
.map(|client_id| Delta::ClientRemoved(ClientId(client_id)))
|
||||
.collect();
|
||||
if removed_channel {
|
||||
deltas.push(Delta::ChannelRemoved(id));
|
||||
}
|
||||
Reduction {
|
||||
deltas,
|
||||
}
|
||||
}
|
||||
_ => Reduction { deltas: vec![] },
|
||||
},
|
||||
|
||||
StateEvent::ClientChanged(info) => match state {
|
||||
Some(s) if s.connection_state == ConnectionState::Ready => {
|
||||
if !s.clients.contains_key(&info.id.0) {
|
||||
s.client_order.push(info.id.0);
|
||||
}
|
||||
s.clients.insert(info.id.0, info.clone());
|
||||
Reduction {
|
||||
deltas: vec![Delta::ClientUpserted(info)],
|
||||
}
|
||||
}
|
||||
_ => Reduction { deltas: vec![] },
|
||||
},
|
||||
|
||||
StateEvent::ClientLeft(id) => match state {
|
||||
Some(s) if s.connection_state == ConnectionState::Ready => {
|
||||
s.clients.remove(&id.0);
|
||||
s.client_order.retain(|existing| *existing != id.0);
|
||||
Reduction {
|
||||
deltas: vec![Delta::ClientRemoved(id)],
|
||||
}
|
||||
}
|
||||
_ => Reduction { deltas: vec![] },
|
||||
},
|
||||
|
||||
StateEvent::ChatReceived(msg) => match state {
|
||||
Some(s) if s.connection_state == ConnectionState::Ready => Reduction {
|
||||
deltas: vec![Delta::ChatMessage(msg)],
|
||||
},
|
||||
_ => Reduction { deltas: vec![] },
|
||||
},
|
||||
|
||||
StateEvent::VoiceActivityChanged {
|
||||
client_id,
|
||||
is_speaking,
|
||||
} => match state {
|
||||
Some(s) if s.connection_state == ConnectionState::Ready => {
|
||||
if let Some(client) = s.clients.get_mut(&client_id.0) {
|
||||
client.is_speaking = is_speaking;
|
||||
Reduction {
|
||||
deltas: vec![Delta::VoiceActivityChanged {
|
||||
client_id,
|
||||
is_speaking,
|
||||
}],
|
||||
}
|
||||
} else {
|
||||
Reduction { deltas: vec![] }
|
||||
}
|
||||
}
|
||||
_ => Reduction { deltas: vec![] },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace server state with a fresh snapshot after a reconnect.
|
||||
/// Discards all previous state unconditionally (SRS-059).
|
||||
pub fn reduce_reconnect_snapshot(
|
||||
state: &mut Option<ServerState>,
|
||||
snap: ServerSnapshot,
|
||||
) -> Reduction {
|
||||
let normalized = normalize_snapshot(snap);
|
||||
*state = Some(ServerState::from_snapshot(normalized.clone()));
|
||||
Reduction {
|
||||
deltas: vec![
|
||||
Delta::ConnectionStateChanged(ConnectionState::Ready),
|
||||
Delta::SnapshotApplied(normalized),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chanora_protocol::MessageTarget;
|
||||
|
||||
fn sample_channel(id: u64) -> ChannelInfo {
|
||||
ChannelInfo {
|
||||
id: ChannelId(id),
|
||||
parent: ChannelId(0),
|
||||
name: format!("channel-{id}"),
|
||||
order: 0,
|
||||
has_password: false,
|
||||
needed_talk_power: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_client(id: u64, channel: u64) -> ClientInfo {
|
||||
ClientInfo {
|
||||
id: ClientId(id),
|
||||
channel: ChannelId(channel),
|
||||
name: format!("client-{id}"),
|
||||
input_muted: false,
|
||||
output_muted: false,
|
||||
is_speaking: false,
|
||||
is_server_query: false,
|
||||
talk_power: 0,
|
||||
talk_power_granted: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_snapshot() -> ServerSnapshot {
|
||||
ServerSnapshot {
|
||||
server_name: "Test Server".into(),
|
||||
welcome_message: String::new(),
|
||||
platform: "Linux".into(),
|
||||
version: "3.13".into(),
|
||||
channels: vec![sample_channel(1), sample_channel(2)],
|
||||
clients: vec![sample_client(10, 1)],
|
||||
own_client_id: 10,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_transitions_compile() {
|
||||
let _ = ConnectionState::Idle;
|
||||
fn snapshot_creates_state() {
|
||||
let mut state = None;
|
||||
let snapshot = sample_snapshot();
|
||||
let reduction = reduce(&mut state, StateEvent::Snapshot(snapshot.clone()));
|
||||
assert!(state.is_some());
|
||||
let s = state.as_ref().unwrap();
|
||||
assert_eq!(s.connection_state, ConnectionState::Ready);
|
||||
assert_eq!(s.channel_count(), 2);
|
||||
assert_eq!(s.client_count(), 1);
|
||||
assert_eq!(s.own_client_id, 10);
|
||||
assert_eq!(
|
||||
reduction.deltas,
|
||||
vec![
|
||||
Delta::ConnectionStateChanged(ConnectionState::Ready),
|
||||
Delta::SnapshotApplied(snapshot),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_upsert_adds_and_updates() {
|
||||
let mut state = None;
|
||||
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
|
||||
let ch = ChannelInfo {
|
||||
id: ChannelId(3),
|
||||
parent: ChannelId(1),
|
||||
name: "new-channel".into(),
|
||||
order: 1,
|
||||
has_password: true,
|
||||
needed_talk_power: Some(50),
|
||||
};
|
||||
let reduction = reduce(&mut state, StateEvent::ChannelChanged(ch.clone()));
|
||||
let s = state.as_ref().unwrap();
|
||||
assert_eq!(s.channel_count(), 3);
|
||||
assert!(s.channel(ChannelId(3)).is_some());
|
||||
assert!(matches!(&reduction.deltas[..], [Delta::ChannelUpserted(_)]));
|
||||
let updated = ChannelInfo {
|
||||
name: "renamed".into(),
|
||||
..ch
|
||||
};
|
||||
reduce(&mut state, StateEvent::ChannelChanged(updated));
|
||||
assert_eq!(
|
||||
state.as_ref().unwrap().channel(ChannelId(3)).unwrap().name,
|
||||
"renamed"
|
||||
);
|
||||
assert_eq!(state.as_ref().unwrap().channel_count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_delete_removes() {
|
||||
let mut state = None;
|
||||
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
|
||||
let reduction = reduce(&mut state, StateEvent::ChannelDeleted(ChannelId(2)));
|
||||
let s = state.as_ref().unwrap();
|
||||
assert_eq!(s.channel_count(), 1);
|
||||
assert!(s.channel(ChannelId(2)).is_none());
|
||||
assert!(matches!(&reduction.deltas[..], [Delta::ChannelRemoved(_)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_delete_removes_clients_in_deleted_channel() {
|
||||
let mut state = None;
|
||||
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
|
||||
reduce(&mut state, StateEvent::ClientChanged(sample_client(20, 2)));
|
||||
reduce(&mut state, StateEvent::ClientChanged(sample_client(30, 2)));
|
||||
|
||||
let reduction = reduce(&mut state, StateEvent::ChannelDeleted(ChannelId(2)));
|
||||
let s = state.as_ref().unwrap();
|
||||
|
||||
assert!(s.channel(ChannelId(2)).is_none());
|
||||
assert!(s.client(ClientId(20)).is_none());
|
||||
assert!(s.client(ClientId(30)).is_none());
|
||||
assert_eq!(s.client_count(), 1);
|
||||
assert_eq!(s.clients_in_channel(ChannelId(2)).count(), 0);
|
||||
assert_eq!(
|
||||
reduction.deltas,
|
||||
vec![
|
||||
Delta::ClientRemoved(ClientId(20)),
|
||||
Delta::ClientRemoved(ClientId(30)),
|
||||
Delta::ChannelRemoved(ChannelId(2)),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_upsert_adds_and_moves() {
|
||||
let mut state = None;
|
||||
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
|
||||
let new_client = sample_client(20, 2);
|
||||
let reduction = reduce(&mut state, StateEvent::ClientChanged(new_client));
|
||||
let s = state.as_ref().unwrap();
|
||||
assert_eq!(s.client_count(), 2);
|
||||
assert!(matches!(&reduction.deltas[..], [Delta::ClientUpserted(_)]));
|
||||
let moved = ClientInfo {
|
||||
channel: ChannelId(2),
|
||||
..sample_client(10, 2)
|
||||
};
|
||||
reduce(&mut state, StateEvent::ClientChanged(moved));
|
||||
assert_eq!(
|
||||
state
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.client(ClientId(10))
|
||||
.unwrap()
|
||||
.channel,
|
||||
ChannelId(2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_leave_removes() {
|
||||
let mut state = None;
|
||||
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
|
||||
let reduction = reduce(&mut state, StateEvent::ClientLeft(ClientId(10)));
|
||||
assert_eq!(state.as_ref().unwrap().client_count(), 0);
|
||||
assert!(matches!(&reduction.deltas[..], [Delta::ClientRemoved(_)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconnect_discards_stale_state() {
|
||||
let mut state = None;
|
||||
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
|
||||
assert_eq!(state.as_ref().unwrap().channel_count(), 2);
|
||||
let reduction = reduce(&mut state, StateEvent::ReconnectStarted);
|
||||
assert!(state.is_none());
|
||||
assert!(matches!(
|
||||
&reduction.deltas[..],
|
||||
[Delta::ConnectionStateChanged(ConnectionState::Reconnecting)]
|
||||
));
|
||||
let snap2 = ServerSnapshot {
|
||||
server_name: "New Server".into(),
|
||||
channels: vec![sample_channel(100)],
|
||||
clients: vec![sample_client(200, 100)],
|
||||
..sample_snapshot()
|
||||
};
|
||||
reduce_reconnect_snapshot(&mut state, snap2);
|
||||
let s = state.as_ref().unwrap();
|
||||
assert_eq!(s.server_name, "New Server");
|
||||
assert_eq!(s.channel_count(), 1);
|
||||
assert_eq!(s.client_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deltas_ignored_when_disconnected() {
|
||||
let mut state: Option<ServerState> = None;
|
||||
let r1 = reduce(&mut state, StateEvent::ChannelChanged(sample_channel(1)));
|
||||
assert!(r1.deltas.is_empty());
|
||||
let r2 = reduce(&mut state, StateEvent::ClientChanged(sample_client(1, 1)));
|
||||
assert!(r2.deltas.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_message_requires_connected_state() {
|
||||
let mut state: Option<ServerState> = None;
|
||||
let msg = ChatMessage {
|
||||
sender_id: ClientId(10),
|
||||
sender_name: "Alice".into(),
|
||||
message: "hello".into(),
|
||||
target: MessageTarget::Channel,
|
||||
};
|
||||
let disconnected = reduce(&mut state, StateEvent::ChatReceived(msg.clone()));
|
||||
assert!(disconnected.deltas.is_empty());
|
||||
|
||||
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
|
||||
reduce(
|
||||
&mut state,
|
||||
StateEvent::ConnectionChanged(ConnectionState::Lost),
|
||||
);
|
||||
let lost = reduce(&mut state, StateEvent::ChatReceived(msg.clone()));
|
||||
assert!(lost.deltas.is_empty());
|
||||
|
||||
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
|
||||
let reduction = reduce(&mut state, StateEvent::ChatReceived(msg));
|
||||
assert!(matches!(&reduction.deltas[..], [Delta::ChatMessage(_)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_state_change_produces_delta() {
|
||||
let mut state: Option<ServerState> = None;
|
||||
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
|
||||
let reduction = reduce(
|
||||
&mut state,
|
||||
StateEvent::ConnectionChanged(ConnectionState::Connecting),
|
||||
);
|
||||
assert_eq!(
|
||||
state.as_ref().unwrap().connection_state,
|
||||
ConnectionState::Connecting
|
||||
);
|
||||
assert!(matches!(
|
||||
&reduction.deltas[..],
|
||||
[Delta::ConnectionStateChanged(ConnectionState::Connecting)]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_order_is_deterministic_and_live_inserts_append() {
|
||||
let mut state = None;
|
||||
let snapshot = ServerSnapshot {
|
||||
channels: vec![sample_channel(20), sample_channel(10)],
|
||||
clients: vec![sample_client(30, 20), sample_client(10, 10)],
|
||||
..sample_snapshot()
|
||||
};
|
||||
reduce(&mut state, StateEvent::Snapshot(snapshot));
|
||||
reduce(&mut state, StateEvent::ChannelChanged(sample_channel(15)));
|
||||
reduce(&mut state, StateEvent::ClientChanged(sample_client(20, 2)));
|
||||
let s = state.as_ref().unwrap();
|
||||
let channel_ids: Vec<u64> = s.channels().map(|c| c.id.0).collect();
|
||||
let client_ids: Vec<u64> = s.clients().map(|c| c.id.0).collect();
|
||||
assert_eq!(channel_ids, vec![20, 10, 15]);
|
||||
assert_eq!(client_ids, vec![10, 30, 20]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_snapshot_duplicate_ids_are_deduplicated_in_order_vectors() {
|
||||
let mut snapshot = sample_snapshot();
|
||||
snapshot.channels.push(ChannelInfo {
|
||||
name: "duplicate".into(),
|
||||
..sample_channel(1)
|
||||
});
|
||||
snapshot.clients.push(ClientInfo {
|
||||
name: "duplicate".into(),
|
||||
..sample_client(10, 2)
|
||||
});
|
||||
|
||||
let mut state = None;
|
||||
let reduction = reduce(&mut state, StateEvent::Snapshot(snapshot));
|
||||
let s = state.as_ref().unwrap();
|
||||
|
||||
assert_eq!(s.channel_count(), 2);
|
||||
assert_eq!(s.channels().count(), 2);
|
||||
assert_eq!(s.client_count(), 1);
|
||||
assert_eq!(s.clients().count(), 1);
|
||||
assert_eq!(s.channel(ChannelId(1)).unwrap().name, "duplicate");
|
||||
assert_eq!(s.client(ClientId(10)).unwrap().channel, ChannelId(2));
|
||||
let Delta::SnapshotApplied(emitted) = &reduction.deltas[1] else {
|
||||
panic!("expected snapshot delta");
|
||||
};
|
||||
assert_eq!(emitted.channels.len(), 2);
|
||||
assert_eq!(emitted.clients.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_activity_updates_existing_client_and_emits_delta() {
|
||||
let mut state = None;
|
||||
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
|
||||
let reduction = reduce(
|
||||
&mut state,
|
||||
StateEvent::VoiceActivityChanged {
|
||||
client_id: ClientId(10),
|
||||
is_speaking: true,
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
state
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.client(ClientId(10))
|
||||
.unwrap()
|
||||
.is_speaking
|
||||
);
|
||||
assert_eq!(
|
||||
reduction.deltas,
|
||||
vec![Delta::VoiceActivityChanged {
|
||||
client_id: ClientId(10),
|
||||
is_speaking: true,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_activity_for_unknown_client_is_ignored() {
|
||||
let mut state = None;
|
||||
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
|
||||
let reduction = reduce(
|
||||
&mut state,
|
||||
StateEvent::VoiceActivityChanged {
|
||||
client_id: ClientId(999),
|
||||
is_speaking: true,
|
||||
},
|
||||
);
|
||||
assert!(reduction.deltas.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn own_channel_returns_correct_channel() {
|
||||
let mut state = None;
|
||||
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
|
||||
let own = state.as_ref().unwrap().own_channel();
|
||||
assert!(own.is_some());
|
||||
assert_eq!(own.unwrap().id, ChannelId(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clients_in_channel_filters_correctly() {
|
||||
let mut state = None;
|
||||
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
|
||||
reduce(&mut state, StateEvent::ClientChanged(sample_client(20, 1)));
|
||||
reduce(&mut state, StateEvent::ClientChanged(sample_client(30, 2)));
|
||||
let s = state.as_ref().unwrap();
|
||||
assert_eq!(s.clients_in_channel(ChannelId(1)).count(), 2);
|
||||
assert_eq!(s.clients_in_channel(ChannelId(2)).count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_delete_is_tolerated() {
|
||||
let mut state = None;
|
||||
reduce(&mut state, StateEvent::Snapshot(sample_snapshot()));
|
||||
let reduction = reduce(&mut state, StateEvent::ChannelDeleted(ChannelId(999)));
|
||||
assert!(reduction.deltas.is_empty());
|
||||
assert_eq!(state.as_ref().unwrap().channel_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_event_sequence_produces_same_state() {
|
||||
let mut a: Option<ServerState> = None;
|
||||
let mut b: Option<ServerState> = None;
|
||||
let events: Vec<StateEvent> = vec![
|
||||
StateEvent::Snapshot(sample_snapshot()),
|
||||
StateEvent::ClientChanged(sample_client(20, 2)),
|
||||
StateEvent::ChannelChanged(ChannelInfo {
|
||||
id: ChannelId(3),
|
||||
parent: ChannelId(1),
|
||||
name: "x".into(),
|
||||
order: 0,
|
||||
has_password: false,
|
||||
needed_talk_power: None,
|
||||
}),
|
||||
StateEvent::ClientLeft(ClientId(20)),
|
||||
];
|
||||
let mut deltas_a = Vec::new();
|
||||
let mut deltas_b = Vec::new();
|
||||
for e in &events {
|
||||
deltas_a.extend(reduce(&mut a, e.clone()).deltas);
|
||||
}
|
||||
for e in &events {
|
||||
deltas_b.extend(reduce(&mut b, e.clone()).deltas);
|
||||
}
|
||||
assert_eq!(
|
||||
a.as_ref().unwrap().channel_count(),
|
||||
b.as_ref().unwrap().channel_count()
|
||||
);
|
||||
assert_eq!(
|
||||
a.as_ref().unwrap().client_count(),
|
||||
b.as_ref().unwrap().client_count()
|
||||
);
|
||||
assert_eq!(
|
||||
a.as_ref().unwrap().own_client_id,
|
||||
b.as_ref().unwrap().own_client_id
|
||||
);
|
||||
assert_eq!(deltas_a, deltas_b);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user