import Foundation import RastrilloApple import RastrilloTestSupport import Testing @testable import SheetsKit private let pageJSON = Data(#""" {"Sheet":{"ID":7,"Title":"Budget","TabID":1,"TabName":"Sheet1"},"Rows":1,"Cols":1,"Truncated":false, "MayWrite":true,"MayComment":false,"MayShare":false,"FillSwatches":[],"InkSwatches":[],"NumberFormats":[]} """#.utf8) /// A sheet whose live stream answers with `stream`; everything else is refused. private func sheetServer(stream: ScriptedTransport.Step) -> ScriptedTransport { ScriptedTransport { call in call.kind == .stream ? stream : .respond(status: 503) } } private func streams(_ transport: ScriptedTransport) -> Int { transport.calls.filter { $0.kind == .stream }.count } @MainActor private func until(_ seconds: Double, _ condition: () -> Bool) async { let deadline = Date().addingTimeInterval(seconds) while !condition() && Date() < deadline { try? await Task.sleep(for: .milliseconds(10)) } } @MainActor private func watching(_ server: ScriptedTransport, _ connectivity: NativeConnectivity) throws -> (SheetModel, Task) { let model = SheetModel(client: server, connectivity: connectivity) model.page = try JSONDecoder().decode(SheetPage.self, from: pageJSON) return (model, Task { await model.watch() }) } /// Offline, the live stream makes no timed attempts and shows "Offline"; when /// the path comes back it reconnects at once rather than after a fixed sleep. @MainActor @Test func liveStreamWaitsForThePathAndReconnectsWhenItReturns() async throws { let path = ScriptedPathSource(.satisfied) let connectivity = NativeConnectivity(source: path) let server = sheetServer(stream: .offline(.notConnectedToInternet)) let (model, task) = try watching(server, connectivity) defer { task.cancel() } await until(2) { streams(server) >= 1 } path.send(.unsatisfied) await until(2) { !connectivity.isPathSatisfied } try await Task.sleep(for: .milliseconds(600)) let offline = streams(server) #expect(model.live == "Offline") try await Task.sleep(for: .milliseconds(2600)) #expect(streams(server) == offline, "The stream retried on a timer while the path was down") let restored = Date() path.send(.satisfied) await until(2) { streams(server) > offline } #expect(streams(server) > offline, "The restored path never reconnected the stream") #expect(Date().timeIntervalSince(restored) < 1, "The reconnect waited out a sleep") } /// A stream that keeps failing at once is retried at 0.5 s, 1 s, 2 s (with /// jitter), not at a fixed interval. @MainActor @Test func liveStreamBacksOffBetweenQuickFailures() async throws { let connectivity = NativeConnectivity(source: ScriptedPathSource(.satisfied)) let server = sheetServer(stream: .respond(status: 503)) let (_, task) = try watching(server, connectivity) defer { task.cancel() } await until(2) { streams(server) >= 1 } try await Task.sleep(for: .milliseconds(1500)) let early = streams(server) #expect(early >= 2, "The first retry waited longer than the first backoff step") #expect(early <= 3, "Retries did not back off") } /// A 429 with Retry-After: 1 holds the live stream off for a second even when /// the app is foregrounded during it. @MainActor @Test func liveStreamRetryAfterSurvivesAWake() async throws { let connectivity = NativeConnectivity(source: ScriptedPathSource(.satisfied)) let server = sheetServer(stream: .respond(status: 429, headers: ["Retry-After": "1"])) let (_, task) = try watching(server, connectivity) defer { task.cancel() } await until(2) { streams(server) >= 1 } let refused = Date() try await Task.sleep(for: .milliseconds(200)) connectivity.foregrounded() try await Task.sleep(for: .milliseconds(500)) #expect(streams(server) == 1, "A foreground wake crossed the server's Retry-After") await until(2) { streams(server) >= 2 } #expect(Date().timeIntervalSince(refused) >= 0.95) } private final class FakeClock: @unchecked Sendable { private let lock = NSLock() private var value: TimeInterval = 1_000 var now: TimeInterval { lock.withLock { value } } func advance(_ seconds: TimeInterval) { lock.withLock { value += seconds } } } /// Each attempt fails after 10 s (by the model's clock) without the stream /// ever opening. That is not a stable stream: the backoff keeps growing, so /// at most three attempts in 1.6 s. @MainActor @Test func liveStreamSlowFailuresThatNeverOpenedKeepBackingOff() async throws { let clock = FakeClock() let connectivity = NativeConnectivity(source: ScriptedPathSource(.satisfied)) let server = ScriptedTransport { call in if call.kind == .stream { clock.advance(10) } return .respond(status: 500) } let model = SheetModel(client: server, connectivity: connectivity) model.reconnectClock = { clock.now } model.page = try JSONDecoder().decode(SheetPage.self, from: pageJSON) let task = Task { await model.watch() } defer { task.cancel() } await until(2) { streams(server) >= 1 } try await Task.sleep(for: .milliseconds(1600)) #expect(streams(server) <= 3, "A slow failure that never opened reset the backoff") } /// A real request to a server that is not there leaves the label "Offline", /// not "Reconnecting", even though the device's path is fine. @MainActor @Test func liveLabelIsOfflineWhenTheServerIsUnreachable() async throws { let connectivity = NativeConnectivity(source: ScriptedPathSource(.satisfied)) let closed = URL(string: "http://127.0.0.1:9")! let client = NativeHTTP(origin: closed, observer: connectivity.observer(for: closed)) let model = SheetModel(client: client, connectivity: connectivity) model.page = try JSONDecoder().decode(SheetPage.self, from: pageJSON) let task = Task { await model.watch() } defer { task.cancel() } await until(3) { model.live != "Connecting" } try await Task.sleep(for: .milliseconds(100)) #expect(model.live == "Offline") }