parent
5320da4b82
commit
6005ce739f
@ -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…
Reference in new issue