230 lines
10 KiB
Swift
230 lines
10 KiB
Swift
// 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<Feed, any Error>] = []
|
|
|
|
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) == [])
|
|
}
|
|
}
|
|
}
|