diff --git a/Music/Remote/RemoteProtocol.swift b/Music/Remote/RemoteProtocol.swift new file mode 100644 index 0000000..e224328 --- /dev/null +++ b/Music/Remote/RemoteProtocol.swift @@ -0,0 +1,172 @@ +import Foundation + +// MARK: - Protocol Version + +/// Current version of the remote control wire protocol. +nonisolated let RemoteProtocolVersion: Int = 1 + +// MARK: - Supporting Types + +/// Snapshot of the host's playback state, sent to remote clients. +nonisolated struct PlaybackStatePayload: Codable, Equatable, Sendable { + var trackId: Int64? + var isPlaying: Bool + var currentTime: Double + var duration: Double + var volume: Float + var isShuffled: Bool +} + +/// Exchanged during connection setup to agree on protocol version. +nonisolated struct HandshakeMessage: Codable, Equatable, Sendable { + var protocolVersion: Int + var appVersion: String +} + +// MARK: - RemoteCommand + +/// Commands sent from a remote client to the host. +/// Wire format: `{"type":"","payload":{...}}` (payload omitted for cases with no associated values). +nonisolated enum RemoteCommand: Equatable, Sendable { + case play(trackId: Int64, queueIds: [Int64]) + case pause + case resume + case next + case previous + case seek(position: Double) + case setVolume(level: Float) + case toggleShuffle + case refreshDB +} + +extension RemoteCommand: Codable { + private enum TypeKey: String, Codable { + case play, pause, resume, next, previous, seek, setVolume, toggleShuffle, refreshDB + } + + private enum CodingKeys: String, CodingKey { + case type, payload + } + + // Payload structs for cases with associated values + private struct PlayPayload: Codable { + var trackId: Int64 + var queueIds: [Int64] + } + + private struct SeekPayload: Codable { + var position: Double + } + + private struct VolumePayload: Codable { + var level: Float + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .play(let trackId, let queueIds): + try container.encode(TypeKey.play, forKey: .type) + try container.encode(PlayPayload(trackId: trackId, queueIds: queueIds), forKey: .payload) + case .pause: + try container.encode(TypeKey.pause, forKey: .type) + case .resume: + try container.encode(TypeKey.resume, forKey: .type) + case .next: + try container.encode(TypeKey.next, forKey: .type) + case .previous: + try container.encode(TypeKey.previous, forKey: .type) + case .seek(let position): + try container.encode(TypeKey.seek, forKey: .type) + try container.encode(SeekPayload(position: position), forKey: .payload) + case .setVolume(let level): + try container.encode(TypeKey.setVolume, forKey: .type) + try container.encode(VolumePayload(level: level), forKey: .payload) + case .toggleShuffle: + try container.encode(TypeKey.toggleShuffle, forKey: .type) + case .refreshDB: + try container.encode(TypeKey.refreshDB, forKey: .type) + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(TypeKey.self, forKey: .type) + switch type { + case .play: + let payload = try container.decode(PlayPayload.self, forKey: .payload) + self = .play(trackId: payload.trackId, queueIds: payload.queueIds) + case .pause: + self = .pause + case .resume: + self = .resume + case .next: + self = .next + case .previous: + self = .previous + case .seek: + let payload = try container.decode(SeekPayload.self, forKey: .payload) + self = .seek(position: payload.position) + case .setVolume: + let payload = try container.decode(VolumePayload.self, forKey: .payload) + self = .setVolume(level: payload.level) + case .toggleShuffle: + self = .toggleShuffle + case .refreshDB: + self = .refreshDB + } + } +} + +// MARK: - HostEvent + +/// Events sent from the host to remote clients. +/// Wire format: `{"type":"","payload":{...}}` (payload omitted for cases with no associated values). +nonisolated enum HostEvent: Equatable, Sendable { + case playbackState(PlaybackStatePayload) + case dbReady + case error(message: String) +} + +extension HostEvent: Codable { + private enum TypeKey: String, Codable { + case playbackState, dbReady, error + } + + private enum CodingKeys: String, CodingKey { + case type, payload + } + + private struct ErrorPayload: Codable { + var message: String + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .playbackState(let payload): + try container.encode(TypeKey.playbackState, forKey: .type) + try container.encode(payload, forKey: .payload) + case .dbReady: + try container.encode(TypeKey.dbReady, forKey: .type) + case .error(let message): + try container.encode(TypeKey.error, forKey: .type) + try container.encode(ErrorPayload(message: message), forKey: .payload) + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(TypeKey.self, forKey: .type) + switch type { + case .playbackState: + let payload = try container.decode(PlaybackStatePayload.self, forKey: .payload) + self = .playbackState(payload) + case .dbReady: + self = .dbReady + case .error: + let payload = try container.decode(ErrorPayload.self, forKey: .payload) + self = .error(message: payload.message) + } + } +} diff --git a/MusicTests/RemoteProtocolTests.swift b/MusicTests/RemoteProtocolTests.swift new file mode 100644 index 0000000..ae8439b --- /dev/null +++ b/MusicTests/RemoteProtocolTests.swift @@ -0,0 +1,132 @@ +import Foundation +import Testing +@testable import Music + +struct RemoteProtocolTests { + private let encoder: JSONEncoder = { + let e = JSONEncoder() + e.outputFormatting = [.sortedKeys] + return e + }() + private let decoder = JSONDecoder() + + // MARK: - Helpers + + /// Encode then decode a value, returning the decoded copy. + private func roundTrip(_ value: T) throws -> T { + let data = try encoder.encode(value) + return try decoder.decode(T.self, from: data) + } + + // MARK: - RemoteCommand round-trip tests + // Each test encodes a RemoteCommand case to JSON and decodes it back, + // verifying the decoded value equals the original. + + @Test func remoteCommandRoundTrip_play() throws { + let cmd = RemoteCommand.play(trackId: 42, queueIds: [42, 43, 44, 45]) + #expect(try roundTrip(cmd) == cmd) + } + + @Test func remoteCommandRoundTrip_pause() throws { + let cmd = RemoteCommand.pause + #expect(try roundTrip(cmd) == cmd) + } + + @Test func remoteCommandRoundTrip_resume() throws { + let cmd = RemoteCommand.resume + #expect(try roundTrip(cmd) == cmd) + } + + @Test func remoteCommandRoundTrip_next() throws { + let cmd = RemoteCommand.next + #expect(try roundTrip(cmd) == cmd) + } + + @Test func remoteCommandRoundTrip_previous() throws { + let cmd = RemoteCommand.previous + #expect(try roundTrip(cmd) == cmd) + } + + @Test func remoteCommandRoundTrip_seek() throws { + let cmd = RemoteCommand.seek(position: 123.456) + #expect(try roundTrip(cmd) == cmd) + } + + @Test func remoteCommandRoundTrip_setVolume() throws { + let cmd = RemoteCommand.setVolume(level: 0.75) + #expect(try roundTrip(cmd) == cmd) + } + + @Test func remoteCommandRoundTrip_toggleShuffle() throws { + let cmd = RemoteCommand.toggleShuffle + #expect(try roundTrip(cmd) == cmd) + } + + @Test func remoteCommandRoundTrip_refreshDB() throws { + let cmd = RemoteCommand.refreshDB + #expect(try roundTrip(cmd) == cmd) + } + + // MARK: - HostEvent round-trip tests + // Each test encodes a HostEvent case to JSON and decodes it back, + // verifying the decoded value equals the original. + + @Test func hostEventRoundTrip_playbackState() throws { + let payload = PlaybackStatePayload( + trackId: 7, + isPlaying: true, + currentTime: 42.5, + duration: 210.0, + volume: 0.8, + isShuffled: false + ) + let event = HostEvent.playbackState(payload) + #expect(try roundTrip(event) == event) + } + + @Test func hostEventRoundTrip_dbReady() throws { + let event = HostEvent.dbReady + #expect(try roundTrip(event) == event) + } + + @Test func hostEventRoundTrip_error() throws { + let event = HostEvent.error(message: "Something went wrong") + #expect(try roundTrip(event) == event) + } + + // MARK: - HandshakeMessage round-trip test + // Verifies HandshakeMessage survives JSON encoding and decoding. + + @Test func handshakeMessageRoundTrip() throws { + let msg = HandshakeMessage(protocolVersion: RemoteProtocolVersion, appVersion: "1.2.3") + #expect(try roundTrip(msg) == msg) + } + + // MARK: - Wire format decode tests + // Verify that hand-crafted JSON strings matching the expected wire format + // decode correctly, ensuring the Codable implementation matches the spec. + + @Test func wireFormatDecode_playCommand() throws { + let json = """ + {"type":"play","payload":{"trackId":42,"queueIds":[42,43,44,45]}} + """ + let decoded = try decoder.decode(RemoteCommand.self, from: Data(json.utf8)) + #expect(decoded == .play(trackId: 42, queueIds: [42, 43, 44, 45])) + } + + @Test func wireFormatDecode_playbackStateEvent() throws { + let json = """ + {"type":"playbackState","payload":{"trackId":7,"isPlaying":true,"currentTime":42.5,"duration":210.0,"volume":0.8,"isShuffled":false}} + """ + let decoded = try decoder.decode(HostEvent.self, from: Data(json.utf8)) + let expected = HostEvent.playbackState(PlaybackStatePayload( + trackId: 7, + isPlaying: true, + currentTime: 42.5, + duration: 210.0, + volume: 0.8, + isShuffled: false + )) + #expect(decoded == expected) + } +}