import CoreGraphics
import Foundation
import Testing
@testable import Sybil

private struct MockClientCallSnapshot: Sendable {
    var listChats = 0
    var listSearches = 0
    var getChat = 0
    var getSearch = 0
    var getActiveRuns = 0
    var attachCompletionStream = 0
    var attachSearchStream = 0
}

private struct UnexpectedClientCall: Error {}

private actor MockSybilClient: SybilAPIClienting {
    private let chatsResponse: [ChatSummary]
    private let searchesResponse: [SearchSummary]
    private let chatDetails: [String: ChatDetail]
    private let searchDetails: [String: SearchDetail]
    private let createChatResponse: ChatSummary?
    private let activeRunsResponse: ActiveRunsResponse

    private var snapshot = MockClientCallSnapshot()
    private var getChatDelayNanoseconds: UInt64 = 0
    private var getSearchDelayNanoseconds: UInt64 = 0
    private var completionStreamNetworkErrorMessage: String?
    private var completionStreamDelayNanoseconds: UInt64 = 0
    private var completionAttachEvents: [String: [CompletionStreamEvent]] = [:]
    private var completionAttachDelayNanoseconds: UInt64 = 0
    private var searchStreamNetworkErrorMessage: String?
    private var searchStreamDelayNanoseconds: UInt64 = 0
    private var searchAttachEvents: [String: [SearchStreamEvent]] = [:]
    private var searchAttachDelayNanoseconds: UInt64 = 0

    init(
        chatsResponse: [ChatSummary] = [],
        searchesResponse: [SearchSummary] = [],
        chatDetails: [String: ChatDetail] = [:],
        searchDetails: [String: SearchDetail] = [:],
        createChatResponse: ChatSummary? = nil,
        activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse()
    ) {
        self.chatsResponse = chatsResponse
        self.searchesResponse = searchesResponse
        self.chatDetails = chatDetails
        self.searchDetails = searchDetails
        self.createChatResponse = createChatResponse
        self.activeRunsResponse = activeRunsResponse
    }

    func currentSnapshot() -> MockClientCallSnapshot {
        snapshot
    }

    func setCompletionStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
        completionStreamNetworkErrorMessage = message
        completionStreamDelayNanoseconds = delayNanoseconds
    }

    func setGetChatDelay(_ delayNanoseconds: UInt64) {
        getChatDelayNanoseconds = delayNanoseconds
    }

    func setGetSearchDelay(_ delayNanoseconds: UInt64) {
        getSearchDelayNanoseconds = delayNanoseconds
    }

    func setSearchStreamNetworkError(_ message: String, delayNanoseconds: UInt64 = 0) {
        searchStreamNetworkErrorMessage = message
        searchStreamDelayNanoseconds = delayNanoseconds
    }

    func setCompletionAttachEvents(
        chatID: String,
        events: [CompletionStreamEvent],
        delayNanoseconds: UInt64 = 0
    ) {
        completionAttachEvents[chatID] = events
        completionAttachDelayNanoseconds = delayNanoseconds
    }

    func setSearchAttachEvents(
        searchID: String,
        events: [SearchStreamEvent],
        delayNanoseconds: UInt64 = 0
    ) {
        searchAttachEvents[searchID] = events
        searchAttachDelayNanoseconds = delayNanoseconds
    }

    func verifySession() async throws -> AuthSession {
        AuthSession(authenticated: true, mode: "open")
    }

    func listChats() async throws -> [ChatSummary] {
        snapshot.listChats += 1
        return chatsResponse
    }

    func createChat(title: String?) async throws -> ChatSummary {
        if let createChatResponse {
            return createChatResponse
        }
        throw UnexpectedClientCall()
    }

    func getChat(chatID: String) async throws -> ChatDetail {
        snapshot.getChat += 1
        if getChatDelayNanoseconds > 0 {
            try await Task.sleep(nanoseconds: getChatDelayNanoseconds)
        }
        guard let detail = chatDetails[chatID] else {
            throw UnexpectedClientCall()
        }
        return detail
    }

    func deleteChat(chatID: String) async throws {
        throw UnexpectedClientCall()
    }

    func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary {
        throw UnexpectedClientCall()
    }

    func listSearches() async throws -> [SearchSummary] {
        snapshot.listSearches += 1
        return searchesResponse
    }

    func createSearch(title: String?, query: String?) async throws -> SearchSummary {
        throw UnexpectedClientCall()
    }

    func getSearch(searchID: String) async throws -> SearchDetail {
        snapshot.getSearch += 1
        if getSearchDelayNanoseconds > 0 {
            try await Task.sleep(nanoseconds: getSearchDelayNanoseconds)
        }
        guard let detail = searchDetails[searchID] else {
            throw UnexpectedClientCall()
        }
        return detail
    }

    func createChatFromSearch(searchID: String, title: String?) async throws -> ChatSummary {
        throw UnexpectedClientCall()
    }

    func deleteSearch(searchID: String) async throws {
        throw UnexpectedClientCall()
    }

    func listModels() async throws -> ModelCatalogResponse {
        ModelCatalogResponse(providers: [:])
    }

    func getActiveRuns() async throws -> ActiveRunsResponse {
        snapshot.getActiveRuns += 1
        return activeRunsResponse
    }

    func runCompletionStream(
        body: CompletionStreamRequest,
        onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
    ) async throws {
        if completionStreamDelayNanoseconds > 0 {
            try await Task.sleep(nanoseconds: completionStreamDelayNanoseconds)
        }
        if let completionStreamNetworkErrorMessage {
            throw APIError.networkError(message: completionStreamNetworkErrorMessage)
        }
        throw UnexpectedClientCall()
    }

    func attachCompletionStream(
        chatID: String,
        onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
    ) async throws {
        snapshot.attachCompletionStream += 1
        let events = completionAttachEvents[chatID] ?? []
        for event in events {
            await onEvent(event)
        }
        if completionAttachDelayNanoseconds > 0 {
            try await Task.sleep(nanoseconds: completionAttachDelayNanoseconds)
        }
    }

    func runSearchStream(
        searchID: String,
        body: SearchRunRequest,
        onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
    ) async throws {
        if searchStreamDelayNanoseconds > 0 {
            try await Task.sleep(nanoseconds: searchStreamDelayNanoseconds)
        }
        if let searchStreamNetworkErrorMessage {
            throw APIError.networkError(message: searchStreamNetworkErrorMessage)
        }
        throw UnexpectedClientCall()
    }

    func attachSearchStream(
        searchID: String,
        onEvent: @escaping @Sendable (SearchStreamEvent) async -> Void
    ) async throws {
        snapshot.attachSearchStream += 1
        let events = searchAttachEvents[searchID] ?? []
        for event in events {
            await onEvent(event)
        }
        if searchAttachDelayNanoseconds > 0 {
            try await Task.sleep(nanoseconds: searchAttachDelayNanoseconds)
        }
    }
}

@MainActor
private func testSettings(named name: String) -> SybilSettingsStore {
    let defaults = UserDefaults(suiteName: name)!
    defaults.removePersistentDomain(forName: name)
    let settings = SybilSettingsStore(defaults: defaults)
    settings.apiBaseURL = "http://127.0.0.1:8787"
    return settings
}

private func makeChatSummary(id: String, date: Date) -> ChatSummary {
    ChatSummary(
        id: id,
        title: "Chat \(id)",
        createdAt: date,
        updatedAt: date,
        initiatedProvider: .openai,
        initiatedModel: "gpt-4.1-mini",
        lastUsedProvider: .openai,
        lastUsedModel: "gpt-4.1-mini"
    )
}

private func makeChatDetail(id: String, date: Date, body: String) -> ChatDetail {
    ChatDetail(
        id: id,
        title: "Chat \(id)",
        createdAt: date,
        updatedAt: date,
        initiatedProvider: .openai,
        initiatedModel: "gpt-4.1-mini",
        lastUsedProvider: .openai,
        lastUsedModel: "gpt-4.1-mini",
        messages: [
            Message(
                id: "message-\(id)",
                createdAt: date,
                role: .assistant,
                content: body,
                name: nil
            )
        ]
    )
}

private func makeSearchSummary(id: String, date: Date) -> SearchSummary {
    SearchSummary(
        id: id,
        title: "Search \(id)",
        query: "query-\(id)",
        createdAt: date,
        updatedAt: date
    )
}

private func makeSearchDetail(id: String, date: Date, answer: String) -> SearchDetail {
    SearchDetail(
        id: id,
        title: "Search \(id)",
        query: "query-\(id)",
        createdAt: date,
        updatedAt: date,
        requestId: "request-\(id)",
        latencyMs: 42,
        error: nil,
        answerText: answer,
        answerRequestId: "answer-\(id)",
        answerCitations: [],
        answerError: nil,
        results: []
    )
}

@MainActor
@Test func normalizedAPIBaseURLPreservesExplicitAPIPath() async throws {
    let defaults = UserDefaults(suiteName: #function)!
    defaults.removePersistentDomain(forName: #function)

    let settings = SybilSettingsStore(defaults: defaults)
    settings.apiBaseURL = "https://sybil.bajor.cloud/api/"

    #expect(settings.normalizedAPIBaseURL?.absoluteString == "https://sybil.bajor.cloud/api")
}

@MainActor
@Test func normalizedAPIBaseURLTrimsWhitespaceAndTrailingSlashes() async throws {
    let defaults = UserDefaults(suiteName: #function)!
    defaults.removePersistentDomain(forName: #function)

    let settings = SybilSettingsStore(defaults: defaults)
    settings.apiBaseURL = "  http://127.0.0.1:8787///  "

    #expect(settings.normalizedAPIBaseURL?.absoluteString == "http://127.0.0.1:8787")
}

@MainActor
@Test func foregroundListRefreshDoesNotReloadHiddenSelection() async throws {
    let date = Date(timeIntervalSince1970: 1_700_000_000)
    let chat = makeChatSummary(id: "chat-1", date: date)
    let search = makeSearchSummary(id: "search-1", date: date)
    let client = MockSybilClient(
        chatsResponse: [chat],
        searchesResponse: [search],
        chatDetails: ["chat-1": makeChatDetail(id: "chat-1", date: date, body: "fresh chat body")]
    )
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
    viewModel.isAuthenticated = true
    viewModel.isCheckingSession = false
    viewModel.selectedItem = .chat("chat-1")

    await viewModel.refreshVisibleContent(refreshCollections: true, refreshSelection: false)

    let snapshot = await client.currentSnapshot()
    #expect(snapshot.listChats == 1)
    #expect(snapshot.listSearches == 1)
    #expect(snapshot.getChat == 0)
    #expect(snapshot.getSearch == 0)
    #expect(viewModel.selectedItem == .chat("chat-1"))
}

@MainActor
@Test func foregroundChatRefreshReloadsSelectedTranscript() async throws {
    let date = Date(timeIntervalSince1970: 1_700_000_100)
    let detail = makeChatDetail(id: "chat-2", date: date, body: "refreshed transcript")
    let client = MockSybilClient(chatDetails: ["chat-2": detail])
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
    viewModel.isAuthenticated = true
    viewModel.isCheckingSession = false
    viewModel.selectedItem = .chat("chat-2")

    await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)

    let snapshot = await client.currentSnapshot()
    #expect(snapshot.listChats == 0)
    #expect(snapshot.listSearches == 0)
    #expect(snapshot.getChat == 1)
    #expect(viewModel.selectedChat?.messages.first?.content == "refreshed transcript")
}

@MainActor
@Test func foregroundSearchRefreshReloadsSelectedSearch() async throws {
    let date = Date(timeIntervalSince1970: 1_700_000_200)
    let detail = makeSearchDetail(id: "search-2", date: date, answer: "fresh answer")
    let client = MockSybilClient(searchDetails: ["search-2": detail])
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
    viewModel.isAuthenticated = true
    viewModel.isCheckingSession = false
    viewModel.selectedItem = .search("search-2")

    await viewModel.refreshVisibleContent(refreshCollections: false, refreshSelection: true)

    let snapshot = await client.currentSnapshot()
    #expect(snapshot.listChats == 0)
    #expect(snapshot.listSearches == 0)
    #expect(snapshot.getSearch == 1)
    #expect(viewModel.selectedSearch?.answerText == "fresh answer")
}

@MainActor
@Test func selectingChatClearsStaleTranscriptUntilNewDetailLoads() async throws {
    let date = Date(timeIntervalSince1970: 1_700_000_210)
    let staleDetail = makeChatDetail(id: "chat-old", date: date, body: "stale transcript")
    let freshDetail = makeChatDetail(id: "chat-new", date: date, body: "fresh transcript")
    let client = MockSybilClient(chatDetails: ["chat-new": freshDetail])
    await client.setGetChatDelay(50_000_000)
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
    viewModel.isAuthenticated = true
    viewModel.isCheckingSession = false
    viewModel.selectedItem = .chat("chat-old")
    viewModel.selectedChat = staleDetail

    viewModel.select(.chat("chat-new"))

    #expect(viewModel.displayedMessages.isEmpty)
    #expect(viewModel.isLoadingSelection)

    try await Task.sleep(nanoseconds: 90_000_000)

    #expect(viewModel.displayedMessages.first?.content == "fresh transcript")
    #expect(!viewModel.isLoadingSelection)
}

@MainActor
@Test func navigationSelectionWaitsForFastTranscriptLoad() async throws {
    let date = Date(timeIntervalSince1970: 1_700_000_220)
    let detail = makeChatDetail(id: "chat-fast", date: date, body: "loaded before push")
    let client = MockSybilClient(chatDetails: ["chat-fast": detail])
    await client.setGetChatDelay(20_000_000)
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
    viewModel.isAuthenticated = true
    viewModel.isCheckingSession = false

    await viewModel.selectForNavigation(.chat("chat-fast"), preloadTimeout: .milliseconds(500))

    #expect(viewModel.selectedItem == .chat("chat-fast"))
    #expect(viewModel.displayedMessages.first?.content == "loaded before push")
    #expect(!viewModel.isLoadingSelection)
}

@MainActor
@Test func navigationSelectionTimesOutAndKeepsLoadingTranscript() async throws {
    let date = Date(timeIntervalSince1970: 1_700_000_230)
    let detail = makeChatDetail(id: "chat-slow", date: date, body: "loaded after push")
    let client = MockSybilClient(chatDetails: ["chat-slow": detail])
    await client.setGetChatDelay(100_000_000)
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
    viewModel.isAuthenticated = true
    viewModel.isCheckingSession = false

    await viewModel.selectForNavigation(.chat("chat-slow"), preloadTimeout: .milliseconds(10))

    #expect(viewModel.selectedItem == .chat("chat-slow"))
    #expect(viewModel.displayedMessages.isEmpty)
    #expect(viewModel.isLoadingSelection)

    try await Task.sleep(nanoseconds: 150_000_000)

    #expect(viewModel.displayedMessages.first?.content == "loaded after push")
    #expect(!viewModel.isLoadingSelection)
}

@MainActor
@Test func newDraftChatDoesNotShowTypingStateFromPreviousSend() async throws {
    let date = Date(timeIntervalSince1970: 1_700_000_240)
    let detail = makeChatDetail(id: "chat-typing", date: date, body: "existing transcript")
    let client = MockSybilClient(chatDetails: ["chat-typing": detail])
    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-typing")
    viewModel.selectedChat = detail
    viewModel.composer = "continue"

    let sendTask = Task {
        await viewModel.sendComposer()
    }
    try await Task.sleep(nanoseconds: 10_000_000)

    #expect(viewModel.isSendingVisibleChat)

    viewModel.startNewChat()

    #expect(viewModel.displayedMessages.isEmpty)
    #expect(!viewModel.isSendingVisibleChat)

    await sendTask.value
}

@MainActor
@Test func reconnectAttachesSelectedActiveChatStream() async throws {
    let date = Date(timeIntervalSince1970: 1_700_000_260)
    let chat = makeChatSummary(id: "chat-active", date: date)
    let detail = makeChatDetail(id: "chat-active", date: date, body: "existing transcript")
    let client = MockSybilClient(
        chatsResponse: [chat],
        chatDetails: ["chat-active": detail],
        activeRunsResponse: ActiveRunsResponse(chats: ["chat-active"])
    )
    await client.setCompletionAttachEvents(
        chatID: "chat-active",
        events: [.delta(CompletionStreamDelta(text: "streaming"))],
        delayNanoseconds: 100_000_000
    )
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }

    await viewModel.reconnect()
    try await Task.sleep(nanoseconds: 20_000_000)

    let snapshot = await client.currentSnapshot()
    #expect(snapshot.getActiveRuns >= 1)
    #expect(snapshot.attachCompletionStream == 1)
    #expect(viewModel.sidebarItems.first?.isRunning == true)
    #expect(viewModel.isSendingVisibleChat)
    #expect(viewModel.displayedMessages.last?.content == "streaming")
}

@MainActor
@Test func activeRunOnDifferentChatDoesNotDisableComposer() async throws {
    let date = Date(timeIntervalSince1970: 1_700_000_270)
    let activeChat = makeChatSummary(id: "chat-active", date: date)
    let idleChat = makeChatSummary(id: "chat-idle", date: date.addingTimeInterval(1))
    let client = MockSybilClient(
        chatsResponse: [idleChat, activeChat],
        chatDetails: [
            "chat-active": makeChatDetail(id: "chat-active", date: date, body: "active transcript"),
            "chat-idle": makeChatDetail(id: "chat-idle", date: date, body: "idle transcript")
        ],
        activeRunsResponse: ActiveRunsResponse(chats: ["chat-active"])
    )
    let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
    viewModel.selectedItem = .chat("chat-idle")
    viewModel.composer = "new message"

    await viewModel.reconnect()

    #expect(viewModel.selectedItem == .chat("chat-idle"))
    #expect(viewModel.sidebarItems.first(where: { $0.selection == .chat("chat-active") })?.isRunning == true)
    #expect(!viewModel.isActiveSelectionSending)
    #expect(viewModel.canSendComposer)
}

@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 {
    let width: CGFloat = 390
    let maxTravel = NewChatSwipeMetrics.maxTravel(for: width)
    let latchDistance = NewChatSwipeMetrics.latchDistance(for: width)

    #expect(NewChatSwipeMetrics.clampedOffset(for: -500, width: width) == -maxTravel)
    #expect(NewChatSwipeMetrics.progress(for: -maxTravel / 2, width: width) == 0.5)
    #expect(NewChatSwipeMetrics.blurRadius(for: -maxTravel, width: width) == 10)
    #expect(NewChatSwipeMetrics.isLatched(offset: -(latchDistance + 1), width: width))
    #expect(!NewChatSwipeMetrics.isLatched(offset: -(latchDistance - 1), width: width))
    #expect(NewChatSwipeMetrics.isLatched(offset: -(latchDistance - 1), width: width, isCurrentlyLatched: true))
    #expect(!NewChatSwipeMetrics.isLatched(offset: -(NewChatSwipeMetrics.latchReleaseDistance(for: width) - 1), width: width, isCurrentlyLatched: true))
    #expect(NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 24, verticalTravel: 8, leftwardVelocity: 0, verticalVelocity: 0))
    #expect(NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 2, verticalTravel: 1, leftwardVelocity: 120, verticalVelocity: 30))
    #expect(!NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 8, verticalTravel: 24, leftwardVelocity: 20, verticalVelocity: 140))
    #expect(!NewChatSwipeMetrics.shouldBeginPan(leftwardTravel: 18, verticalTravel: 18, leftwardVelocity: 80, verticalVelocity: 90))
    #expect(!NewChatSwipeMetrics.shouldComplete(offset: -24, velocityX: 0, width: width, isLatched: false))
    #expect(NewChatSwipeMetrics.shouldComplete(offset: -24, velocityX: -800, width: width, isLatched: false))
    #expect(!NewChatSwipeMetrics.shouldComplete(offset: -(latchDistance + 1), velocityX: 800, width: width, isLatched: true))
    #expect(BackSwipeMetrics.clampedOffset(for: 500, width: width) == maxTravel)
    #expect(BackSwipeMetrics.progress(for: maxTravel / 2, width: width) == 0.5)
    #expect(BackSwipeMetrics.isLatched(offset: latchDistance + 1, width: width))
    #expect(BackSwipeMetrics.shouldBeginPan(rightwardTravel: 24, verticalTravel: 8, rightwardVelocity: 0, verticalVelocity: 0))
    #expect(!BackSwipeMetrics.shouldBeginPan(rightwardTravel: 8, verticalTravel: 24, rightwardVelocity: 20, verticalVelocity: 140))
    #expect(BackSwipeMetrics.shouldComplete(offset: 24, velocityX: 800, width: width, isLatched: false))
    #expect(!BackSwipeMetrics.shouldComplete(offset: latchDistance + 1, velocityX: -800, width: width, isLatched: true))
}
