ios: better network handling
This commit is contained in:
@@ -72,19 +72,22 @@ public struct SplitView: View {
|
|||||||
switch nextPhase {
|
switch nextPhase {
|
||||||
case .background:
|
case .background:
|
||||||
shouldRefreshOnForeground = true
|
shouldRefreshOnForeground = true
|
||||||
|
viewModel.markAppInactiveForNetwork()
|
||||||
case .active:
|
case .active:
|
||||||
|
viewModel.markAppActiveForNetwork()
|
||||||
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
|
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
shouldRefreshOnForeground = false
|
shouldRefreshOnForeground = false
|
||||||
Task {
|
Task {
|
||||||
await viewModel.refreshVisibleContent(
|
await viewModel.refreshAfterAppBecameActive(
|
||||||
refreshCollections: true,
|
refreshCollections: true,
|
||||||
refreshSelection: viewModel.hasRefreshableSelection
|
refreshSelection: viewModel.hasRefreshableSelection
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case .inactive:
|
case .inactive:
|
||||||
break
|
shouldRefreshOnForeground = true
|
||||||
|
viewModel.markAppInactiveForNetwork()
|
||||||
@unknown default:
|
@unknown default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ extension Theme {
|
|||||||
.paragraph { configuration in
|
.paragraph { configuration in
|
||||||
configuration.label
|
configuration.label
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.relativeLineSpacing(.em(0.36))
|
.relativeLineSpacing(.em(0.46))
|
||||||
.markdownMargin(top: .zero, bottom: .em(0.82))
|
.markdownMargin(top: .zero, bottom: .em(0.82))
|
||||||
}
|
}
|
||||||
.blockquote { configuration in
|
.blockquote { configuration in
|
||||||
|
|||||||
@@ -51,19 +51,22 @@ struct SybilPhoneShellView: View {
|
|||||||
switch nextPhase {
|
switch nextPhase {
|
||||||
case .background:
|
case .background:
|
||||||
shouldRefreshOnForeground = true
|
shouldRefreshOnForeground = true
|
||||||
|
viewModel.markAppInactiveForNetwork()
|
||||||
case .active:
|
case .active:
|
||||||
|
viewModel.markAppActiveForNetwork()
|
||||||
guard shouldRefreshOnForeground else {
|
guard shouldRefreshOnForeground else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
shouldRefreshOnForeground = false
|
shouldRefreshOnForeground = false
|
||||||
Task {
|
Task {
|
||||||
await viewModel.refreshVisibleContent(
|
await viewModel.refreshAfterAppBecameActive(
|
||||||
refreshCollections: path.isEmpty,
|
refreshCollections: path.isEmpty,
|
||||||
refreshSelection: !path.isEmpty && viewModel.hasRefreshableSelection
|
refreshSelection: !path.isEmpty && viewModel.hasRefreshableSelection
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case .inactive:
|
case .inactive:
|
||||||
break
|
shouldRefreshOnForeground = true
|
||||||
|
viewModel.markAppInactiveForNetwork()
|
||||||
@unknown default:
|
@unknown default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ final class SybilViewModel {
|
|||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
private var chatBackgroundTask: SybilBackgroundTaskAssertion?
|
private var chatBackgroundTask: SybilBackgroundTaskAssertion?
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
|
private var isAppActive = true
|
||||||
|
@ObservationIgnored
|
||||||
|
private var appLifecycleGeneration = 0
|
||||||
|
@ObservationIgnored
|
||||||
private let clientFactory: (APIConfiguration) -> any SybilAPIClienting
|
private let clientFactory: (APIConfiguration) -> any SybilAPIClienting
|
||||||
|
|
||||||
private let fallbackModels: [Provider: [String]] = [
|
private let fallbackModels: [Provider: [String]] = [
|
||||||
@@ -502,6 +506,38 @@ final class SybilViewModel {
|
|||||||
await reconnect()
|
await reconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func markAppInactiveForNetwork() {
|
||||||
|
guard isAppActive else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isAppActive = false
|
||||||
|
appLifecycleGeneration += 1
|
||||||
|
SybilLog.debug(SybilLog.app, "App became inactive for network lifecycle generation \(appLifecycleGeneration)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func markAppActiveForNetwork() {
|
||||||
|
isAppActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshAfterAppBecameActive(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async {
|
||||||
|
markAppActiveForNetwork()
|
||||||
|
|
||||||
|
guard isAuthenticated, !isCheckingSession else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard shouldRefreshCollections || shouldRefreshSelection else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try? await Task.sleep(for: .milliseconds(150))
|
||||||
|
await refreshVisibleContent(
|
||||||
|
refreshCollections: shouldRefreshCollections,
|
||||||
|
refreshSelection: shouldRefreshSelection
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func refreshVisibleContent(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async {
|
func refreshVisibleContent(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async {
|
||||||
guard isAuthenticated, !isCheckingSession else {
|
guard isAuthenticated, !isCheckingSession else {
|
||||||
return
|
return
|
||||||
@@ -729,6 +765,7 @@ final class SybilViewModel {
|
|||||||
SybilLog.app,
|
SybilLog.app,
|
||||||
"Refreshed collections: \(nextChats.count) chats, \(nextSearches.count) searches"
|
"Refreshed collections: \(nextChats.count) chats, \(nextSearches.count) searches"
|
||||||
)
|
)
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
if case .settings = selectedItem {
|
if case .settings = selectedItem {
|
||||||
isLoadingCollections = false
|
isLoadingCollections = false
|
||||||
@@ -749,8 +786,12 @@ final class SybilViewModel {
|
|||||||
await refreshSelectionIfNeeded()
|
await refreshSelectionIfNeeded()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = normalizeAPIError(error)
|
if shouldSuppressInactiveTransportError(error) {
|
||||||
SybilLog.error(SybilLog.app, "Refresh collections failed", error: error)
|
SybilLog.info(SybilLog.app, "Suppressing collection refresh transport interruption while app is inactive")
|
||||||
|
} else {
|
||||||
|
errorMessage = normalizeAPIError(error)
|
||||||
|
SybilLog.error(SybilLog.app, "Refresh collections failed", error: error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoadingCollections = false
|
isLoadingCollections = false
|
||||||
@@ -789,9 +830,12 @@ final class SybilViewModel {
|
|||||||
case .settings:
|
case .settings:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
errorMessage = nil
|
||||||
} catch {
|
} catch {
|
||||||
if isCancellation(error) {
|
if isCancellation(error) {
|
||||||
SybilLog.debug(SybilLog.app, "Selection refresh cancelled for \(selectedItem.id)")
|
SybilLog.debug(SybilLog.app, "Selection refresh cancelled for \(selectedItem.id)")
|
||||||
|
} else if shouldSuppressInactiveTransportError(error) {
|
||||||
|
SybilLog.info(SybilLog.app, "Suppressing selection refresh transport interruption while app is inactive")
|
||||||
} else {
|
} else {
|
||||||
errorMessage = normalizeAPIError(error)
|
errorMessage = normalizeAPIError(error)
|
||||||
SybilLog.error(SybilLog.app, "Selection refresh failed", error: error)
|
SybilLog.error(SybilLog.app, "Selection refresh failed", error: error)
|
||||||
@@ -882,6 +926,8 @@ final class SybilViewModel {
|
|||||||
} + [CompletionRequestMessage(role: .user, content: content, attachments: attachments.isEmpty ? nil : attachments)]
|
} + [CompletionRequestMessage(role: .user, content: content, attachments: attachments.isEmpty ? nil : attachments)]
|
||||||
|
|
||||||
let streamStatus = CompletionStreamStatus()
|
let streamStatus = CompletionStreamStatus()
|
||||||
|
let streamLifecycleGeneration = appLifecycleGeneration
|
||||||
|
let streamStartedWhileInactive = !isAppActive
|
||||||
|
|
||||||
if isUntitledChat(chatID: chatID, detail: selectedChat) {
|
if isUntitledChat(chatID: chatID, detail: selectedChat) {
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
@@ -917,24 +963,63 @@ final class SybilViewModel {
|
|||||||
chatBackgroundTask = nil
|
chatBackgroundTask = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
try await client.runCompletionStream(
|
do {
|
||||||
body: CompletionStreamRequest(
|
try await client.runCompletionStream(
|
||||||
chatId: chatID,
|
body: CompletionStreamRequest(
|
||||||
provider: provider,
|
chatId: chatID,
|
||||||
model: selectedModel,
|
provider: provider,
|
||||||
messages: requestMessages
|
model: selectedModel,
|
||||||
)
|
messages: requestMessages
|
||||||
) { [weak self] event in
|
)
|
||||||
guard let self else { return }
|
) { [weak self] event in
|
||||||
await self.applyCompletionEvent(event, streamStatus: streamStatus)
|
guard let self else { return }
|
||||||
|
await self.applyCompletionEvent(event, streamStatus: streamStatus)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if shouldSuppressLifecycleTransportError(
|
||||||
|
error,
|
||||||
|
startedAt: streamLifecycleGeneration,
|
||||||
|
startedWhileInactive: streamStartedWhileInactive
|
||||||
|
) {
|
||||||
|
SybilLog.info(SybilLog.app, "Suppressing chat stream transport interruption after app lifecycle change")
|
||||||
|
pendingChatState = nil
|
||||||
|
if isAppActive {
|
||||||
|
await refreshInterruptedStream(preferredSelection: .chat(chatID))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
if let streamError = await streamStatus.error() {
|
if let streamError = await streamStatus.error() {
|
||||||
throw APIError.httpError(statusCode: 502, message: streamError)
|
throw APIError.httpError(statusCode: 502, message: streamError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard isAppActive else {
|
||||||
|
pendingChatState = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
await refreshCollections(preferredSelection: .chat(chatID))
|
await refreshCollections(preferredSelection: .chat(chatID))
|
||||||
selectedChat = try await client.getChat(chatID: chatID)
|
do {
|
||||||
|
selectedChat = try await client.getChat(chatID: chatID)
|
||||||
|
} catch {
|
||||||
|
if shouldSuppressLifecycleTransportError(
|
||||||
|
error,
|
||||||
|
startedAt: streamLifecycleGeneration,
|
||||||
|
startedWhileInactive: streamStartedWhileInactive
|
||||||
|
) {
|
||||||
|
SybilLog.info(SybilLog.app, "Suppressing chat refresh transport interruption after app lifecycle change")
|
||||||
|
pendingChatState = nil
|
||||||
|
if isAppActive {
|
||||||
|
await refreshInterruptedStream(preferredSelection: .chat(chatID))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
pendingChatState = nil
|
pendingChatState = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1003,19 +1088,41 @@ final class SybilViewModel {
|
|||||||
)
|
)
|
||||||
|
|
||||||
let streamStatus = SearchStreamStatus()
|
let streamStatus = SearchStreamStatus()
|
||||||
|
let streamLifecycleGeneration = appLifecycleGeneration
|
||||||
|
let streamStartedWhileInactive = !isAppActive
|
||||||
|
|
||||||
try await client.runSearchStream(
|
do {
|
||||||
searchID: searchID,
|
try await client.runSearchStream(
|
||||||
body: SearchRunRequest(query: query, title: String(query.prefix(80)), type: "auto", numResults: 10)
|
searchID: searchID,
|
||||||
) { [weak self] event in
|
body: SearchRunRequest(query: query, title: String(query.prefix(80)), type: "auto", numResults: 10)
|
||||||
guard let self else { return }
|
) { [weak self] event in
|
||||||
await self.applySearchEvent(event, searchID: searchID, streamStatus: streamStatus)
|
guard let self else { return }
|
||||||
|
await self.applySearchEvent(event, searchID: searchID, streamStatus: streamStatus)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if shouldSuppressLifecycleTransportError(
|
||||||
|
error,
|
||||||
|
startedAt: streamLifecycleGeneration,
|
||||||
|
startedWhileInactive: streamStartedWhileInactive
|
||||||
|
) {
|
||||||
|
SybilLog.info(SybilLog.app, "Suppressing search stream transport interruption after app lifecycle change")
|
||||||
|
if isAppActive {
|
||||||
|
await refreshInterruptedStream(preferredSelection: .search(searchID))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
if let streamError = await streamStatus.error() {
|
if let streamError = await streamStatus.error() {
|
||||||
throw APIError.httpError(statusCode: 502, message: streamError)
|
throw APIError.httpError(statusCode: 502, message: streamError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard isAppActive else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
await refreshCollections(preferredSelection: .search(searchID))
|
await refreshCollections(preferredSelection: .search(searchID))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1250,6 +1357,57 @@ final class SybilViewModel {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func refreshInterruptedStream(preferredSelection: SidebarSelection) async {
|
||||||
|
try? await Task.sleep(for: .milliseconds(150))
|
||||||
|
await refreshCollections(preferredSelection: preferredSelection)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shouldSuppressLifecycleTransportError(
|
||||||
|
_ error: Error,
|
||||||
|
startedAt generation: Int,
|
||||||
|
startedWhileInactive: Bool
|
||||||
|
) -> Bool {
|
||||||
|
guard generation != appLifecycleGeneration || startedWhileInactive || !isAppActive else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return isTransientTransportInterruption(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shouldSuppressInactiveTransportError(_ error: Error) -> Bool {
|
||||||
|
!isAppActive && isTransientTransportInterruption(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isTransientTransportInterruption(_ error: Error) -> Bool {
|
||||||
|
if isCancellation(error) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if let apiError = error as? APIError,
|
||||||
|
case let .networkError(message) = apiError {
|
||||||
|
let lowercased = message.lowercased()
|
||||||
|
return lowercased.contains("network error -999")
|
||||||
|
|| lowercased.contains("network error -1005")
|
||||||
|
|| lowercased.contains("network connection was lost")
|
||||||
|
|| lowercased.contains("software caused connection abort")
|
||||||
|
|| lowercased.contains("socket is not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
let nsError = error as NSError
|
||||||
|
if nsError.domain == NSURLErrorDomain {
|
||||||
|
return nsError.code == URLError.cancelled.rawValue
|
||||||
|
|| nsError.code == URLError.networkConnectionLost.rawValue
|
||||||
|
|| nsError.code == URLError.notConnectedToInternet.rawValue
|
||||||
|
|| nsError.code == URLError.timedOut.rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
if nsError.domain == NSPOSIXErrorDomain {
|
||||||
|
return nsError.code == 53 || nsError.code == 57
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
private func isCancellation(_ error: Error) -> Bool {
|
private func isCancellation(_ error: Error) -> Bool {
|
||||||
if error is CancellationError {
|
if error is CancellationError {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
private let searchDetails: [String: SearchDetail]
|
private let searchDetails: [String: SearchDetail]
|
||||||
|
|
||||||
private var snapshot = MockClientCallSnapshot()
|
private var snapshot = MockClientCallSnapshot()
|
||||||
|
private var completionStreamNetworkErrorMessage: String?
|
||||||
|
private var completionStreamDelayNanoseconds: UInt64 = 0
|
||||||
|
private var searchStreamNetworkErrorMessage: String?
|
||||||
|
private var searchStreamDelayNanoseconds: UInt64 = 0
|
||||||
|
|
||||||
init(
|
init(
|
||||||
chatsResponse: [ChatSummary] = [],
|
chatsResponse: [ChatSummary] = [],
|
||||||
@@ -36,6 +40,16 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
snapshot
|
snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setCompletionStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
|
||||||
|
completionStreamNetworkErrorMessage = message
|
||||||
|
completionStreamDelayNanoseconds = delayNanoseconds
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSearchStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
|
||||||
|
searchStreamNetworkErrorMessage = message
|
||||||
|
searchStreamDelayNanoseconds = delayNanoseconds
|
||||||
|
}
|
||||||
|
|
||||||
func verifySession() async throws -> AuthSession {
|
func verifySession() async throws -> AuthSession {
|
||||||
AuthSession(authenticated: true, mode: "open")
|
AuthSession(authenticated: true, mode: "open")
|
||||||
}
|
}
|
||||||
@@ -98,6 +112,12 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
body: CompletionStreamRequest,
|
body: CompletionStreamRequest,
|
||||||
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
|
||||||
) async throws {
|
) async throws {
|
||||||
|
if completionStreamDelayNanoseconds > 0 {
|
||||||
|
try await Task.sleep(nanoseconds: completionStreamDelayNanoseconds)
|
||||||
|
}
|
||||||
|
if let completionStreamNetworkErrorMessage {
|
||||||
|
throw APIError.networkError(message: completionStreamNetworkErrorMessage)
|
||||||
|
}
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +126,12 @@ private actor MockSybilClient: SybilAPIClienting {
|
|||||||
body: SearchRunRequest,
|
body: SearchRunRequest,
|
||||||
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
|
||||||
) async throws {
|
) async throws {
|
||||||
|
if searchStreamDelayNanoseconds > 0 {
|
||||||
|
try await Task.sleep(nanoseconds: searchStreamDelayNanoseconds)
|
||||||
|
}
|
||||||
|
if let searchStreamNetworkErrorMessage {
|
||||||
|
throw APIError.networkError(message: searchStreamNetworkErrorMessage)
|
||||||
|
}
|
||||||
throw UnexpectedClientCall()
|
throw UnexpectedClientCall()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,6 +293,84 @@ private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchD
|
|||||||
#expect(viewModel.selectedSearch?.answerText == "fresh answer")
|
#expect(viewModel.selectedSearch?.answerText == "fresh answer")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func backgroundChatStreamInterruptionIsSuppressedUntilForegroundRefresh() async throws {
|
||||||
|
let date = Date(timeIntervalSince1970: 1_700_000_300)
|
||||||
|
let chat = makeChatSummary(id: "chat-3", date: date)
|
||||||
|
let initialDetail = makeChatDetail(id: "chat-3", date: date, body: "stale transcript")
|
||||||
|
let refreshedDetail = makeChatDetail(id: "chat-3", date: date, body: "fresh transcript")
|
||||||
|
let client = MockSybilClient(
|
||||||
|
chatsResponse: [chat],
|
||||||
|
chatDetails: ["chat-3": refreshedDetail]
|
||||||
|
)
|
||||||
|
await client.setCompletionStreamNetworkError(
|
||||||
|
"Network error -1005 while requesting POST: The network connection was lost.",
|
||||||
|
delayNanoseconds: 50_000_000
|
||||||
|
)
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
viewModel.isAuthenticated = true
|
||||||
|
viewModel.isCheckingSession = false
|
||||||
|
viewModel.selectedItem = .chat("chat-3")
|
||||||
|
viewModel.selectedChat = initialDetail
|
||||||
|
viewModel.composer = "continue"
|
||||||
|
|
||||||
|
let sendTask = Task {
|
||||||
|
await viewModel.sendComposer()
|
||||||
|
}
|
||||||
|
try await Task.sleep(nanoseconds: 10_000_000)
|
||||||
|
viewModel.markAppInactiveForNetwork()
|
||||||
|
await sendTask.value
|
||||||
|
|
||||||
|
#expect(viewModel.errorMessage == nil)
|
||||||
|
#expect(viewModel.composer.isEmpty)
|
||||||
|
#expect(!viewModel.isSending)
|
||||||
|
#expect(viewModel.selectedChat?.messages.first?.content == "stale transcript")
|
||||||
|
|
||||||
|
await viewModel.refreshAfterAppBecameActive(refreshCollections: false, refreshSelection: true)
|
||||||
|
|
||||||
|
let snapshot = await client.currentSnapshot()
|
||||||
|
#expect(snapshot.getChat == 1)
|
||||||
|
#expect(viewModel.errorMessage == nil)
|
||||||
|
#expect(viewModel.selectedChat?.messages.first?.content == "fresh transcript")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func backgroundSearchStreamInterruptionIsSuppressedUntilForegroundRefresh() async throws {
|
||||||
|
let date = Date(timeIntervalSince1970: 1_700_000_400)
|
||||||
|
let refreshedDetail = makeSearchDetail(id: "search-3", date: date, answer: "fresh answer")
|
||||||
|
let client = MockSybilClient(
|
||||||
|
searchDetails: ["search-3": refreshedDetail]
|
||||||
|
)
|
||||||
|
await client.setSearchStreamNetworkError(
|
||||||
|
"Network error -1005 while requesting POST: The network connection was lost.",
|
||||||
|
delayNanoseconds: 50_000_000
|
||||||
|
)
|
||||||
|
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
|
||||||
|
viewModel.isAuthenticated = true
|
||||||
|
viewModel.isCheckingSession = false
|
||||||
|
viewModel.selectedItem = .search("search-3")
|
||||||
|
viewModel.selectedSearch = makeSearchDetail(id: "search-3", date: date, answer: "stale answer")
|
||||||
|
viewModel.composer = "refresh me"
|
||||||
|
|
||||||
|
let sendTask = Task {
|
||||||
|
await viewModel.sendComposer()
|
||||||
|
}
|
||||||
|
try await Task.sleep(nanoseconds: 10_000_000)
|
||||||
|
viewModel.markAppInactiveForNetwork()
|
||||||
|
await sendTask.value
|
||||||
|
|
||||||
|
#expect(viewModel.errorMessage == nil)
|
||||||
|
#expect(viewModel.composer.isEmpty)
|
||||||
|
#expect(!viewModel.isSending)
|
||||||
|
|
||||||
|
await viewModel.refreshAfterAppBecameActive(refreshCollections: false, refreshSelection: true)
|
||||||
|
|
||||||
|
let snapshot = await client.currentSnapshot()
|
||||||
|
#expect(snapshot.getSearch == 1)
|
||||||
|
#expect(viewModel.errorMessage == nil)
|
||||||
|
#expect(viewModel.selectedSearch?.answerText == "fresh answer")
|
||||||
|
}
|
||||||
|
|
||||||
@Test func newChatSwipeMetricsClampProgressAndLatch() async throws {
|
@Test func newChatSwipeMetricsClampProgressAndLatch() async throws {
|
||||||
let width: CGFloat = 390
|
let width: CGFloat = 390
|
||||||
let maxTravel = NewChatSwipeMetrics.maxTravel(for: width)
|
let maxTravel = NewChatSwipeMetrics.maxTravel(for: width)
|
||||||
|
|||||||
Reference in New Issue
Block a user