parent
89d50e6e94
commit
b0359f127b
@ -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…
Reference in new issue