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:
1 parent
99b300855e
commit
25be69fa30
14 files changed
+591
-94
No files matched your search
@@ -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) == [])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -474,8 +474,50 @@ private let feedItemJSON = """
|
||||
#expect(fresh.messages(before: [m("5")]).map(\.id) == ["5"])
|
||||
}
|
||||
|
||||
@Test func olderMessagesTakeTheNewerCopy() {
|
||||
func m(_ id: String, _ text: String) -> Message { Message(id: id, role: .assistant, text: text, createdAt: .now) }
|
||||
var history = MessageHistory()
|
||||
// Load earlier goes out with 3 caught mid-stream. A refresh lands before the page does.
|
||||
history.startKeeping([m("3", "Runn"), m("4", "Done")])
|
||||
history.keep([m("3", "Running the suite"), m("4", "Done"), m("5", "Next")])
|
||||
#expect(history.messages(before: [m("6", "Later")]).map(\.text) == ["Running the suite", "Done", "Next", "Later"])
|
||||
|
||||
// The page repeats 3 and its copy, fetched last, wins in place.
|
||||
history.prepend(HistoryResponse(messages: [m("2", "Two"), m("3", "Running the suite, all 48")], earlierCursor: nil))
|
||||
#expect(history.messages(before: [m("6", "Later")]).map(\.text)
|
||||
== ["Two", "Running the suite, all 48", "Done", "Next", "Later"])
|
||||
}
|
||||
|
||||
@Test func queryValuesAreEncodedStrictly() {
|
||||
let url = GatewayClient.url(
|
||||
URL(string: "https://gateway.test")!, ["v1", "threads", "h/1", "t"],
|
||||
query: [URLQueryItem(name: "cursor", value: "habc+/d=&e f")])
|
||||
#expect(url.absoluteString == "https://gateway.test/v1/threads/h%2F1/t?cursor=habc%2B%2Fd%3D%26e%20f")
|
||||
#expect(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.first?.value == "habc+/d=&e f")
|
||||
}
|
||||
|
||||
@Test func threadCacheKeepsTheNewest() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
var cache = FeedCache(directory: directory)
|
||||
cache.threadLimit = 3
|
||||
var detail = try decode(ThreadDetail.self, #"{ "item": \#(feedItemJSON), "messages": [], "canStop": false }"#)
|
||||
for id in ["a", "b", "c", "d", "e"] {
|
||||
detail.item.threadId = id
|
||||
cache.save(detail)
|
||||
try await Task.sleep(for: .milliseconds(20))
|
||||
}
|
||||
cache.save(Feed(needsYou: [], working: [], recent: [], hosts: []))
|
||||
let kept = ["a", "b", "c", "d", "e"].filter { cache.thread(hostId: detail.item.hostId, threadId: $0) != nil }
|
||||
#expect(kept == ["c", "d", "e"])
|
||||
#expect(cache.feed() != nil)
|
||||
}
|
||||
|
||||
@Test func markdownReadsCleanly() {
|
||||
#expect(Markdown.block("## Done") == "**Done**")
|
||||
#expect(Markdown.block("## Porting to C#") == "**Porting to C#**")
|
||||
#expect(Markdown.block("## Done ##") == "**Done**")
|
||||
#expect(Markdown.block("# Notes # ") == "**Notes**")
|
||||
#expect(Markdown.block(" - item") == " • item")
|
||||
#expect(Markdown.block("> quoted") == "quoted")
|
||||
#expect(Markdown.block("**bold** stays") == "**bold** stays")
|
||||
@@ -599,6 +641,9 @@ private let feedItemJSON = """
|
||||
#expect(Route(url: route.url) == route)
|
||||
}
|
||||
#expect(Route(url: URL(string: "wristcode://thread/only-host")!) == nil)
|
||||
// The gateway searches two characters or more.
|
||||
#expect(Route(url: URL(string: "wristcode://search?q=a")!) == nil)
|
||||
#expect(Route(url: URL(string: "wristcode://search?q=ab")!) == .search(query: "ab"))
|
||||
#expect(Route(url: URL(string: "https://thread/h/t")!) == nil)
|
||||
}
|
||||
|
||||
@@ -1008,6 +1053,8 @@ private let feedItemJSON = """
|
||||
#expect(model.feedIsCached)
|
||||
|
||||
model.signOut()
|
||||
// The clear runs off the main actor, after any write still queued.
|
||||
await model.cacheWrite?.value
|
||||
#expect(cache.feed() == nil)
|
||||
#expect(model.feed == nil)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user