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