Files
wristcode/watch/WristCodeTests/AppModelTests.swift
T

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) == [])
}
}
}