Watch: review fixes for action errors that vanished, feed refreshes dropped or overwritten around a change, a fresh thread screen per route, errors cleared on sign out, notification actions only while paired, cache writes off the main thread and capped at 30 threads, Ask before acting and Load earlier races, two-letter search, C# headings, and strictly encoded query values

This commit is contained in:
gib committed 2026-11-02 15:29:15 -05:00
1 parent 99b300855e
commit 25be69fa30
14 files changed
+591 -94

No files matched your search

+229
View File
@@ -0,0 +1,229 @@
// 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) == [])
}
}
}