feat(remote): add ConnectionState machine with transition validation

feat/music-streaming
Laurent 1 month ago
parent 89d50e6e94
commit b0359f127b
  1. 69
      Music/Remote/ConnectionState.swift
  2. 44
      MusicTests/ConnectionStateTests.swift

@ -0,0 +1,69 @@
import Foundation
/// Represents the connection lifecycle of the remote client.
/// Transitions are validated by `canTransition(to:)` to enforce a strict state machine.
nonisolated enum ConnectionState: Equatable, Sendable {
case disconnected
case discovering
case foundHost(String)
case downloadingDB
case connectingCommandChannel
case connected(String)
case connectionLost(String)
/// Human-readable status message for display in the UI.
var userMessage: String? {
switch self {
case .disconnected:
nil
case .discovering:
"Searching for hosts..."
case .foundHost(let host):
"Found \(host)"
case .downloadingDB:
"Downloading library..."
case .connectingCommandChannel:
"Connecting..."
case .connected(let host):
"Connected to \(host)"
case .connectionLost(let reason):
"Connection lost — \(reason)"
}
}
/// Whether the client is fully connected.
var isConnected: Bool {
if case .connected = self { return true }
return false
}
/// Validates whether transitioning from the current state to `next` is allowed.
///
/// Valid forward transitions follow the connection lifecycle:
/// disconnected discovering foundHost downloadingDB connectingCommandChannel connected connectionLost discovering
///
/// Any state may transition to `.disconnected` (the user can always disconnect).
func canTransition(to next: ConnectionState) -> Bool {
// Any state can go to disconnected
if case .disconnected = next { return true }
switch self {
case .disconnected:
if case .discovering = next { return true }
case .discovering:
if case .foundHost = next { return true }
case .foundHost:
if case .downloadingDB = next { return true }
case .downloadingDB:
if case .connectingCommandChannel = next { return true }
case .connectingCommandChannel:
if case .connected = next { return true }
case .connected:
if case .connectionLost = next { return true }
case .connectionLost:
if case .discovering = next { return true }
}
return false
}
}

@ -0,0 +1,44 @@
import Testing
import Foundation
@testable import Music
struct ConnectionStateTests {
// Verifies that each valid forward transition is allowed.
@Test func validTransitionsSucceed() {
#expect(ConnectionState.disconnected.canTransition(to: .discovering) == true)
#expect(ConnectionState.discovering.canTransition(to: .foundHost("Mac Mini")) == true)
#expect(ConnectionState.foundHost("Mac Mini").canTransition(to: .downloadingDB) == true)
#expect(ConnectionState.downloadingDB.canTransition(to: .connectingCommandChannel) == true)
#expect(ConnectionState.connectingCommandChannel.canTransition(to: .connected("Mac Mini")) == true)
#expect(ConnectionState.connected("Mac Mini").canTransition(to: .connectionLost("Network changed")) == true)
#expect(ConnectionState.connectionLost("Network changed").canTransition(to: .discovering) == true)
}
// Verifies that skipping states is rejected.
@Test func invalidTransitionsRejected() {
#expect(ConnectionState.disconnected.canTransition(to: .connected("Mac Mini")) == false)
#expect(ConnectionState.discovering.canTransition(to: .connected("Mac Mini")) == false)
#expect(ConnectionState.connected("Mac Mini").canTransition(to: .discovering) == false)
}
// Any state can transition to disconnected (user can always disconnect).
@Test func anyStateCanDisconnect() {
#expect(ConnectionState.discovering.canTransition(to: .disconnected) == true)
#expect(ConnectionState.foundHost("X").canTransition(to: .disconnected) == true)
#expect(ConnectionState.downloadingDB.canTransition(to: .disconnected) == true)
#expect(ConnectionState.connectingCommandChannel.canTransition(to: .disconnected) == true)
#expect(ConnectionState.connected("X").canTransition(to: .disconnected) == true)
}
// Verifies the human-readable status message for each state.
@Test func userVisibleDescriptions() {
#expect(ConnectionState.disconnected.userMessage == nil)
#expect(ConnectionState.discovering.userMessage == "Searching for hosts...")
#expect(ConnectionState.foundHost("Mac Mini").userMessage == "Found Mac Mini")
#expect(ConnectionState.downloadingDB.userMessage == "Downloading library...")
#expect(ConnectionState.connectingCommandChannel.userMessage == "Connecting...")
#expect(ConnectionState.connected("Mac Mini").userMessage == "Connected to Mac Mini")
#expect(ConnectionState.connectionLost("Network changed").userMessage == "Connection lost — Network changed")
}
}
Loading…
Cancel
Save