Create Wristivo iPhone and Apple Watch foundation
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import RemoteCore
|
||||
@testable import GestureCore
|
||||
|
||||
@Test func directionsRequireArmingAndNeutralThenEmitOnce() {
|
||||
let samples: [(Double, Double, RemoteCommand)] = [(0.9, 0, .right), (-0.9, 0, .left), (0, 0.9, .up), (0, -0.9, .down)]
|
||||
for (x, y, command) in samples {
|
||||
var gate = DirectionalGestureGate()
|
||||
#expect(gate.process(x: x, y: y, at: 0) == nil)
|
||||
let session = UUID()
|
||||
gate.arm(sessionID: session, at: 0)
|
||||
#expect(gate.process(x: x, y: y, at: 0.1) == nil)
|
||||
#expect(gate.process(x: 0, y: 0, at: 0.2) == nil)
|
||||
let intent = gate.process(x: x, y: y, at: 0.3)
|
||||
#expect(intent?.command == command)
|
||||
#expect(intent?.sessionID == session)
|
||||
#expect(gate.process(x: x, y: y, at: 1) == nil)
|
||||
#expect(gate.process(x: -x, y: -y, at: 2) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func hysteresisAndDebounceConsumeSuppressedExcursion() {
|
||||
var gate = DirectionalGestureGate()
|
||||
gate.arm(sessionID: UUID(), at: 0)
|
||||
#expect(gate.process(x: 0, y: 0, at: 0.1) == nil)
|
||||
#expect(gate.process(x: 0.9, y: 0, at: 0.2)?.command == .right)
|
||||
#expect(gate.process(x: 0.4, y: 0, at: 0.3) == nil)
|
||||
#expect(gate.process(x: 0.9, y: 0, at: 0.35) == nil)
|
||||
#expect(gate.process(x: 0, y: 0, at: 0.4) == nil)
|
||||
#expect(gate.process(x: -0.9, y: 0, at: 0.45) == nil)
|
||||
#expect(gate.process(x: -0.9, y: 0, at: 1) == nil)
|
||||
#expect(gate.process(x: 0, y: 0, at: 1.1) == nil)
|
||||
#expect(gate.process(x: -0.9, y: 0, at: 1.2)?.command == .left)
|
||||
}
|
||||
|
||||
@Test func ambiguityInvalidInputAndOutOfOrderSamplesAreSuppressed() {
|
||||
var gate = DirectionalGestureGate()
|
||||
gate.arm(sessionID: UUID(), at: 0)
|
||||
#expect(gate.process(x: 0, y: 0, at: 0.1) == nil)
|
||||
#expect(gate.process(x: 0.9, y: 0.9, at: 0.2) == nil)
|
||||
#expect(gate.process(x: 0.9, y: 0, at: 0.3) == nil)
|
||||
#expect(gate.process(x: 0, y: 0, at: 0.4) == nil)
|
||||
#expect(gate.process(x: .nan, y: 0, at: 0.5) == nil)
|
||||
#expect(gate.process(x: 0.9, y: 0, at: 0.6) == nil)
|
||||
#expect(gate.process(x: 0, y: 0, at: 1) == nil)
|
||||
#expect(gate.process(x: 0.9, y: 0, at: 0.9) == nil)
|
||||
#expect(gate.process(x: 0.9, y: 0, at: 1.1)?.command == .right)
|
||||
}
|
||||
|
||||
@Test func timeoutAndDisarmPreventActionsAndRearmingRequiresNeutral() {
|
||||
var gate = DirectionalGestureGate()
|
||||
gate.arm(sessionID: UUID(), at: 0)
|
||||
#expect(gate.process(x: 0, y: 0, at: 1) == nil)
|
||||
#expect(gate.process(x: 0.9, y: 0, at: 10) == nil)
|
||||
#expect(gate.sessionID == nil)
|
||||
gate.arm(sessionID: UUID(), at: 20)
|
||||
#expect(gate.process(x: 0.9, y: 0, at: 20.1) == nil)
|
||||
#expect(gate.process(x: 0, y: 0, at: 20.2) == nil)
|
||||
gate.disarm()
|
||||
#expect(gate.process(x: 0.9, y: 0, at: 20.3) == nil)
|
||||
}
|
||||
|
||||
@Test func rejectsInvalidConfiguration() {
|
||||
#expect(throws: GestureConfiguration.ConfigurationError.self) {
|
||||
try GestureConfiguration(activationThreshold: 0.2, neutralThreshold: 0.3, minimumInterval: 0.3, maximumArmedDuration: 10, axisDominanceRatio: 0.6)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import RemoteCore
|
||||
|
||||
private let now = Date(timeIntervalSince1970: 1_000)
|
||||
|
||||
private func envelope(session: UUID, tv: UUID, sequence: UInt64 = 1,
|
||||
id: UUID = UUID(), issued: Date = now,
|
||||
expires: Date = now.addingTimeInterval(0.5),
|
||||
version: Int = 1) -> CommandEnvelope {
|
||||
CommandEnvelope(sessionID: session, televisionID: tv, sequence: sequence,
|
||||
issuedAt: issued, expiresAt: expires, command: .right,
|
||||
id: id, schemaVersion: version)
|
||||
}
|
||||
|
||||
@Test func admitsExactlyOnceAndRequiresIncreasingSequence() {
|
||||
let session = UUID(), tv = UUID()
|
||||
var gate = CommandAdmission(sessionID: session, televisionID: tv)
|
||||
let first = envelope(session: session, tv: tv)
|
||||
#expect(gate.admit(first, now: now) == .accepted)
|
||||
#expect(gate.admit(first, now: now) == .rejected(.duplicate))
|
||||
#expect(gate.admit(envelope(session: session, tv: tv), now: now) == .rejected(.outOfOrder))
|
||||
#expect(gate.admit(envelope(session: session, tv: tv, sequence: 2), now: now) == .accepted)
|
||||
}
|
||||
|
||||
@Test func rejectsWrongRoutingAndUnsupportedSchemaWithoutConsumingSequence() {
|
||||
let session = UUID(), tv = UUID()
|
||||
var gate = CommandAdmission(sessionID: session, televisionID: tv)
|
||||
#expect(gate.admit(envelope(session: UUID(), tv: tv), now: now) == .rejected(.wrongSession))
|
||||
#expect(gate.admit(envelope(session: session, tv: UUID()), now: now) == .rejected(.wrongTelevision))
|
||||
#expect(gate.admit(envelope(session: session, tv: tv, version: 2), now: now) == .rejected(.unsupportedSchema))
|
||||
#expect(gate.admit(envelope(session: session, tv: tv), now: now) == .accepted)
|
||||
}
|
||||
|
||||
@Test func rejectsStaleExpiredFutureAndInvalidLifetime() {
|
||||
let session = UUID(), tv = UUID()
|
||||
var gate = CommandAdmission(sessionID: session, televisionID: tv)
|
||||
#expect(gate.admit(envelope(session: session, tv: tv, issued: now.addingTimeInterval(-2), expires: now.addingTimeInterval(-1.5)), now: now) == .rejected(.stale))
|
||||
#expect(gate.admit(envelope(session: session, tv: tv, issued: now.addingTimeInterval(-0.5), expires: now), now: now) == .rejected(.expired))
|
||||
#expect(gate.admit(envelope(session: session, tv: tv, issued: now.addingTimeInterval(0.3), expires: now.addingTimeInterval(0.8)), now: now) == .rejected(.futureTimestamp))
|
||||
#expect(gate.admit(envelope(session: session, tv: tv, expires: now.addingTimeInterval(2)), now: now) == .rejected(.invalidLifetime))
|
||||
#expect(gate.admit(envelope(session: session, tv: tv, expires: now), now: now) == .rejected(.invalidLifetime))
|
||||
#expect(gate.admit(envelope(session: session, tv: tv), now: Date(timeIntervalSince1970: .infinity)) == .rejected(.invalidTimestamp))
|
||||
}
|
||||
|
||||
@Test func acceptsSkewBoundaryAndMaximumLifetime() {
|
||||
let session = UUID(), tv = UUID()
|
||||
var gate = CommandAdmission(sessionID: session, televisionID: tv)
|
||||
let issued = now.addingTimeInterval(0.25)
|
||||
#expect(gate.admit(envelope(session: session, tv: tv, issued: issued, expires: issued.addingTimeInterval(1)), now: now) == .accepted)
|
||||
}
|
||||
|
||||
@Test func resetUsesFreshSessionAndOldReplayCannotPassAfterIDEviction() {
|
||||
let session = UUID(), tv = UUID()
|
||||
var gate = CommandAdmission(sessionID: session, televisionID: tv)
|
||||
let first = envelope(session: session, tv: tv, sequence: 0)
|
||||
#expect(gate.admit(first, now: now) == .accepted)
|
||||
for value in 1...CommandAdmission.rememberedIDLimit {
|
||||
#expect(gate.admit(envelope(session: session, tv: tv, sequence: UInt64(value)), now: now) == .accepted)
|
||||
}
|
||||
#expect(gate.admit(first, now: now) == .rejected(.outOfOrder))
|
||||
let newSession = gate.startNewSession(televisionID: tv)
|
||||
#expect(newSession != session)
|
||||
#expect(gate.admit(first, now: now) == .rejected(.wrongSession))
|
||||
#expect(gate.admit(envelope(session: newSession, tv: tv, sequence: 0), now: now) == .accepted)
|
||||
}
|
||||
|
||||
@Test func decodingDoesNotBypassAdmission() throws {
|
||||
let session = UUID(), tv = UUID()
|
||||
let invalid = envelope(session: session, tv: tv, version: 99)
|
||||
let decoded = try JSONDecoder().decode(CommandEnvelope.self, from: JSONEncoder().encode(invalid))
|
||||
#expect(decoded == invalid)
|
||||
var gate = CommandAdmission(sessionID: session, televisionID: tv)
|
||||
#expect(gate.admit(decoded, now: now) == .rejected(.unsupportedSchema))
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import RemoteCore
|
||||
@testable import SamsungProtocol
|
||||
|
||||
@Test func commandFramesContainExpectedClickProtocol() throws {
|
||||
let expected: [RemoteCommand: String] = [
|
||||
.up: "KEY_UP", .down: "KEY_DOWN", .left: "KEY_LEFT", .right: "KEY_RIGHT",
|
||||
.select: "KEY_ENTER", .back: "KEY_RETURN", .home: "KEY_HOME",
|
||||
.playPause: "KEY_PLAYPAUSE", .volumeUp: "KEY_VOLUP", .volumeDown: "KEY_VOLDOWN", .mute: "KEY_MUTE"
|
||||
]
|
||||
for command in RemoteCommand.allCases {
|
||||
let frame = try #require(JSONSerialization.jsonObject(with: SamsungRemoteEncoder.clickFrame(for: command)) as? [String: Any])
|
||||
#expect(frame["method"] as? String == "ms.remote.control")
|
||||
let parameters = try #require(frame["params"] as? [String: String])
|
||||
#expect(parameters == ["Cmd": "Click", "DataOfCmd": expected[command]!, "Option": "false", "TypeOfRemote": "SendRemoteKey"])
|
||||
}
|
||||
}
|
||||
|
||||
@Test func endpointEscapesTokenAndIdentifiesClient() throws {
|
||||
let token = "prototype+token /?&=#%"
|
||||
let url = try SamsungRemoteEncoder.endpoint(host: "192.0.2.10", token: token)
|
||||
let parts = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false))
|
||||
#expect(parts.scheme == "wss")
|
||||
#expect(parts.port == 8002)
|
||||
#expect(parts.host == "192.0.2.10")
|
||||
#expect(parts.path == "/api/v2/channels/samsung.remote.control")
|
||||
#expect(parts.queryItems?.first(where: { $0.name == "token" })?.value == token)
|
||||
#expect(!url.absoluteString.contains("+"))
|
||||
let name = try #require(parts.queryItems?.first(where: { $0.name == "name" })?.value)
|
||||
#expect(Data(base64Encoded: name).flatMap { String(data: $0, encoding: .utf8) } == "Wristivo")
|
||||
}
|
||||
|
||||
@Test func endpointOmitsAbsentTokenAndRejectsURLsAsHosts() throws {
|
||||
let url = try SamsungRemoteEncoder.endpoint(host: "tv.local")
|
||||
#expect(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.count == 1)
|
||||
for host in ["", "wss://tv.local", "tv.local:8002", "tv.local/path", "tv.local?token=oops", "[email protected]", "tv local", "tv.local#x"] {
|
||||
#expect(throws: SamsungRemoteEncoder.EndpointError.self) { try SamsungRemoteEncoder.endpoint(host: host) }
|
||||
}
|
||||
#expect(try SamsungRemoteEncoder.endpoint(host: "[2001:db8::1]").port == 8002)
|
||||
}
|
||||
Reference in New Issue
Block a user