feat(remote): add RemoteCommand and HostEvent protocol types with tests

feat/music-streaming
Laurent 1 month ago
parent 5320da4b82
commit 6005ce739f
  1. 172
      Music/Remote/RemoteProtocol.swift
  2. 132
      MusicTests/RemoteProtocolTests.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":"<case>","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":"<case>","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)
}
}
}

@ -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<T: Codable>(_ 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)
}
}
Loading…
Cancel
Save