| 1 | import Foundation |
| 2 | import RastrilloApple |
| 3 | import RastrilloTestSupport |
| 4 | import Testing |
| 5 | @testable import SheetsKit |
| 6 | |
| 7 | private let pageJSON = Data(#""" |
| 8 | {"Sheet":{"ID":7,"Title":"Budget","TabID":1,"TabName":"Sheet1"},"Rows":1,"Cols":1,"Truncated":false, |
| 9 | "MayWrite":true,"MayComment":false,"MayShare":false,"FillSwatches":[],"InkSwatches":[],"NumberFormats":[]} |
| 10 | """#.utf8) |
| 11 | |
| 12 | /// A sheet whose live stream answers with `stream`; everything else is refused. |
| 13 | private func sheetServer(stream: ScriptedTransport.Step) -> ScriptedTransport { |
| 14 | ScriptedTransport { call in call.kind == .stream ? stream : .respond(status: 503) } |
| 15 | } |
| 16 | |
| 17 | private func streams(_ transport: ScriptedTransport) -> Int { transport.calls.filter { $0.kind == .stream }.count } |
| 18 | |
| 19 | @MainActor private func until(_ seconds: Double, _ condition: () -> Bool) async { |
| 20 | let deadline = Date().addingTimeInterval(seconds) |
| 21 | while !condition() && Date() < deadline { try? await Task.sleep(for: .milliseconds(10)) } |
| 22 | } |
| 23 | |
| 24 | @MainActor private func watching(_ server: ScriptedTransport, _ connectivity: NativeConnectivity) throws -> (SheetModel, Task<Void, Never>) { |
| 25 | let model = SheetModel(client: server, connectivity: connectivity) |
| 26 | model.page = try JSONDecoder().decode(SheetPage.self, from: pageJSON) |
| 27 | return (model, Task { await model.watch() }) |
| 28 | } |
| 29 | |
| 30 | /// Offline, the live stream makes no timed attempts and shows "Offline"; when |
| 31 | /// the path comes back it reconnects at once rather than after a fixed sleep. |
| 32 | @MainActor @Test func liveStreamWaitsForThePathAndReconnectsWhenItReturns() async throws { |
| 33 | let path = ScriptedPathSource(.satisfied) |
| 34 | let connectivity = NativeConnectivity(source: path) |
| 35 | let server = sheetServer(stream: .offline(.notConnectedToInternet)) |
| 36 | let (model, task) = try watching(server, connectivity) |
| 37 | defer { task.cancel() } |
| 38 | await until(2) { streams(server) >= 1 } |
| 39 | path.send(.unsatisfied) |
| 40 | await until(2) { !connectivity.isPathSatisfied } |
| 41 | try await Task.sleep(for: .milliseconds(600)) |
| 42 | let offline = streams(server) |
| 43 | #expect(model.live == "Offline") |
| 44 | try await Task.sleep(for: .milliseconds(2600)) |
| 45 | #expect(streams(server) == offline, "The stream retried on a timer while the path was down") |
| 46 | let restored = Date() |
| 47 | path.send(.satisfied) |
| 48 | await until(2) { streams(server) > offline } |
| 49 | #expect(streams(server) > offline, "The restored path never reconnected the stream") |
| 50 | #expect(Date().timeIntervalSince(restored) < 1, "The reconnect waited out a sleep") |
| 51 | } |
| 52 | |
| 53 | /// A stream that keeps failing at once is retried at 0.5 s, 1 s, 2 s (with |
| 54 | /// jitter), not at a fixed interval. |
| 55 | @MainActor @Test func liveStreamBacksOffBetweenQuickFailures() async throws { |
| 56 | let connectivity = NativeConnectivity(source: ScriptedPathSource(.satisfied)) |
| 57 | let server = sheetServer(stream: .respond(status: 503)) |
| 58 | let (_, task) = try watching(server, connectivity) |
| 59 | defer { task.cancel() } |
| 60 | await until(2) { streams(server) >= 1 } |
| 61 | try await Task.sleep(for: .milliseconds(1500)) |
| 62 | let early = streams(server) |
| 63 | #expect(early >= 2, "The first retry waited longer than the first backoff step") |
| 64 | #expect(early <= 3, "Retries did not back off") |
| 65 | } |
| 66 | |
| 67 | /// A 429 with Retry-After: 1 holds the live stream off for a second even when |
| 68 | /// the app is foregrounded during it. |
| 69 | @MainActor @Test func liveStreamRetryAfterSurvivesAWake() async throws { |
| 70 | let connectivity = NativeConnectivity(source: ScriptedPathSource(.satisfied)) |
| 71 | let server = sheetServer(stream: .respond(status: 429, headers: ["Retry-After": "1"])) |
| 72 | let (_, task) = try watching(server, connectivity) |
| 73 | defer { task.cancel() } |
| 74 | await until(2) { streams(server) >= 1 } |
| 75 | let refused = Date() |
| 76 | try await Task.sleep(for: .milliseconds(200)) |
| 77 | connectivity.foregrounded() |
| 78 | try await Task.sleep(for: .milliseconds(500)) |
| 79 | #expect(streams(server) == 1, "A foreground wake crossed the server's Retry-After") |
| 80 | await until(2) { streams(server) >= 2 } |
| 81 | #expect(Date().timeIntervalSince(refused) >= 0.95) |
| 82 | } |
| 83 | |
| 84 | private final class FakeClock: @unchecked Sendable { |
| 85 | private let lock = NSLock() |
| 86 | private var value: TimeInterval = 1_000 |
| 87 | var now: TimeInterval { lock.withLock { value } } |
| 88 | func advance(_ seconds: TimeInterval) { lock.withLock { value += seconds } } |
| 89 | } |
| 90 | |
| 91 | /// Each attempt fails after 10 s (by the model's clock) without the stream |
| 92 | /// ever opening. That is not a stable stream: the backoff keeps growing, so |
| 93 | /// at most three attempts in 1.6 s. |
| 94 | @MainActor @Test func liveStreamSlowFailuresThatNeverOpenedKeepBackingOff() async throws { |
| 95 | let clock = FakeClock() |
| 96 | let connectivity = NativeConnectivity(source: ScriptedPathSource(.satisfied)) |
| 97 | let server = ScriptedTransport { call in |
| 98 | if call.kind == .stream { clock.advance(10) } |
| 99 | return .respond(status: 500) |
| 100 | } |
| 101 | let model = SheetModel(client: server, connectivity: connectivity) |
| 102 | model.reconnectClock = { clock.now } |
| 103 | model.page = try JSONDecoder().decode(SheetPage.self, from: pageJSON) |
| 104 | let task = Task { await model.watch() } |
| 105 | defer { task.cancel() } |
| 106 | await until(2) { streams(server) >= 1 } |
| 107 | try await Task.sleep(for: .milliseconds(1600)) |
| 108 | #expect(streams(server) <= 3, "A slow failure that never opened reset the backoff") |
| 109 | } |
| 110 | |
| 111 | /// A real request to a server that is not there leaves the label "Offline", |
| 112 | /// not "Reconnecting", even though the device's path is fine. |
| 113 | @MainActor @Test func liveLabelIsOfflineWhenTheServerIsUnreachable() async throws { |
| 114 | let connectivity = NativeConnectivity(source: ScriptedPathSource(.satisfied)) |
| 115 | let closed = URL(string: "http://127.0.0.1:9")! |
| 116 | let client = NativeHTTP(origin: closed, observer: connectivity.observer(for: closed)) |
| 117 | let model = SheetModel(client: client, connectivity: connectivity) |
| 118 | model.page = try JSONDecoder().decode(SheetPage.self, from: pageJSON) |
| 119 | let task = Task { await model.watch() } |
| 120 | defer { task.cancel() } |
| 121 | await until(3) { model.live != "Connecting" } |
| 122 | try await Task.sleep(for: .milliseconds(100)) |
| 123 | #expect(model.live == "Offline") |
| 124 | } |
| 125 | |