From b0359f127b0f957aa41dd1d54a59e666ccdfda69 Mon Sep 17 00:00:00 2001 From: Laurent Date: Tue, 26 May 2026 20:52:23 +0200 Subject: [PATCH] feat(remote): add ConnectionState machine with transition validation --- Music/Remote/ConnectionState.swift | 69 +++++++++++++++++++++++++++ MusicTests/ConnectionStateTests.swift | 44 +++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 Music/Remote/ConnectionState.swift create mode 100644 MusicTests/ConnectionStateTests.swift diff --git a/Music/Remote/ConnectionState.swift b/Music/Remote/ConnectionState.swift new file mode 100644 index 0000000..0b8820f --- /dev/null +++ b/Music/Remote/ConnectionState.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 + } +} diff --git a/MusicTests/ConnectionStateTests.swift b/MusicTests/ConnectionStateTests.swift new file mode 100644 index 0000000..b4ac222 --- /dev/null +++ b/MusicTests/ConnectionStateTests.swift @@ -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") + } +}