274 lines
17 KiB
Swift
274 lines
17 KiB
Swift
import AppKit
|
|
|
|
@main
|
|
@MainActor
|
|
struct ResolvedPlaybackCheck {
|
|
final class Clock { var now = 100.0 }
|
|
static func require(_ condition: @autoclosure () -> Bool, _ message: String) {
|
|
if !condition() { fatalError("FAIL: " + message) }
|
|
}
|
|
static func until(_ message: String, model: PlayerModel, _ predicate: () -> Bool) async throws {
|
|
let deadline = ContinuousClock.now.advanced(by: .seconds(45))
|
|
while !predicate(), ContinuousClock.now < deadline {
|
|
if model.state.status == PLAYER_ERROR || model.commandError != nil {
|
|
fatalError("FAIL: \(message): " + (model.errorText ?? "Playback failed"))
|
|
}
|
|
try await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
require(predicate(), message)
|
|
}
|
|
static func main() async {
|
|
do { try await run() }
|
|
catch { fatalError("FAIL: " + error.localizedDescription) }
|
|
}
|
|
|
|
static func run() async throws {
|
|
PlayerModel.metadataLifecycleCheck()
|
|
if CommandLine.arguments[1] == "--metadata-lifecycle" {
|
|
print("PASS: metadata lifecycle replacement, A-B-A stale results, and actual-duration changes")
|
|
return
|
|
}
|
|
_ = NSApplication.shared
|
|
NSApp.setActivationPolicy(.prohibited)
|
|
let input = CommandLine.arguments[1]
|
|
let file = URL(fileURLWithPath: input)
|
|
let source: ResolvedSource
|
|
if input.hasPrefix("https://") {
|
|
guard case .miruro(let mediaID, let episode) = try StreamURL.parse(input) else {
|
|
fatalError("FAIL: URL smoke requires a Miruro episode")
|
|
}
|
|
source = try await MiruroResolver.resolve(mediaID: mediaID, episode: episode)
|
|
} else {
|
|
source = try ResolvedSource.load(from: file)
|
|
}
|
|
let suite = "terebi.resolved-check." + UUID().uuidString
|
|
let defaults = UserDefaults(suiteName: suite)!
|
|
defer { defaults.removePersistentDomain(forName: suite) }
|
|
let clock = Clock()
|
|
let model = PlayerModel(defaults: defaults, progressFile: CommandLine.arguments.count > 2 ? CommandLine.arguments[2] : nil,
|
|
skipClock: { clock.now })
|
|
defer { model.shutdown() }
|
|
if input.hasPrefix("https://") { model.openStream(input) }
|
|
else { model.openResolvedSource(file) }
|
|
try await until("video/audio decode and advancing playback", model: model) {
|
|
model.state.status == PLAYER_PLAYING && model.state.has_video && model.state.has_audio && model.state.position > 0.2
|
|
}
|
|
print("PASS: resolved HLS decodes video/audio and advances")
|
|
fflush(stdout)
|
|
if input.hasPrefix("https://") {
|
|
try await until("descriptive title and measured-duration metadata bands", model: model) {
|
|
model.fileName != source.title && !model.visibleIntervals.isEmpty
|
|
}
|
|
let intervals = model.visibleIntervals
|
|
require(intervals.allSatisfy {
|
|
$0.start.isFinite && $0.end.isFinite && $0.start >= 0 &&
|
|
$0.end > $0.start && $0.end <= model.state.duration
|
|
}, "bands use finite in-bounds actual media duration")
|
|
require(intervals.filter { $0.kind == .intro }.count <= 1 &&
|
|
intervals.filter { $0.kind == .outro }.count <= 1, "at most one band of each kind")
|
|
print("PASS: Miruro title '\(model.fileName!)' and \(intervals.count) measured-duration bands")
|
|
}
|
|
require(model.setPlayback(playing: false), "pause accepted")
|
|
try await until("pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
|
require(model.state.can_seek && model.state.duration > 0, "seekable measured duration")
|
|
guard let index = source.subtitles.firstIndex(where: \.isDefault) else {
|
|
fatalError("FAIL: smoke requires a default subtitle")
|
|
}
|
|
let track = source.subtitles[index]
|
|
let document = try await WebVTTDocument.load(url: track.url, headers: track.headers)
|
|
let playableCues = document.cues.filter { !$0.text.isEmpty && $0.start + 0.1 < model.state.duration }
|
|
require(playableCues.count >= 2, "smoke requires two visible subtitle cues")
|
|
let target = playableCues[1].start + min(0.1, (playableCues[1].end - playableCues[1].start) / 2)
|
|
require(!document.text(at: target).isEmpty, "forward seek targets a visible English cue")
|
|
require(model.seekRelative(target - model.state.position), "seek accepted")
|
|
try await until("seek acknowledged and subtitle synchronized", model: model) {
|
|
!model.state.seek_pending && abs(model.state.position - target) < 0.2 &&
|
|
model.subtitleText == document.text(at: model.state.position) && model.subtitleError == nil
|
|
}
|
|
require(model.selectedSubtitle == index && model.subtitleTracks[index].label == track.label, "default selection and label retained")
|
|
let backward = playableCues[0].start + min(0.1, (playableCues[0].end - playableCues[0].start) / 2)
|
|
require(model.seekRelative(backward - model.state.position), "backward seek accepted")
|
|
try await until("backward seek subtitle synchronized", model: model) {
|
|
!model.state.seek_pending && abs(model.state.position - backward) < 0.2 &&
|
|
model.subtitleText == document.text(at: model.state.position)
|
|
}
|
|
print("PASS: default VTT selected, forward/backward seek and acknowledged cue timing")
|
|
if !input.hasPrefix("https://") { try await segmentSkips(model, clock: clock, file: file) }
|
|
if !input.hasPrefix("https://") {
|
|
model.shutdown()
|
|
try await streamQueueLifecycle(source: source)
|
|
}
|
|
}
|
|
|
|
static func streamQueueLifecycle(source: ResolvedSource) async throws {
|
|
let directory = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("terebi.queue-check." + UUID().uuidString).resolvingSymlinksInPath()
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true,
|
|
attributes: [.posixPermissions: 0o700])
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let youtubeURL = URL(string: "https://www.youtube.com/watch?v=AAAAAAAAAAA")!
|
|
let episodeURL = URL(string: "https://barelystarted.miruro.tv/watch/fixture-ID/episode?ep=1")!
|
|
let firstReport = directory.appendingPathComponent("youtube.jsonl")
|
|
let secondReport = directory.appendingPathComponent("miruro.jsonl")
|
|
let queue = StreamQueue(entries: [
|
|
.init(url: youtubeURL, identity: youtubeURL.absoluteString, progressFile: firstReport, label: "First"),
|
|
.init(url: episodeURL, identity: source.identity, progressFile: secondReport, label: "Second")
|
|
])
|
|
let suite = "terebi.queue-check." + UUID().uuidString
|
|
let defaults = UserDefaults(suiteName: suite)!
|
|
defer { defaults.removePersistentDomain(forName: suite) }
|
|
var youtubeRequests = 0
|
|
var episodeRequests = 0
|
|
var stale: CheckedContinuation<YouTubeVideo, Never>?
|
|
let video = YouTubeVideo(title: "Queue YouTube fixture",
|
|
video: .init(url: source.url, headers: source.headers), audio: nil,
|
|
duration: 0, isLive: false, webpageURL: youtubeURL, height: nil, availableHeights: [])
|
|
let model = PlayerModel(defaults: defaults, streamQueue: queue, youtubeResolver: { _, _ in
|
|
youtubeRequests += 1
|
|
if youtubeRequests == 1 {
|
|
return await withCheckedContinuation { stale = $0 }
|
|
}
|
|
return video
|
|
}, miruroResolver: { _, _ in
|
|
episodeRequests += 1
|
|
return source
|
|
})
|
|
defer { model.shutdown() }
|
|
require(model.progressInitializationError == nil, "queue report registration")
|
|
model.openStreamQueue(queue)
|
|
try await until("only selected first stream resolves", model: model) { stale != nil }
|
|
require(episodeRequests == 0 && model.queue.items.map(\.url) == [youtubeURL, episodeURL],
|
|
"stream queue is lazy and preserves supplied order")
|
|
let firstID = model.queue.items[0].id
|
|
let secondID = model.queue.items[1].id
|
|
model.selectQueueItem(secondID)
|
|
try await until("manual stream selection decodes native media", model: model) {
|
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.state.position > 0.2
|
|
}
|
|
stale!.resume(returning: video)
|
|
stale = nil
|
|
// Yield to the canceled resolver; it must not open or redirect its report.
|
|
for _ in 0..<10 { await Task.yield() }
|
|
require(model.queue.currentID == secondID && model.fileName == source.title &&
|
|
!FileManager.default.fileExists(atPath: firstReport.path), "stale extraction cannot replace selected media or report")
|
|
model.advanceQueue(-1)
|
|
try await until("previous resolves YouTube afresh", model: model) {
|
|
!model.isBusy && model.state.status == PLAYER_PLAYING && model.fileName == video.title
|
|
}
|
|
require(youtubeRequests == 2 && model.queue.currentID == firstID, "manual previous preserves queue and resolves current item")
|
|
require(model.setPlayback(playing: false), "pause first queued stream")
|
|
try await until("queue pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
|
|
require(model.seekRelative(max(0, model.duration - 0.3) - model.state.position), "seek first stream near natural end")
|
|
try await until("end seek acknowledged", model: model) {
|
|
!model.state.seek_pending && !model.isBusy && abs(model.state.position - max(0, model.duration - 0.3)) < 0.1
|
|
}
|
|
require(model.setPlayback(playing: true), "play first stream to natural end")
|
|
try await until("natural end advances and freshly resolves second stream", model: model) {
|
|
model.queue.currentID == secondID && !model.isBusy && model.state.status == PLAYER_PLAYING &&
|
|
model.fileName == source.title && episodeRequests == 2
|
|
}
|
|
require(model.setPlayback(playing: false), "pause second stream for restoration")
|
|
try await until("second paused", model: model) { model.state.status == PLAYER_PAUSED }
|
|
require(model.seekRelative(0.8 - model.state.position), "save an acknowledged second-stream bookmark")
|
|
try await until("second bookmark acknowledged", model: model) {
|
|
!model.state.seek_pending && abs(model.state.position - 0.8) < 0.1
|
|
}
|
|
model.advanceQueue(-1)
|
|
try await until("revisit first report session", model: model) {
|
|
!model.isBusy && model.fileName == video.title && model.state.status == PLAYER_PLAYING
|
|
}
|
|
model.advanceQueue(1)
|
|
try await until("second revisit resumes acknowledged bookmark", model: model) {
|
|
!model.isBusy && model.fileName == source.title && model.state.status == PLAYER_PLAYING &&
|
|
model.state.position >= 0.7
|
|
}
|
|
require(youtubeRequests == 3 && episodeRequests == 3, "every revisit resolves fresh transport metadata")
|
|
model.shutdown()
|
|
func records(_ file: URL) throws -> [[String: Any]] {
|
|
try String(contentsOf: file, encoding: .utf8).split(separator: "\n").map {
|
|
try JSONSerialization.jsonObject(with: Data($0.utf8)) as! [String: Any]
|
|
}
|
|
}
|
|
let youtubeRows = try records(firstReport)
|
|
let episodeRows = try records(secondReport)
|
|
require(youtubeRows.count >= 4 && youtubeRows.allSatisfy {
|
|
$0["version"] as? Int == 1 && $0["media_url"] as? String == youtubeURL.absoluteString
|
|
}, "YouTube report appends on revisit without another identity")
|
|
require(youtubeRows.contains { $0["event"] as? String == "ended" }, "natural end retains its acknowledged completion snapshot")
|
|
require(episodeRows.count >= 6 && episodeRows.allSatisfy {
|
|
let identity = $0["media_identity"] as? [String: Any]
|
|
return $0["version"] as? Int == 2 && identity?["media_id"] as? String == "fixture-ID" &&
|
|
identity?["episode"] as? Int == 1
|
|
}, "Miruro report is isolated through selection, advance and revisits")
|
|
print("PASS: ordered lazy mixed stream queue, stale cancellation, native end advance, fresh revisits/resume and report isolation")
|
|
}
|
|
|
|
static func segmentSkips(_ model: PlayerModel, clock: Clock, file: URL) async throws {
|
|
let opening = MiruroInterval(kind: .intro, start: 0.5, end: 1.5, episode: 1)
|
|
let ending = MiruroInterval(kind: .outro, start: 2, end: model.duration, episode: 1)
|
|
let metadata = MiruroMetadata(title: nil, intervals: [opening, ending])
|
|
require(model.segmentToSkip == nil && !model.skipSegment(), "absent metadata cannot skip")
|
|
require(model.seekRelative(0.75 - model.state.position), "enter opening")
|
|
try await until("opening position acknowledged", model: model) {
|
|
!model.state.seek_pending && abs(model.state.position - 0.75) < 0.1
|
|
}
|
|
model.supplyMetadataForCheck(metadata, episode: 1)
|
|
require(model.segmentToSkip == opening, "late metadata offers opening at acknowledged paused position")
|
|
require(model.skipOfferRemainingFraction == 1, "late entry starts with a full countdown background")
|
|
clock.now = 105
|
|
require(model.skipOfferRemainingFraction == 0.5, "paused countdown drains by elapsed offer time, not media position")
|
|
clock.now = 109.999
|
|
require(model.segmentToSkip == opening, "opening remains available before ten seconds")
|
|
clock.now = 110
|
|
require(model.segmentToSkip == nil && !model.skipSegment(), "offer expires at ten seconds while paused")
|
|
require(model.skipOfferRemainingFraction == 0, "expired offer has no remaining background")
|
|
clock.now = 111
|
|
try await Task.sleep(for: .milliseconds(100))
|
|
require(model.segmentToSkip == nil, "polling does not reoffer ignored opening")
|
|
require(model.seekRelative(2.25 - model.state.position), "enter ending")
|
|
require(model.segmentToSkip == nil, "pending seek cannot offer stale opening")
|
|
try await until("ending offer", model: model) {
|
|
!model.state.seek_pending && model.segmentToSkip == ending
|
|
}
|
|
require(model.skipSegment(), "ending skip accepted")
|
|
require(model.segmentToSkip == nil, "accepted skip immediately removes button")
|
|
try await until("ending skip reaches exact media end", model: model) {
|
|
!model.state.seek_pending && abs(model.state.position - ending.end) < 0.1
|
|
}
|
|
require(!model.effectivePlaying, "paused skip does not start playback")
|
|
require(model.seekRelative(0.75 - model.state.position), "revisit ignored opening")
|
|
try await until("opening revisit acknowledged", model: model) {
|
|
!model.state.seek_pending && abs(model.state.position - 0.75) < 0.1
|
|
}
|
|
require(model.segmentToSkip == nil, "ignored opening stays dismissed when revisited")
|
|
model.closeFile()
|
|
require(model.segmentToSkip == nil, "close removes offer")
|
|
model.openResolvedSource(file)
|
|
try await until("reopened media playing", model: model) {
|
|
model.state.status == PLAYER_PLAYING && !model.state.seek_pending && !model.isBusy
|
|
}
|
|
require(model.setPlayback(playing: false), "pause reopened media")
|
|
try await until("reopened media paused", model: model) { model.state.status == PLAYER_PAUSED }
|
|
require(model.seekRelative(0.75 - model.state.position), "reenter opening after reopen")
|
|
try await until("reopened opening acknowledged", model: model) {
|
|
!model.state.seek_pending && abs(model.state.position - 0.75) < 0.1
|
|
}
|
|
model.supplyMetadataForCheck(metadata, episode: 1)
|
|
try await until("new open resets dismissal after seek settles", model: model) {
|
|
model.segmentToSkip == opening
|
|
}
|
|
require(model.setPlayback(playing: true), "resume inside opening")
|
|
try await until("playing opening offer", model: model) {
|
|
model.state.status == PLAYER_PLAYING && !model.state.buffering &&
|
|
model.state.position > 0.85 && model.segmentToSkip == opening
|
|
}
|
|
require(model.skipSegment(), "opening skip accepted")
|
|
try await until("opening skip reaches segment end", model: model) {
|
|
!model.state.seek_pending && abs(model.state.position - opening.end) < 0.1
|
|
}
|
|
require(model.segmentToSkip == nil, "segment end is outside opening")
|
|
require(model.effectivePlaying, "opening skip preserves playing intent")
|
|
print("PASS: timed opening/ending offers, dismissal, pending seeks, exact-end skips and reopen reset")
|
|
}
|
|
}
|