feat: show skip offer countdown and transitions
This commit is contained in:
@@ -331,6 +331,48 @@ def check_cli_instances(root, app, fixtures):
|
||||
wait_states(count, lambda _: True, "original players survive independent resolved GUI quits")
|
||||
print("PASS actual resolved CLI handoff: two isolated GUI episodes, authenticated encrypted HLS, independent default VTT, v2 progress and quit")
|
||||
|
||||
def check_stream_queue_instance():
|
||||
# DEBUG-queue-startup: distinguish queue startup from ordinary empty startup.
|
||||
invoke(["--new-instance", "--no-config"])
|
||||
empty_states = wait_states(1, lambda _: True, "diagnostic empty startup creates its window")
|
||||
quit_instance(next(iter(empty_states)), 1)
|
||||
wait_states(0, lambda _: True, "diagnostic empty startup quits")
|
||||
session = (root / "stream-queue-session").resolve()
|
||||
session.mkdir(mode=0o700)
|
||||
paths = [session / "first.jsonl", session / "second.jsonl"]
|
||||
urls = [reported_urls[0], canonical] # Deliberately not alphabetical URL order.
|
||||
manifest = session / "queue.json"
|
||||
manifest.write_text(json.dumps({"version": 1, "items": [
|
||||
{"url": url, "progress_file": str(path)} for url, path in zip(urls, paths)]}))
|
||||
invoke(["--new-instance", "--no-config", "--queue-file", str(manifest)])
|
||||
states = wait_states(1, lambda snapshots: all(
|
||||
state["queueURLs"] == urls and state["queueIndex"] == 0 and not state["busy"]
|
||||
and state["status"] == 3 and state["hasVideo"] and state["position"] > 0.2
|
||||
for state in snapshots.values()), "actual queue CLI delivers ordered native stream playback")
|
||||
pid = next(iter(states))
|
||||
if paths[1].exists():
|
||||
raise RuntimeError("unselected queue entry must not create or write its report")
|
||||
input_request(pid, 1, lambda state: state["queueIndex"] == 1 and not state["busy"]
|
||||
and state["status"] == 3, "native queue manual next", action="queueAdvance", offset=1)
|
||||
input_request(pid, 1, lambda state: state["queueIndex"] == 0 and not state["busy"]
|
||||
and state["status"] == 3, "native queue previous revisits owned report", action="queueAdvance", offset=-1)
|
||||
states = input_request(pid, 1, lambda state: state["status"] == 2 and not state["busy"],
|
||||
"pause queue first item", action="pause")
|
||||
target = states[pid]["duration"] - 0.3
|
||||
input_request(pid, 1, lambda state: at_position(state, target),
|
||||
"acknowledged queue end seek", action="seek", position=target)
|
||||
input_request(pid, 1, lambda state: state["queueIndex"] == 1 and not state["busy"]
|
||||
and state["status"] == 3 and state["queueURLs"] == urls,
|
||||
"natural end advances without losing supplied queue", action="play")
|
||||
quit_instance(pid, 1)
|
||||
for path, url in zip(paths, urls):
|
||||
rows = wait_report(path, {url}, lambda rows: bool(rows) and rows[-1]["event"] == "closed",
|
||||
"queue quit drains independent stable report")
|
||||
if len(rows) < 4:
|
||||
raise RuntimeError(f"queue revisit must append without clobbering earlier records: {path}: {rows}")
|
||||
wait_states(0, lambda _: True, "queue GUI quit leaves no stale instance")
|
||||
print("PASS actual queue CLI: ordered lazy streams, manual next/previous, natural end advance and private report revisits")
|
||||
|
||||
resolved_fixtures(fixtures)
|
||||
with StartupStreamingServer(fixtures) as server:
|
||||
if extractor and runtime:
|
||||
@@ -365,9 +407,16 @@ def check_cli_instances(root, app, fixtures):
|
||||
runtime_settings = {"directory": str(root), "path": str(root) + os.pathsep + os.environ.get("PATH", "")}
|
||||
(app / "Contents/Resources/cli-check.json").write_text(json.dumps(runtime_settings))
|
||||
subprocess.run(["codesign", "--force", "--sign", "-", str(app)], check=True)
|
||||
if os.environ.get("TEREBI_CHECK_STREAM_QUEUE_ONLY") == "1":
|
||||
if not extractor or not runtime:
|
||||
raise RuntimeError("stream queue CLI smoke requires bundled extractor and runtime fixtures")
|
||||
check_stream_queue_instance()
|
||||
return
|
||||
if os.environ.get("TEREBI_CHECK_RESOLVED_ONLY") == "1":
|
||||
check_resolved_instances(server, 0)
|
||||
return
|
||||
if extractor and runtime:
|
||||
check_stream_queue_instance()
|
||||
movie = fixtures / "cache.mp4"
|
||||
audio = root / "audio with spaces.wav"
|
||||
shutil.copy2(fixtures / "audio.wav", audio)
|
||||
@@ -721,6 +770,9 @@ with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
|
||||
descriptor = os.open(pipe, os.O_RDONLY | os.O_NONBLOCK)
|
||||
launch = None
|
||||
try:
|
||||
if os.environ.get("TEREBI_CHECK_STREAM_QUEUE_ONLY") == "1":
|
||||
check_cli_instances(root, app, fixtures)
|
||||
sys.exit(0)
|
||||
with selectors.DefaultSelector() as selector:
|
||||
selector.register(descriptor, selectors.EVENT_READ)
|
||||
launch = subprocess.Popen(
|
||||
|
||||
@@ -925,6 +925,8 @@ private enum CLIInstanceCheck {
|
||||
case "queue":
|
||||
model.showsQueue = command["visible"] as! Bool
|
||||
model.revealControls()
|
||||
case "queueAdvance":
|
||||
model.advanceQueue(command["offset"] as! Int)
|
||||
case "caption":
|
||||
guard let view = window.contentView, let selector = captionSelector(in: view),
|
||||
let cell = selector.cell as? NSPopUpButtonCell, let menu = selector.menu else {
|
||||
@@ -1013,6 +1015,8 @@ private enum CLIInstanceCheck {
|
||||
"canChangeQuality": model.canChangeYouTubeQuality,
|
||||
"qualityHeights": model.youtubeHeights,
|
||||
"windows": windows.count,
|
||||
"allWindows": NSApp.windows.map { ["class": NSStringFromClass(type(of: $0)),
|
||||
"visible": $0.isVisible, "main": $0.canBecomeMain, "content": $0.contentView != nil] as [String: Any] },
|
||||
"hasAudio": model.state.has_audio,
|
||||
"hasVideo": model.state.has_video,
|
||||
"error": model.errorText ?? "",
|
||||
@@ -1031,6 +1035,8 @@ private enum CLIInstanceCheck {
|
||||
"editorText": editorText,
|
||||
"compact": model.isCompact,
|
||||
"showsQueue": model.showsQueue,
|
||||
"queueURLs": model.queue.items.map(\.url.absoluteString),
|
||||
"queueIndex": model.queue.items.firstIndex { $0.id == model.queue.currentID } ?? -1,
|
||||
"window": windowSnapshot(windows.first),
|
||||
"onTop": model.isOnTop,
|
||||
]
|
||||
|
||||
@@ -105,6 +105,19 @@ struct PlaybackQueueChecks {
|
||||
"replace discards previous queue and starts new selection")
|
||||
queue.replace([])
|
||||
require(queue.items.isEmpty && queue.currentID == nil, "empty replacement clears queue")
|
||||
let youtube = URL(string: "https://www.youtube.com/watch?v=AAAAAAAAAAA")!
|
||||
let miruro = URL(string: "https://barelystarted.miruro.tv/watch/fixture/episode?ep=2")!
|
||||
queue.replaceStreams([(url: miruro, label: "Episode 2"), (url: youtube, label: "YouTube")])
|
||||
require(queue.items.map(\.url) == [miruro, youtube], "stream order is preserved without file filtering or sorting")
|
||||
require(queue.currentItem?.displayName == "Episode 2", "stream rows have descriptive labels")
|
||||
let streamID = queue.currentID!
|
||||
queue.updateCurrentLabel("Resolved episode title")
|
||||
require(queue.currentID == streamID && queue.currentItem?.displayName == "Resolved episode title",
|
||||
"metadata updates labels without changing queue identity")
|
||||
require(queue.advance(1) == youtube && queue.advance(-1) == miruro, "stream navigation uses supplied order")
|
||||
queue.move(fromOffsets: IndexSet(integer: 0), toOffset: 2)
|
||||
require(queue.currentID == streamID && queue.items.map(\.url) == [youtube, miruro], "stream reorder preserves selection")
|
||||
require(queue.remove(streamID) == nil && queue.items.map(\.url) == [youtube], "removing current final stream stops")
|
||||
print("playback queue checks passed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,69 @@ struct PlayerCommandChecks {
|
||||
fatalError("FAIL: FIFO source accepted")
|
||||
} catch {}
|
||||
|
||||
let queueFile = directory.appendingPathComponent("queue.json")
|
||||
let resolvedDirectory = realpath(directory.path, nil)!
|
||||
defer { free(resolvedDirectory) }
|
||||
let privateDirectory = URL(fileURLWithPath: String(cString: resolvedDirectory), isDirectory: true)
|
||||
require(chmod(privateDirectory.path, 0o700) == 0, "queue reports require a private owned parent")
|
||||
let youtubeReport = privateDirectory.appendingPathComponent("youtube.jsonl").path
|
||||
let miruroReport = privateDirectory.appendingPathComponent("miruro.jsonl").path
|
||||
let entries: [[String: Any]] = [
|
||||
["url": miruro.absoluteString, "progress_file": miruroReport],
|
||||
["url": canonical.absoluteString, "progress_file": youtubeReport]
|
||||
]
|
||||
func saveQueue(_ value: [String: Any]) throws {
|
||||
try JSONSerialization.data(withJSONObject: value).write(to: queueFile)
|
||||
}
|
||||
try saveQueue(["version": 1, "items": entries])
|
||||
guard case .launchQueue(let deliveredQueue, let queueIndependent, let queueBypass) =
|
||||
try PlayerCommand.parse(["--new-instance", "--no-config", "--queue-file", queueFile.path]) else {
|
||||
fatalError("FAIL: expected stream queue launch")
|
||||
}
|
||||
require(deliveredQueue == queueFile && queueIndependent && queueBypass, "queue CLI preserves independent delivery")
|
||||
let parsedQueue = try StreamQueue.load(from: queueFile)
|
||||
require(parsedQueue.entries.map(\.url) == [miruro, canonical], "stream queue order is supplied, never filename-sorted")
|
||||
require(parsedQueue.entries[0].identity.hasPrefix("miruro:") &&
|
||||
parsedQueue.entries[1].identity == canonical.absoluteString, "stable identities exclude slugs and transport URLs")
|
||||
let duplicateYouTube = ["url": "https://youtu.be/jNQXAC9IVRw", "progress_file": miruroReport]
|
||||
let duplicateMiruro = ["url": miruro.absoluteString.replacingOccurrences(of: "the-apothecary-diaries-season-3", with: "different-slug"),
|
||||
"progress_file": youtubeReport]
|
||||
let invalidQueues: [[String: Any]] = [
|
||||
["version": 2, "items": entries], ["version": true, "items": entries],
|
||||
["version": 1, "items": []], ["version": 1],
|
||||
["version": 1, "items": Array(repeating: entries[0], count: 4097)],
|
||||
["version": 1, "items": [entries[1], duplicateYouTube]],
|
||||
["version": 1, "items": [entries[0], duplicateMiruro]],
|
||||
["version": 1, "items": [entries[0], ["url": canonical.absoluteString, "progress_file": miruroReport]]],
|
||||
["version": 1, "items": [entries[0], ["url": canonical.absoluteString,
|
||||
"progress_file": privateDirectory.resolvingSymlinksInPath().appendingPathComponent("miruro.jsonl").path]]],
|
||||
["version": 1, "items": [["url": "https://cdn.example/expired.m3u8", "progress_file": youtubeReport]]],
|
||||
["version": 1, "items": [["url": canonical.absoluteString, "progress_file": "relative.jsonl"]]],
|
||||
["version": 1, "items": [["url": canonical.absoluteString, "progress_file": movie.path]]]
|
||||
]
|
||||
for document in invalidQueues {
|
||||
try saveQueue(document)
|
||||
do {
|
||||
_ = try PlayerCommand.parse(["--new-instance", "--queue-file", queueFile.path])
|
||||
fatalError("FAIL: malformed queue must never launch or degrade to single playback")
|
||||
} catch {}
|
||||
}
|
||||
try saveQueue(["version": 1, "items": entries])
|
||||
for arguments in [
|
||||
["--queue-file", queueFile.path], ["--new-instance", "--queue-file"],
|
||||
["--new-instance", "--queue-file", "queue.json"],
|
||||
["--new-instance", "--queue-file", queueFile.path, "--url", canonical.absoluteString],
|
||||
["--new-instance", "--queue-file", queueFile.path, "--progress-file", report.path],
|
||||
["--new-instance", "--queue-file", queueFile.path, "--queue-file", queueFile.path]
|
||||
] {
|
||||
do { _ = try PlayerCommand.parse(arguments); fatalError("FAIL: invalid queue option combination") }
|
||||
catch {}
|
||||
}
|
||||
require(chmod(privateDirectory.path, 0o755) == 0, "make report parent nonprivate for rejection check")
|
||||
do { _ = try StreamQueue.load(from: queueFile); fatalError("FAIL: public report directory accepted") }
|
||||
catch {}
|
||||
require(chmod(privateDirectory.path, 0o700) == 0, "restore private check directory")
|
||||
|
||||
for arguments in [["--url"], ["--url", canonical.absoluteString, movie.path], ["--"],
|
||||
[movie.path, movie.path], ["--unknown"], ["missing.mp4"], [directory.path],
|
||||
["file://remote-host/video.mp4"], ["https://example.com/video.mp4"],
|
||||
|
||||
@@ -314,6 +314,41 @@ int main(int argc, char **argv) {
|
||||
require(symlink(existingPath.c_str(), linkPath.c_str()) == 0 && rejected(linkPath), "symlink destination fails initialization");
|
||||
require([[NSData dataWithContentsOfFile:nativePath(existingPath)] isEqual:sentinel], "rejection never changes an existing destination");
|
||||
|
||||
const auto queueFirst = root + "/queue-first.jsonl", queueSecond = root + "/queue-second.jsonl";
|
||||
{
|
||||
playback::ProgressReports reports;
|
||||
reports.registerFile(firstURL, queueFirst);
|
||||
reports.registerFile(secondURL, queueSecond);
|
||||
reports.begin(firstURL);
|
||||
reports.observe(sample(PLAYER_PAUSED, 12));
|
||||
reports.begin(secondURL);
|
||||
reports.observe(sample(PLAYER_ENDED, 100));
|
||||
reports.begin(firstURL);
|
||||
reports.observe(sample(PLAYER_PAUSED, 24));
|
||||
reports.begin("miruro:unregistered:1");
|
||||
reports.observe(sample(PLAYER_PAUSED, 55));
|
||||
reports.finish();
|
||||
require(reports.error().empty(), "queue reports can revisit their owned session without reopening arbitrary files");
|
||||
}
|
||||
auto firstRows = records(queueFirst), secondRows = records(queueSecond);
|
||||
require(firstRows.count == 4 && secondRows.count == 2, "queue switches drain old reports and revisits append");
|
||||
expect(firstRows[0], @"progress", firstURL, 12, true);
|
||||
expect(firstRows[2], @"progress", firstURL, 24, true);
|
||||
expect(secondRows[0], @"ended", secondURL, 100, true);
|
||||
const auto replacedPath = root + "/replaced.jsonl";
|
||||
{
|
||||
ProgressReporter reporter(replacedPath, [] { return 100.; });
|
||||
reporter.begin(firstURL);
|
||||
reporter.observe(sample(PLAYER_PAUSED, 1));
|
||||
reporter.finish();
|
||||
require(rename(replacedPath.c_str(), (replacedPath + ".original").c_str()) == 0, "retain original inode while replacing path");
|
||||
require([sentinel writeToFile:nativePath(replacedPath) atomically:NO], "replace report with unrelated sentinel");
|
||||
bool refused = false;
|
||||
try { reporter.resume(); } catch (...) { refused = true; }
|
||||
require(refused && [[NSData dataWithContentsOfFile:nativePath(replacedPath)] isEqual:sentinel],
|
||||
"revisiting never appends to a replaced report inode");
|
||||
}
|
||||
|
||||
const auto concurrentPath = root + "/concurrent.jsonl";
|
||||
std::atomic<int> ready{0}, successes{0}, failures{0};
|
||||
std::atomic<bool> start{false};
|
||||
|
||||
@@ -94,6 +94,113 @@ struct ResolvedPlaybackCheck {
|
||||
}
|
||||
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 {
|
||||
@@ -107,10 +214,14 @@ struct ResolvedPlaybackCheck {
|
||||
}
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user