import Foundation import Testing @testable import Trio @Suite("Nightscout Upload Serializer Tests") struct NightscoutUploadSerializerTests { /// Records whether an operation is currently in flight and flags any overlap. private actor OverlapDetector { private var inFlight = false private(set) var overlapDetected = false private(set) var completedRuns = 0 func enter() { if inFlight { overlapDetected = true } inFlight = true } func exit() { inFlight = false completedRuns += 1 } } /// Ordered log of named events across concurrent tasks, with a run counter. private actor EventLog { private(set) var events: [String] = [] private var runNumber = 0 func append(_ event: String) { events.append(event) } func beginRun() -> Int { runNumber += 1 return runNumber } } /// One-shot gate that suspends waiters until opened. private actor Gate { private var isOpen = false private var waiters: [CheckedContinuation] = [] func open() { isOpen = true for waiter in waiters { waiter.resume() } waiters.removeAll() } func wait() async { if isOpen { return } await withCheckedContinuation { waiters.append($0) } } } /// Thrown when `waitUntil` gives up on its condition. private struct TimeoutError: Error {} /// Polls until `condition` is true, throwing after ~2 seconds so a broken /// serializer fails the test at the wait site instead of hanging it. private func waitUntil(_ condition: @escaping @Sendable() async -> Bool) async throws { for _ in 0 ..< 400 { if await condition() { return } try await Task.sleep(nanoseconds: 5_000_000) } throw TimeoutError() } @Test("Concurrent runs of the same pipeline never overlap") func noOverlapWithinPipeline() async { let detector = OverlapDetector() let serializer = NightscoutUploadSerializer { _ in await detector.enter() try? await Task.sleep(nanoseconds: 20_000_000) await detector.exit() } await withTaskGroup(of: Void.self) { group in for _ in 0 ..< 10 { group.addTask { await serializer.run(.overrides) } } } #expect(await detector.overlapDetected == false, "No two runs of the same pipeline may be in flight at once") #expect(await detector.completedRuns >= 1, "At least one run must execute") } @Test("A run requested mid-flight executes only after the current one finishes") func queuedRunWaitsForCurrent() async throws { let log = EventLog() let gate = Gate() let serializer = NightscoutUploadSerializer { _ in let n = await log.beginRun() await log.append("start-\(n)") if n == 1 { await gate.wait() } await log.append("end-\(n)") } await serializer.request(.overrides) try await waitUntil { await log.events.contains("start-1") } await serializer.request(.overrides) await gate.open() try await waitUntil { await log.events.contains("end-2") } #expect(await log.events == ["start-1", "end-1", "start-2", "end-2"]) } @Test("Requests made while a run is in flight coalesce into a single follow-up run") func requestsCoalesceIntoOneFollowUp( ) async throws { let log = EventLog() let gate = Gate() let serializer = NightscoutUploadSerializer { _ in let n = await log.beginRun() await log.append("run-\(n)") if n == 1 { await gate.wait() } } await serializer.request(.glucose) try await waitUntil { await log.events.contains("run-1") } // All five requests arrive while the first run is blocked, so they must // coalesce into exactly one follow-up run. for _ in 0 ..< 5 { await serializer.request(.glucose) } await gate.open() try await waitUntil { await log.events.contains("run-2") } // Grace period: any extra (incorrect) follow-up runs would surface here. try await Task.sleep(nanoseconds: 100_000_000) #expect(await log.events == ["run-1", "run-2"], "Coalesced requests must produce exactly one follow-up run") } @Test("run returns only after its own operation has completed") func runAwaitsOwnOperation() async { let log = EventLog() let serializer = NightscoutUploadSerializer { _ in let n = await log.beginRun() await log.append("run-\(n)") } await serializer.run(.carbs) #expect(await log.events == ["run-1"]) await serializer.run(.carbs) #expect(await log.events == ["run-1", "run-2"]) } /// Holds the serializer so an upload operation can call back into it. private actor SerializerBox { private var serializer: NightscoutUploadSerializer? func set(_ serializer: NightscoutUploadSerializer) { self.serializer = serializer } func get() -> NightscoutUploadSerializer? { serializer } } @Test( "A re-entrant run from inside its own operation degrades to a follow-up instead of deadlocking" ) func reentrantRunDoesNotDeadlock() async throws { let log = EventLog() let reentrantLog = EventLog() let box = SerializerBox() let serializer = NightscoutUploadSerializer( uploadOperation: { _ in let n = await log.beginRun() await log.append("run-\(n)") if n == 1 { // Forbidden re-entrant call; the guard must turn it into a queued follow-up. await box.get()?.run(.overrides) } }, onReentrantRun: { pipeline in Task { await reentrantLog.append("reentrant-\(pipeline)") } } ) await box.set(serializer) await serializer.request(.overrides) try await waitUntil { await log.events.contains("run-2") } try await waitUntil { await reentrantLog.events.isEmpty == false } #expect(await log.events == ["run-1", "run-2"]) #expect(await reentrantLog.events == ["reentrant-overrides"]) } @Test("Different pipelines run independently of each other") func pipelinesAreIndependent() async throws { let log = EventLog() let gate = Gate() let serializer = NightscoutUploadSerializer { pipeline in if pipeline == .overrides { await log.append("overrides-start") await gate.wait() await log.append("overrides-end") } else { await log.append("tempTargets-done") } } await serializer.request(.overrides) try await waitUntil { await log.events.contains("overrides-start") } // Safety valve: if pipelines were wrongly serialized against each other, // the tempTargets run below would be stuck behind the gated overrides run. // Opening the gate after a delay lets the test fail on ordering instead of hanging. let safetyValve = Task { try? await Task.sleep(nanoseconds: 2_000_000_000) await gate.open() } await serializer.run(.tempTargets) await gate.open() try await waitUntil { await log.events.contains("overrides-end") } safetyValve.cancel() #expect(await log.events == ["overrides-start", "tempTargets-done", "overrides-end"]) } }