// How AppModel keeps the feed honest around changes: a refresh asked for during another runs again, // an answer sent before a change or a reset is dropped, action errors outlive the reloads after // them, and the disk cache skips unchanged writes. These write the complication's shared state, so // they run in the serialized SharedStateTests suite and put that state back after. import Foundation import Testing @testable import WristCode /// A gateway whose feed answers wait for the test to hand them over, unless `holdFeed` is off, and /// whose other calls fail with `failure` when it is set. private actor FakeGateway: GatewayAPI { struct Unused: Error {} var holdFeed = true var feedAnswer = Feed(needsYou: [], working: [], recent: [], hosts: []) var failure: GatewayError? var snoozedItems: [FeedItem] = [] private(set) var feedCalls = 0 private var waiting: [CheckedContinuation] = [] var waitingFeeds: Int { waiting.count } func set(holdFeed: Bool? = nil, feed: Feed? = nil, failure: GatewayError?? = nil, snoozed: [FeedItem]? = nil) { if let holdFeed { self.holdFeed = holdFeed } if let feed { feedAnswer = feed } if let failure { self.failure = failure } if let snoozed { snoozedItems = snoozed } } /// Answers the oldest feed request still waiting. func answerFeed(_ feed: Feed) { waiting.removeFirst().resume(returning: feed) } private func check() throws { if let failure { throw failure } } func feed() async throws -> Feed { feedCalls += 1 guard holdFeed else { return feedAnswer } return try await withCheckedThrowingContinuation { waiting.append($0) } } func snoozed() async throws -> RecentResponse { RecentResponse(items: snoozedItems) } func settle(hostId: String, threadId: String) async throws { try check() } func unsnooze(hostId: String, threadId: String) async throws { try check() } func settleRecent() async throws -> SettleRecentResponse { try check(); throw Unused() } func recent() async throws -> RecentResponse { try check(); throw Unused() } func projects() async throws -> ProjectsResponse { try check(); throw Unused() } func summary() async throws -> Summary { throw Unused() } func thread(hostId: String, threadId: String) async throws -> ThreadDetail { throw Unused() } func history(hostId: String, threadId: String, cursor: String) async throws -> HistoryResponse { throw Unused() } func sendMessage(hostId: String, threadId: String, text: String) async throws { throw Unused() } func respond(hostId: String, threadId: String, request: RespondRequest) async throws { throw Unused() } func stop(hostId: String, threadId: String) async throws { throw Unused() } func setAskFirst(hostId: String, threadId: String, askFirst: Bool) async throws { throw Unused() } func snooze(hostId: String, threadId: String, until: Date) async throws { throw Unused() } func projectThreads(hostId: String, projectId: String) async throws -> ThreadListResponse { throw Unused() } func search(_ query: String) async throws -> SearchResponse { throw Unused() } func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse { throw Unused() } func registerPush(_ registration: PushRegistration) async throws { throw Unused() } func status() async throws -> StatusResponse { throw Unused() } func reconnect(hostId: String) async throws { throw Unused() } func testPush() async throws -> PushResult { throw Unused() } func setNotification(_ key: NotificationPrefs.Key, _ on: Bool) async throws -> NotificationPrefs { throw Unused() } func unpair() async throws { throw Unused() } } /// Waits, a turn at a time, until `condition` holds. @MainActor private func until(_ condition: () async -> Bool) async throws { for _ in 0 ..< 1000 { if await condition() { return } await Task.yield() } Issue.record("Timed out") throw CancellationError() } /// Lets tasks already queued on the main actor run up to their first wait. @MainActor private func settleMainActor() async { for _ in 0 ..< 5 { await Task.yield() } } /// Puts the complication's shared state back after `body`. @MainActor private func keepingSharedState(_ body: () async throws -> Void) async rethrows { let demo = SharedState.demo let summary = SharedState.lastSummary defer { SharedState.demo = demo SharedState.lastSummary = summary } try await body() } private let fixtures = DemoFixtures.previewFeed(now: Date(timeIntervalSince1970: 1_800_000_000)) /// A feed with one recent thread, and the same feed after it settled. private let withThread = Feed(needsYou: [], working: [], recent: [fixtures.recent[0]], hosts: fixtures.hosts) private let settled = Feed(needsYou: [], working: [], recent: [], hosts: fixtures.hosts) extension SharedStateTests { @MainActor @Test func refreshAskedForDuringAnotherRunsAgain() async throws { try await keepingSharedState { let gateway = FakeGateway() let model = AppModel(mode: .demo, gateway: gateway) let poll = Task { await model.refreshFeed() } try await until { await gateway.waitingFeeds == 1 } // A change lands while the poll is out. Its refresh waits instead of being dropped. let afterChange = Task { await model.refreshFeed() } await settleMainActor() await gateway.answerFeed(withThread) try await until { await gateway.waitingFeeds == 1 } #expect(model.feed == nil) await gateway.answerFeed(settled) await poll.value await afterChange.value #expect(model.feed == settled) #expect(await gateway.feedCalls == 2) } } @MainActor @Test func feedAnswersFromBeforeAChangeOrResetAreDropped() async throws { try await keepingSharedState { let gateway = FakeGateway() let model = AppModel(mode: .demo, gateway: gateway) let poll = Task { await model.refreshFeed() } try await until { await gateway.waitingFeeds == 1 } model.beginChange() await gateway.answerFeed(withThread) try await until { await gateway.waitingFeeds == 1 } await gateway.answerFeed(settled) await poll.value #expect(model.feed == settled) // Signing out while a refresh is out: its answer never lands. let late = Task { await model.refreshFeed() } try await until { await gateway.waitingFeeds == 1 } model.signOut() await gateway.answerFeed(withThread) await late.value #expect(model.feed == nil) #expect(SharedState.lastSummary == nil) #expect(await gateway.feedCalls == 3) } } @MainActor @Test func failedUnsnoozeShowsItsErrorAfterTheReload() async throws { try await keepingSharedState { let item = fixtures.recent[0] let gateway = FakeGateway() await gateway.set(holdFeed: false, failure: .transport("Gateway down"), snoozed: [item]) let model = AppModel(mode: .demo, snoozed: [item], gateway: gateway) await model.unsnooze(item) #expect(model.snoozedError == "Gateway down") #expect(model.snoozed == [item]) } } @MainActor @Test func settleErrorsLeaveWithTheirThreadsAndOnSignOut() async throws { try await keepingSharedState { let item = fixtures.recent[0] let gateway = FakeGateway() await gateway.set(holdFeed: false, feed: withThread, failure: .transport("Gateway down")) let model = AppModel(mode: .demo, gateway: gateway) await model.settle([item]) #expect(model.settleErrors[item.id] == "Gateway down") // The thread left the feed elsewhere, so its error goes too. await gateway.set(feed: settled) await model.refreshFeed() #expect(model.settleErrors.isEmpty) await gateway.set(feed: withThread) await model.settle([item]) await model.settleAllRecent() await model.loadAllRecent() await model.loadProjects() await model.unsnooze(item) #expect(!model.settleErrors.isEmpty) #expect(model.settleAllError != nil) #expect(model.allRecentError != nil) #expect(model.projectsError != nil) #expect(model.snoozedError != nil) model.signOut() #expect(model.settleErrors.isEmpty) #expect(model.settling.isEmpty) #expect(!model.settlingAll) #expect(model.settleAllError == nil) #expect(model.allRecentError == nil) #expect(model.projectsError == nil) #expect(model.snoozedError == nil) } } @MainActor @Test func unchangedFeedIsNotWrittenAgain() async throws { try await keepingSharedState { let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let cache = FeedCache(directory: directory) let file = directory.appending(path: "feed.json") let gateway = FakeGateway() await gateway.set(holdFeed: false, feed: withThread) let credentials = Credentials(gatewayURL: URL(string: "https://gateway.test")!, token: "t") let model = AppModel(mode: .paired(credentials), cache: cache, gateway: gateway) await model.refreshFeed() await model.cacheWrite?.value #expect(cache.feed()?.recent.map(\.id) == withThread.recent.map(\.id)) try FileManager.default.removeItem(at: file) await model.refreshFeed() await model.cacheWrite?.value #expect(!FileManager.default.fileExists(atPath: file.path())) await gateway.set(feed: settled) await model.refreshFeed() await model.cacheWrite?.value #expect(cache.feed()?.recent.map(\.id) == []) } } }