refactor: concentrate Cast ownership and preserve intent on disconnect
This commit is contained in:
@@ -17,6 +17,153 @@ struct CastPlaybackCheck {
|
||||
require(predicate(), message)
|
||||
}
|
||||
|
||||
static func interrupted(_ model: PlayerModel, source: URL, port: UInt16,
|
||||
scenario: String, gates: URL, defaults: UserDefaults) async throws {
|
||||
func signal(_ name: String) throws {
|
||||
try Data().write(to: gates.appendingPathComponent(name), options: .atomic)
|
||||
}
|
||||
func awaitSignal(_ name: String) async throws {
|
||||
try await until(model, "\(scenario): fixture signals \(name)", permitError: true) {
|
||||
FileManager.default.fileExists(atPath: gates.appendingPathComponent(name).path)
|
||||
}
|
||||
}
|
||||
let replacement = gates.appendingPathComponent("replacement.mp4")
|
||||
try FileManager.default.copyItem(at: source, to: replacement)
|
||||
let active = scenario.hasSuffix("-active")
|
||||
let expectedError = scenario == "reject-load" || scenario == "disconnect-active"
|
||||
model.setVolume(0)
|
||||
model.open(source)
|
||||
try await until(model, "interruption source plays") { model.state.status == PLAYER_PLAYING && model.canSeek }
|
||||
require(model.setPlayback(playing: false), "initial local pause accepted")
|
||||
try await until(model, "initial local pause acknowledged") {
|
||||
model.state.status == PLAYER_PAUSED && model.playbackIntent == nil
|
||||
}
|
||||
require(model.seekRelative(9.25 - model.state.position), "initial local position accepted")
|
||||
try await until(model, "initial local position acknowledged") {
|
||||
!model.state.seek_pending && abs(model.state.position - 9.25) < 0.15
|
||||
}
|
||||
if !active {
|
||||
require(model.setPlayback(playing: true), "local playback starts before pending handoff")
|
||||
try await until(model, "local playback acknowledged before handoff") {
|
||||
model.state.status == PLAYER_PLAYING && model.playbackIntent == nil
|
||||
}
|
||||
}
|
||||
let beforeConnect = model.state.position
|
||||
model.cast(to: CastDevice(id: "loopback", name: "Loopback TV", host: "127.0.0.1", port: port))
|
||||
if active {
|
||||
try await until(model, "active receiver acknowledges handoff") {
|
||||
model.castDevice != nil && !model.castConnecting && model.canSeek && model.state.status == PLAYER_PAUSED
|
||||
}
|
||||
require(abs(model.state.position - 9.25) < 0.15, "active receiver starts at acknowledged local position")
|
||||
require(model.volume == 1, "active receiver volume is visible before local rollback")
|
||||
if scenario == "disconnect-active" {
|
||||
require(model.setPlayback(playing: true), "initial receiver PLAY accepted")
|
||||
try await until(model, "initial receiver PLAY acknowledged") {
|
||||
model.state.status == PLAYER_PLAYING && model.playbackIntent == nil
|
||||
}
|
||||
}
|
||||
try signal("active")
|
||||
}
|
||||
try await awaitSignal("held")
|
||||
if !active {
|
||||
require(model.castDevice != nil && model.castConnecting, "held handoff remains pending")
|
||||
if scenario == "cancel-connect" {
|
||||
try await until(model, "pending connection does not pause local playback") {
|
||||
model.state.status == PLAYER_PLAYING && model.state.position > beforeConnect
|
||||
}
|
||||
} else {
|
||||
try await until(model, "pending LOAD follows acknowledged local pause") { model.state.status == PLAYER_PAUSED }
|
||||
}
|
||||
}
|
||||
if scenario == "reject-load" {
|
||||
try signal("observed")
|
||||
try await until(model, "LOAD rejection restores original local playback", permitError: true) {
|
||||
model.castDevice == nil && !model.castConnecting && model.commandError != nil &&
|
||||
model.state.status == PLAYER_PLAYING && model.canSeek
|
||||
}
|
||||
} else {
|
||||
switch scenario {
|
||||
case "cancel-connect", "cancel-load":
|
||||
model.stopCasting()
|
||||
case "replace-load", "replace-active":
|
||||
model.open(replacement)
|
||||
case "close-load", "close-active":
|
||||
model.closeFile()
|
||||
case "shutdown-load", "shutdown-active":
|
||||
model.shutdown()
|
||||
case "disconnect-active":
|
||||
require(model.setPlayback(playing: false) && model.setPlayback(playing: true) &&
|
||||
model.setPlayback(playing: false), "latest unacknowledged receiver PAUSE accepted")
|
||||
require(model.playbackIntent == false && model.state.status == PLAYER_PLAYING,
|
||||
"latest intent remains distinct from acknowledged receiver playback")
|
||||
require(model.seekRelative(10), "receiver seek preview accepted before disconnect")
|
||||
require(model.state.seek_pending, "receiver seek remains pending before disconnect")
|
||||
default:
|
||||
fatalError("Unknown interruption scenario: " + scenario)
|
||||
}
|
||||
try signal("interrupted")
|
||||
}
|
||||
try await awaitSignal("released")
|
||||
if scenario.hasPrefix("shutdown-") {
|
||||
require(model.castDevice == nil && !model.castConnecting && !model.canOpen && !model.canClose,
|
||||
"shutdown releases Cast ownership and permanently disables the local player")
|
||||
require(!model.setPlayback(playing: true) && !model.seekRelative(5) && model.volume == 0,
|
||||
"late receiver traffic cannot resume a destroyed player or replace local volume")
|
||||
print("PASS: " + scenario)
|
||||
return
|
||||
}
|
||||
if scenario == "disconnect-active" {
|
||||
try await until(model, "disconnect restores latest intent at acknowledged position", permitError: true) {
|
||||
model.castDevice == nil && !model.castConnecting && !model.isBusy &&
|
||||
model.state.status == PLAYER_PAUSED && !model.state.seek_pending && model.playbackIntent == nil &&
|
||||
abs(model.state.position - 9.25) < 0.15
|
||||
}
|
||||
require(abs((PlaybackProgress(defaults: defaults).position(for: source) ?? 0) - 9.25) < 0.15,
|
||||
"unacknowledged remote seek preview never replaces saved acknowledged position")
|
||||
} else if scenario.hasPrefix("close-") {
|
||||
try await until(model, "close acknowledges empty decoder after late receiver traffic") {
|
||||
model.state.status == PLAYER_EMPTY && model.fileName == nil && model.castDevice == nil && !model.isBusy
|
||||
}
|
||||
require(model.playbackIntent == nil, "close does not leave an accidental resume intent")
|
||||
model.open(replacement)
|
||||
} else if scenario.hasPrefix("replace-") {
|
||||
try await until(model, "replacement acknowledges its own local playback after late receiver traffic") {
|
||||
model.castDevice == nil && !model.castConnecting && !model.isBusy &&
|
||||
model.fileName == replacement.lastPathComponent && model.state.status == PLAYER_PLAYING && model.canSeek
|
||||
}
|
||||
} else {
|
||||
try await until(model, "cancelled or rejected handoff leaves usable local playback", permitError: expectedError) {
|
||||
model.castDevice == nil && !model.castConnecting && model.state.status == PLAYER_PLAYING && model.canSeek
|
||||
}
|
||||
}
|
||||
require(model.volume == 0, "every interrupted handoff restores local volume")
|
||||
if scenario != "disconnect-active" {
|
||||
// A native acknowledgement is the barrier after released late receiver traffic;
|
||||
// never sleep and infer that stale callbacks did nothing.
|
||||
try await until(model, "local decoder is usable after interruption", permitError: expectedError) {
|
||||
model.canSeek && !model.isBusy
|
||||
}
|
||||
require(model.setPlayback(playing: false), "post-interruption local pause accepted")
|
||||
try await until(model, "post-interruption local pause acknowledged", permitError: expectedError) {
|
||||
model.state.status == PLAYER_PAUSED && model.playbackIntent == nil
|
||||
}
|
||||
require(model.seekRelative(4.25 - model.state.position), "post-interruption local seek accepted")
|
||||
try await until(model, "local seek acknowledges after obsolete receiver replies", permitError: expectedError) {
|
||||
!model.state.seek_pending && abs(model.state.position - 4.25) < 0.15
|
||||
}
|
||||
require(model.castDevice == nil && !model.castConnecting && model.volume == 0,
|
||||
"obsolete receiver callbacks cannot reclaim playback or volume")
|
||||
if scenario.hasPrefix("replace-") || scenario.hasPrefix("close-") {
|
||||
require(model.fileName == replacement.lastPathComponent, "interruption preserves replacement media identity")
|
||||
}
|
||||
}
|
||||
model.closeFile()
|
||||
try await until(model, "interrupted scenario closes all playback", permitError: expectedError) {
|
||||
model.state.status == PLAYER_EMPTY && model.castDevice == nil && !model.castConnecting
|
||||
}
|
||||
print("PASS: " + scenario)
|
||||
}
|
||||
|
||||
static func main() async throws {
|
||||
_ = NSApplication.shared
|
||||
NSApp.setActivationPolicy(.prohibited)
|
||||
@@ -32,6 +179,11 @@ struct CastPlaybackCheck {
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let model = PlayerModel(defaults: defaults)
|
||||
defer { model.shutdown() }
|
||||
if CommandLine.arguments.count == 5 {
|
||||
try await interrupted(model, source: source, port: port, scenario: CommandLine.arguments[3],
|
||||
gates: URL(fileURLWithPath: CommandLine.arguments[4]), defaults: defaults)
|
||||
return
|
||||
}
|
||||
model.setVolume(0)
|
||||
model.open(source)
|
||||
try await until(model, "local source plays") { model.state.status == PLAYER_PLAYING && model.canSeek }
|
||||
|
||||
Reference in New Issue
Block a user