oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2import RastrilloApple
3import RastrilloTestSupport
4import Testing
5@testable import SheetsKit
6
7private 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.
13private func sheetServer(stream: ScriptedTransport.Step) -> ScriptedTransport {
14 ScriptedTransport { call in call.kind == .stream ? stream : .respond(status: 503) }
15}
16
17private 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
84private 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