Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/test/CallsTab/CallsListViewController+ViewModelLoaderTest.swift
1 views
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import SignalServiceKit
import XCTest

@testable import Signal

final class CallsListViewControllerViewModelLoaderTest: XCTestCase {
    typealias CallViewModel = CallsListViewController.CallViewModel
    typealias ViewModelLoader = CallsListViewController.ViewModelLoader

    private var viewModelLoader: ViewModelLoader!

    private var mockDB: InMemoryDB!
    private var mockCallRecordLoader: MockCallRecordLoader!
    private lazy var callViewModelForCallRecords: ViewModelLoader.CallViewModelForCallRecords! = {
        self.createCallViewModel(callRecords: $0, tx: $1)
    }

    private lazy var fetchCallRecordBlock: ViewModelLoader.FetchCallRecordBlock! = { callRecordId, tx -> CallRecord? in
        return self.mockCallRecordLoader.callRecordsById[callRecordId]
    }

    private func createCallViewModel(callRecords: [CallRecord], tx: DBReadTransaction) -> CallViewModel {
        let recipientType: CallViewModel.RecipientType = {
            switch callRecords.first!.callStatus {
            case .individual:
                return .individual(type: .video, contactThread: TSContactThread(
                    contactUUID: UUID().uuidString,
                    contactPhoneNumber: nil,
                ))
            case .group:
                return .groupThread(groupId: Data(count: 32))
            case .callLink:
                fatalError()
            }
        }()

        let direction: CallViewModel.Direction = {
            if callRecords.first!.callStatus.isMissedCall {
                return .missed
            }

            switch callRecords.first!.callDirection {
            case .incoming: return .incoming
            case .outgoing: return .outgoing
            }
        }()

        return CallViewModel(
            reference: .callRecords(oldestId: callRecords.last!.id),
            callRecords: callRecords,
            title: "Hey, I just met you, and this is crazy, but here's my number, so call me maybe?",
            recipientType: recipientType,
            direction: direction,
            medium: .video,
            state: .inactive,
        )
    }

    private func setUpViewModelLoader(
        viewModelPageSize: Int,
        maxCoalescedCallsInOneViewModel: Int = 100,
    ) {
        viewModelLoader = ViewModelLoader(
            callLinkStore: CallLinkRecordStoreImpl(),
            callRecordLoader: mockCallRecordLoader,
            callViewModelForCallRecords: { self.callViewModelForCallRecords($0, $1) },
            callViewModelForUpcomingCallLink: { _, _ in owsFail("Not implemented.") },
            fetchCallRecordBlock: { self.fetchCallRecordBlock($0, $1) },
            shouldFetchUpcomingCallLinks: false,
            viewModelPageSize: viewModelPageSize,
            maxCoalescedCallsInOneViewModel: maxCoalescedCallsInOneViewModel,
        )
    }

    private var loadedCallIds: [[UInt64]] {
        return (0..<viewModelLoader.totalCount).map {
            return viewModelLoader.modelReferences(at: $0).callRecordRowIds.map(\.callId)
        }
    }

    private func loadMore(direction: ViewModelLoader.LoadDirection) -> Bool {
        return mockDB.read { tx in
            let (hasChanges, _) = viewModelLoader.loadCallHistoryItemReferences(direction: direction, tx: tx)
            return hasChanges
        }
    }

    private func assertCached(loadedViewModelReferenceIndices: Range<Int>) {
        // Cache the default block, since we're gonna override it.
        let defaultFetchCallRecordBlock = fetchCallRecordBlock!

        var fetchedCallIds = [UInt64]()
        fetchCallRecordBlock = { callRecordId, tx -> CallRecord? in
            fetchedCallIds.append(callRecordId.callId)
            return defaultFetchCallRecordBlock(callRecordId, tx)
        }

        for index in loadedViewModelReferenceIndices {
            XCTAssertNotNil(
                viewModelLoader.viewModel(at: index, sneakyTransactionDb: mockDB),
                "Missing cached view model for index \(index)!",
            )
        }

        XCTAssertEqual(fetchedCallIds, [])
    }

    private func assertCachedCallIds(
        _ callIds: [UInt64],
        atLoadedViewModelReferenceIndex loadedViewModelReferenceIndex: Int,
    ) {
        guard let cachedViewModel = viewModelLoader.viewModel(at: loadedViewModelReferenceIndex, sneakyTransactionDb: mockDB) else {
            XCTFail("Missing cached view model entirely!")
            return
        }

        XCTAssertEqual(callIds, cachedViewModel.callRecords.map { $0.callId })
    }

    private func assertLoadedCallIds(_ callIdsByReference: [UInt64]...) {
        var callIdsByReference = callIdsByReference

        for actualCallIds in loadedCallIds {
            let expectedCallIds = callIdsByReference.popFirst()
            XCTAssertEqual(expectedCallIds, actualCallIds)
        }
        XCTAssertTrue(callIdsByReference.isEmpty)
    }

    override func setUp() {
        mockDB = InMemoryDB()
        mockCallRecordLoader = MockCallRecordLoader()
    }

    func testLoadingNoCallRecords() {
        setUpViewModelLoader(viewModelPageSize: 10)

        XCTAssertFalse(loadMore(direction: .older))
        XCTAssertTrue(viewModelLoader.viewModelReferences().isEmpty)

        XCTAssertFalse(loadMore(direction: .newer))
        XCTAssertTrue(viewModelLoader.viewModelReferences().isEmpty)
    }

    func testBasicCoalescingRules() {
        setUpViewModelLoader(viewModelPageSize: 100)

        var timestamp = SequentialTimestampBuilder()

        mockCallRecordLoader.callRecords = [
            /// Yes coalescing if inside time window, same thread, same direction, same missed-call status.
            .fixture(callId: 99, timestamp: timestamp.uncoalescable(), threadRowId: 0, direction: .incoming, status: .group(.ringingMissed)),
            .fixture(callId: 98, timestamp: timestamp.coalescable(), threadRowId: 0, direction: .incoming, status: .group(.ringingMissedNotificationProfile)),
            .fixture(callId: 97, timestamp: timestamp.coalescable(), threadRowId: 0, direction: .incoming, status: .group(.ringingMissedNotificationProfile)),
            .fixture(callId: 96, timestamp: timestamp.coalescable(), threadRowId: 0, direction: .incoming, status: .group(.ringingMissed)),
            .fixture(callId: 95, timestamp: timestamp.coalescable(), threadRowId: 1, direction: .outgoing, status: .group(.ringingAccepted)),
            .fixture(callId: 94, timestamp: timestamp.coalescable(), threadRowId: 1, direction: .outgoing, status: .group(.ringingAccepted)),
            .fixture(callId: 93, timestamp: timestamp.coalescable(), threadRowId: 1, direction: .outgoing, status: .group(.ringingAccepted)),
            .fixture(callId: 92, timestamp: timestamp.coalescable(), threadRowId: 1, direction: .outgoing, status: .group(.ringingAccepted)),

            /// No coalescing outside of the time window.
            .fixture(callId: 0, timestamp: timestamp.uncoalescable(), threadRowId: 0, direction: .incoming, status: .group(.joined)),
            .fixture(callId: 1, timestamp: timestamp.uncoalescable(), threadRowId: 0, direction: .incoming, status: .group(.joined)),

            /// No coalescing across threads.
            .fixture(callId: 2, timestamp: timestamp.uncoalescable(), threadRowId: 1, direction: .incoming, status: .group(.joined)),
            .fixture(callId: 3, timestamp: timestamp.coalescable(), threadRowId: 2, direction: .incoming, status: .group(.joined)),

            /// No coalescing across direction.
            .fixture(callId: 4, timestamp: timestamp.uncoalescable(), threadRowId: 1, direction: .incoming, status: .group(.joined)),
            .fixture(callId: 5, timestamp: timestamp.coalescable(), threadRowId: 2, direction: .incoming, status: .group(.joined)),

            /// No coalescing across missed-call status.
            .fixture(callId: 6, timestamp: timestamp.uncoalescable(), threadRowId: 3, direction: .incoming, status: .individual(.incomingMissed)),
            .fixture(callId: 7, timestamp: timestamp.coalescable(), threadRowId: 3, direction: .incoming, status: .individual(.accepted)),

            /// No coalsecing if there's an intervening call.
            .fixture(callId: 8, timestamp: timestamp.uncoalescable(), threadRowId: 3, direction: .incoming, status: .individual(.incomingMissed)),
            .fixture(callId: 9, timestamp: timestamp.coalescable(), threadRowId: 3, direction: .incoming, status: .individual(.accepted)),
            .fixture(callId: 10, timestamp: timestamp.coalescable(), threadRowId: 3, direction: .incoming, status: .individual(.incomingMissed)),
        ]

        XCTAssertTrue(loadMore(direction: .older))
        XCTAssertEqual(loadedCallIds, [
            [99, 98, 97, 96],
            [95, 94, 93, 92],
            [0],
            [1],
            [2],
            [3],
            [4],
            [5],
            [6],
            [7],
            [8],
            [9],
            [10],
        ])
    }

    func testScrollingBackAndForthThroughMultiplePages() {
        var timestamp = SequentialTimestampBuilder()

        /// Add 9 call view models' worth of call records to the mock. The 0th,
        /// 3rd, and 6th will be a coalesced call view model.
        mockCallRecordLoader.callRecords = (1...9).flatMap { idx -> [CallRecord] in
            if idx % 3 == 0 {
                /// Add a coalescable pair of calls.
                return [
                    .fixture(callId: UInt64(idx), timestamp: timestamp.uncoalescable(), threadRowId: Int64(idx)),
                    .fixture(callId: UInt64(idx * 1000), timestamp: timestamp.coalescable(), threadRowId: Int64(idx)),
                ]
            } else {
                /// Add a single uncoalescable call.
                return [
                    .fixture(callId: UInt64(idx), timestamp: timestamp.uncoalescable(), threadRowId: Int64(idx)),
                ]
            }
        }

        setUpViewModelLoader(viewModelPageSize: 3)

        /// Scroll backwards three pages, thereby dropping the first-loaded view
        /// models.

        XCTAssertTrue(loadMore(direction: .older))
        assertLoadedCallIds([1], [2], [3, 3000])
        assertCachedCallIds([1], atLoadedViewModelReferenceIndex: 0)
        assertCached(loadedViewModelReferenceIndices: 0..<3)
        assertCachedCallIds([2], atLoadedViewModelReferenceIndex: 1)
        assertCachedCallIds([3, 3000], atLoadedViewModelReferenceIndex: 2)

        XCTAssertTrue(loadMore(direction: .older))
        assertLoadedCallIds([1], [2], [3, 3000], [4], [5], [6, 6000])
        assertCachedCallIds([1], atLoadedViewModelReferenceIndex: 0)
        assertCachedCallIds([2], atLoadedViewModelReferenceIndex: 1)
        assertCachedCallIds([3, 3000], atLoadedViewModelReferenceIndex: 2)
        assertCachedCallIds([4], atLoadedViewModelReferenceIndex: 3)
        assertCached(loadedViewModelReferenceIndices: 0..<6)
        assertCachedCallIds([5], atLoadedViewModelReferenceIndex: 4)
        assertCachedCallIds([6, 6000], atLoadedViewModelReferenceIndex: 5)

        XCTAssertTrue(loadMore(direction: .older))
        assertLoadedCallIds([1], [2], [3, 3000], [4], [5], [6, 6000], [7], [8], [9, 9000])
        assertCachedCallIds([4], atLoadedViewModelReferenceIndex: 3)
        assertCachedCallIds([5], atLoadedViewModelReferenceIndex: 4)
        assertCachedCallIds([6, 6000], atLoadedViewModelReferenceIndex: 5)
        assertCachedCallIds([7], atLoadedViewModelReferenceIndex: 6)
        assertCached(loadedViewModelReferenceIndices: 3..<9)
        assertCachedCallIds([8], atLoadedViewModelReferenceIndex: 7)
        assertCachedCallIds([9, 9000], atLoadedViewModelReferenceIndex: 8)

        XCTAssertFalse(loadMore(direction: .older))
        assertLoadedCallIds([1], [2], [3, 3000], [4], [5], [6, 6000], [7], [8], [9, 9000])
        assertCached(loadedViewModelReferenceIndices: 3..<9)
        assertCachedCallIds([4], atLoadedViewModelReferenceIndex: 3)
        assertCachedCallIds([5], atLoadedViewModelReferenceIndex: 4)
        assertCachedCallIds([6, 6000], atLoadedViewModelReferenceIndex: 5)
        assertCachedCallIds([7], atLoadedViewModelReferenceIndex: 6)
        assertCachedCallIds([8], atLoadedViewModelReferenceIndex: 7)
        assertCachedCallIds([9, 9000], atLoadedViewModelReferenceIndex: 8)

        /// Now, scroll forwards, thereby dropping the last-loaded view models.
        /// These loads won't load any brand-new calls, and will instead
        /// rehydrate already-loaded view model references.
        XCTAssertFalse(loadMore(direction: .newer))
        assertLoadedCallIds([1], [2], [3, 3000], [4], [5], [6, 6000], [7], [8], [9, 9000])
        assertCachedCallIds([1], atLoadedViewModelReferenceIndex: 0)
        assertCached(loadedViewModelReferenceIndices: 0..<6)
        assertCachedCallIds([2], atLoadedViewModelReferenceIndex: 1)
        assertCachedCallIds([3, 3000], atLoadedViewModelReferenceIndex: 2)
        assertCachedCallIds([4], atLoadedViewModelReferenceIndex: 3)
        assertCachedCallIds([5], atLoadedViewModelReferenceIndex: 4)
        assertCachedCallIds([6, 6000], atLoadedViewModelReferenceIndex: 5)

        XCTAssertFalse(loadMore(direction: .newer))
        assertLoadedCallIds([1], [2], [3, 3000], [4], [5], [6, 6000], [7], [8], [9, 9000])
        assertCached(loadedViewModelReferenceIndices: 0..<6)
        assertCachedCallIds([1], atLoadedViewModelReferenceIndex: 0)
        assertCachedCallIds([2], atLoadedViewModelReferenceIndex: 1)
        assertCachedCallIds([3, 3000], atLoadedViewModelReferenceIndex: 2)
        assertCachedCallIds([4], atLoadedViewModelReferenceIndex: 3)
        assertCachedCallIds([5], atLoadedViewModelReferenceIndex: 4)
        assertCachedCallIds([6, 6000], atLoadedViewModelReferenceIndex: 5)
    }

    /// Load a ton of calls, such that the cached view models have long ago
    /// dropped the first-loaded calls, and then simulate a super-fast scroll to
    /// the top, then the bottom, by loading until the first, then the last,
    /// calls are cached.
    func testLoadUntilCached() {
        setUpViewModelLoader(viewModelPageSize: 100)
        var timestamp = SequentialTimestampBuilder()

        mockCallRecordLoader.callRecords = (1...5000).flatMap { idx -> [CallRecord] in
            if idx % 4 == 0 {
                /// Add a coalescable triplet of calls.
                return [
                    .fixture(callId: UInt64(idx), timestamp: timestamp.uncoalescable(), threadRowId: Int64(idx)),
                    .fixture(callId: UInt64(idx + 5000), timestamp: timestamp.coalescable(), threadRowId: Int64(idx)),
                    .fixture(callId: UInt64(idx + 10000), timestamp: timestamp.coalescable(), threadRowId: Int64(idx)),
                ]
            } else {
                /// Add a single uncoalescable call.
                return [
                    .fixture(callId: UInt64(idx), timestamp: timestamp.uncoalescable(), threadRowId: Int64(idx)),
                ]
            }
        }

        for _ in 0..<50 {
            XCTAssertTrue(loadMore(direction: .older))
        }
        XCTAssertFalse(loadMore(direction: .older))

        assertCachedCallIds([5000, 10000, 15000], atLoadedViewModelReferenceIndex: 4999)
        assertCached(loadedViewModelReferenceIndices: 4900..<5000)
        assertCachedCallIds([1], atLoadedViewModelReferenceIndex: 0)
        assertCached(loadedViewModelReferenceIndices: 0..<100)
        assertCachedCallIds([5000, 10000, 15000], atLoadedViewModelReferenceIndex: 4999)
        assertCached(loadedViewModelReferenceIndices: 4900..<5000)
    }

    func testNewerCallInserted() {
        setUpViewModelLoader(viewModelPageSize: 2)
        var timestamp = SequentialTimestampBuilder()

        let timestampToInsert0 = timestamp.uncoalescable()
        let timestampToInsert1 = timestamp.coalescable()
        let timestampToInsert2 = timestamp.coalescable()

        mockCallRecordLoader.callRecords = [
            .fixture(callId: 3, timestamp: timestamp.coalescable()),
            .fixture(callId: 4, timestamp: timestamp.coalescable()),
            .fixture(callId: 5, timestamp: timestamp.coalescable()),
            .fixture(callId: 6, timestamp: timestamp.uncoalescable()),
        ]

        XCTAssertTrue(loadMore(direction: .newer))
        assertLoadedCallIds([3, 4, 5], [6])
        assertCachedCallIds([3, 4, 5], atLoadedViewModelReferenceIndex: 0)
        assertCached(loadedViewModelReferenceIndices: 0..<2)
        assertCachedCallIds([6], atLoadedViewModelReferenceIndex: 1)

        XCTAssertFalse(loadMore(direction: .newer))
        assertLoadedCallIds([3, 4, 5], [6])
        assertCached(loadedViewModelReferenceIndices: 0..<2)
        assertCachedCallIds([3, 4, 5], atLoadedViewModelReferenceIndex: 0)
        assertCachedCallIds([6], atLoadedViewModelReferenceIndex: 1)

        /// If we insert a single new call record that can be coalesced into the
        /// existing first view model, it should be merged into the existing
        /// view model.
        mockCallRecordLoader.callRecords.insert(.fixture(callId: 2, timestamp: timestampToInsert2), at: 0)
        XCTAssertTrue(loadMore(direction: .newer))
        assertLoadedCallIds([2, 3, 4, 5], [6])
        assertCachedCallIds([2, 3, 4, 5], atLoadedViewModelReferenceIndex: 0)
        assertCached(loadedViewModelReferenceIndices: 0..<2)
        assertCachedCallIds([6], atLoadedViewModelReferenceIndex: 1)

        mockCallRecordLoader.callRecords.insert(.fixture(callId: 1, timestamp: timestampToInsert1), at: 0)
        mockCallRecordLoader.callRecords.insert(.fixture(callId: 0, timestamp: timestampToInsert0), at: 0)
        XCTAssertTrue(loadMore(direction: .newer))
        assertLoadedCallIds([0], [1, 2, 3, 4, 5], [6])
        assertCachedCallIds([0], atLoadedViewModelReferenceIndex: 0)
        assertCached(loadedViewModelReferenceIndices: 0..<3)
        assertCachedCallIds([1, 2, 3, 4, 5], atLoadedViewModelReferenceIndex: 1)
        assertCachedCallIds([6], atLoadedViewModelReferenceIndex: 2)

        /// And now, finally, there's nothing new to load.
        XCTAssertFalse(loadMore(direction: .newer))
        assertLoadedCallIds([0], [1, 2, 3, 4, 5], [6])
        assertCached(loadedViewModelReferenceIndices: 0..<3)
        assertCachedCallIds([0], atLoadedViewModelReferenceIndex: 0)
        assertCachedCallIds([1, 2, 3, 4, 5], atLoadedViewModelReferenceIndex: 1)
        assertCachedCallIds([6], atLoadedViewModelReferenceIndex: 2)
    }

    func testRefreshingViewModels() {
        // Cache the default block, since we're gonna override it.
        let defaultFetchCallRecordBlock = fetchCallRecordBlock!

        setUpViewModelLoader(viewModelPageSize: 2)
        var timestamp = SequentialTimestampBuilder()

        let earlierTimestamp = timestamp.uncoalescable()

        mockCallRecordLoader.callRecords = [
            .fixture(callId: 0, timestamp: timestamp.uncoalescable()),
            .fixture(callId: 1, timestamp: timestamp.coalescable()),

            .fixture(callId: 2, timestamp: timestamp.uncoalescable()),
        ]

        XCTAssertTrue(loadMore(direction: .older))
        assertLoadedCallIds([0, 1], [2])
        assertCachedCallIds([0, 1], atLoadedViewModelReferenceIndex: 0)
        assertCached(loadedViewModelReferenceIndices: 0..<2)
        assertCachedCallIds([2], atLoadedViewModelReferenceIndex: 1)

        var fetchedCallIds = [UInt64]()
        fetchCallRecordBlock = { callRecordId, tx -> CallRecord? in
            fetchedCallIds.append(callRecordId.callId)
            return defaultFetchCallRecordBlock(callRecordId, tx)
        }

        let firstViewModelIds: [CallRecord.ID] = [
            .fixture(callId: 0),
            .fixture(callId: 1),
        ]

        for callRecordId in firstViewModelIds {
            /// Asking to recreate for either call record ID in a coalesced
            /// view model should re-fetch all the calls in the view model.
            XCTAssertEqual(
                viewModelLoader.invalidate(callLinkRowIds: [], callRecordIds: [callRecordId]),
                [.callRecords(oldestId: .fixture(callId: 1))],
            )
            assertCachedCallIds([0, 1], atLoadedViewModelReferenceIndex: 0)
            XCTAssertEqual(fetchedCallIds, [0, 1])
            fetchedCallIds = []
        }

        XCTAssertEqual(
            viewModelLoader.invalidate(callLinkRowIds: [], callRecordIds: [.fixture(callId: 2)]),
            [.callRecords(oldestId: .fixture(callId: 2))],
        )
        assertCachedCallIds([2], atLoadedViewModelReferenceIndex: 1)
        XCTAssertEqual(fetchedCallIds, [2])
        fetchedCallIds = []

        // Insert an earlier record that doesn't have a CallViewModel.
        mockCallRecordLoader.callRecords.insert(.fixture(callId: 3, timestamp: earlierTimestamp), at: 0)
        XCTAssertTrue(loadMore(direction: .newer))

        /// If we ask to recreate for a call record ID that's not part of
        /// any cached view models, it should be marked for reloading.
        fetchCallRecordBlock = { _, _ in XCTFail("Unexpectedly tried to fetch!"); return nil }
        XCTAssertEqual(
            viewModelLoader.invalidate(callLinkRowIds: [], callRecordIds: [.fixture(callId: 3)]),
            [.callRecords(oldestId: .fixture(callId: 3))],
        )
        XCTAssertEqual(fetchedCallIds, [])
    }

    func testDroppingViewModels() {
        setUpViewModelLoader(viewModelPageSize: 6)
        var timestamp = SequentialTimestampBuilder()

        mockCallRecordLoader.callRecords = [
            /// We won't delete this one, but it'll have been paged out.
            .fixture(callId: 99, timestamp: timestamp.uncoalescable()),

            /// We'll page this out before deleting it.
            .fixture(callId: 98, timestamp: timestamp.uncoalescable()),

            /// We'll have this paged in and won't delete it; see the next one.
            .fixture(callId: 0, timestamp: timestamp.uncoalescable()),

            /// We'll delete this (while having it paged in), which will
            /// technically make `callId: 0`  coalescable with
            /// `callIds: [1, 2, 3]` below, since there won't be any intervening
            /// calls. However, deleting does not prompt re-coalescing.
            .fixture(callId: 97, timestamp: timestamp.coalescable(), threadRowId: 1),

            /// We'll delete the primary call record from this view model.
            .fixture(callId: 1, timestamp: timestamp.coalescable()),
            .fixture(callId: 2, timestamp: timestamp.coalescable()),
            .fixture(callId: 3, timestamp: timestamp.coalescable()),

            /// We'll delete a coalesced call record from this view model.
            .fixture(callId: 4, timestamp: timestamp.uncoalescable()),
            .fixture(callId: 5, timestamp: timestamp.coalescable()),
            .fixture(callId: 6, timestamp: timestamp.coalescable()),

            /// We'll delete all the call records from this view model.
            .fixture(callId: 7, timestamp: timestamp.uncoalescable()),
            .fixture(callId: 8, timestamp: timestamp.coalescable()),
            .fixture(callId: 9, timestamp: timestamp.coalescable()),

            /// We won't delete this one :)
            .fixture(callId: 10, timestamp: timestamp.uncoalescable()),
            .fixture(callId: 11, timestamp: timestamp.coalescable()),
        ]

        XCTAssertTrue(loadMore(direction: .older))
        assertLoadedCallIds([99], [98], [0], [97], [1, 2, 3], [4, 5, 6])
        assertCachedCallIds([99], atLoadedViewModelReferenceIndex: 0)
        assertCached(loadedViewModelReferenceIndices: 0..<6)

        XCTAssertTrue(loadMore(direction: .older))
        assertLoadedCallIds([99], [98], [0], [97], [1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11])
        assertCachedCallIds([7, 8, 9], atLoadedViewModelReferenceIndex: 6)
        assertCached(loadedViewModelReferenceIndices: 2..<8)

        mockDB.read { tx in
            viewModelLoader.dropCalls(
                matching: [
                    .fixture(callId: 98),
                    .fixture(callId: 97, threadRowId: 1),
                    .fixture(callId: 1),
                    .fixture(callId: 5),
                    .fixture(callId: 7),
                    .fixture(callId: 8),
                    .fixture(callId: 9),
                ],
                tx: tx,
            )
        }

        assertLoadedCallIds(
            [99],
            [0],
            [2, 3],
            [4, 6],
            [10, 11],
        )
        assertCached(loadedViewModelReferenceIndices: 0..<2)
        assertCached(loadedViewModelReferenceIndices: 4..<5)
        assertCachedCallIds([0], atLoadedViewModelReferenceIndex: 1)
        assertCachedCallIds([2, 3], atLoadedViewModelReferenceIndex: 2)
        assertCached(loadedViewModelReferenceIndices: 0..<5)
        assertCachedCallIds([4, 6], atLoadedViewModelReferenceIndex: 3)
        assertCachedCallIds([10, 11], atLoadedViewModelReferenceIndex: 4)
    }

    func testMaxCoalescedCallsInOneViewModel() {
        setUpViewModelLoader(viewModelPageSize: 3, maxCoalescedCallsInOneViewModel: 3)
        var timestamp = SequentialTimestampBuilder()

        mockCallRecordLoader.callRecords = [
            .fixture(callId: 1, timestamp: timestamp.uncoalescable()),
            .fixture(callId: 2, timestamp: timestamp.coalescable()),
            .fixture(callId: 3, timestamp: timestamp.coalescable()),
            .fixture(callId: 4, timestamp: timestamp.coalescable()),
            .fixture(callId: 5, timestamp: timestamp.coalescable()),
            .fixture(callId: 6, timestamp: timestamp.coalescable()),
            .fixture(callId: 7, timestamp: timestamp.coalescable()),
        ]

        XCTAssertTrue(loadMore(direction: .older))
        assertLoadedCallIds([1, 2, 3], [4, 5, 6], [7])
    }
}

// MARK: - Mocks

private struct SequentialTimestampBuilder {
    private var current: UInt64 = Date().ows_millisecondsSince1970

    /// Generates a timestamp that is earlier than and coalescable with the
    /// previously-generated one.
    mutating func coalescable() -> UInt64 {
        current -= 1
        return current
    }

    /// Generates a timestamp earlier that is than and not coalescable with the
    /// previously-generated one.
    mutating func uncoalescable() -> UInt64 {
        let millisecondsOutsideCoalesceWindow = 4 * 1000 * UInt64(TimeInterval.hour) + 1
        current -= millisecondsOutsideCoalesceWindow
        return current
    }
}

private extension CallRecord.ID {
    static func fixture(callId: UInt64, threadRowId: Int64 = 0) -> CallRecord.ID {
        return CallRecord.ID(
            conversationId: .thread(threadRowId: threadRowId),
            callId: callId,
        )
    }
}

private extension CallRecord {
    static func fixture(
        callId: UInt64,
        timestamp: UInt64,
        callType: CallRecord.CallType? = nil,
        threadRowId: Int64 = 0,
        direction: CallRecord.CallDirection = .incoming,
        status: CallRecord.CallStatus = .group(.joined),
    ) -> CallRecord {
        return CallRecord(
            callId: callId,
            interactionRowId: 0,
            threadRowId: threadRowId,
            callType: callType ?? {
                switch status {
                case .individual: return .audioCall
                case .group: return .groupCall
                case .callLink: return .adHocCall
                }
            }(),
            callDirection: direction,
            callStatus: status,
            callBeganTimestamp: timestamp,
        )
    }
}

private class MockCallRecordLoader: CallRecordLoader {
    private class Cursor: CallRecordCursor {
        private var callRecords: [CallRecord] = []

        init(_ callRecords: [CallRecord], direction: LoadDirection) {
            self.callRecords = callRecords
        }

        func next() throws -> CallRecord? { return callRecords.popFirst() }
    }

    var callRecords: [CallRecord] {
        get { callRecordsDescending }
        set {
            callRecordsDescending = newValue.sorted { $0.callBeganTimestamp > $1.callBeganTimestamp }
            callRecordsAscending = newValue.sorted { $0.callBeganTimestamp < $1.callBeganTimestamp }
            callRecordsById = Dictionary(
                newValue.map { ($0.id, $0) },
                uniquingKeysWith: { new, _ in return new },
            )
        }
    }

    private(set) var callRecordsById: [CallRecord.ID: CallRecord] = [:]
    private var callRecordsDescending: [CallRecord] = []
    private var callRecordsAscending: [CallRecord] = []

    private func applyLoadDirection(_ direction: LoadDirection) -> [CallRecord] {
        switch direction {
        case .olderThan(oldestCallTimestamp: nil):
            return callRecordsDescending
        case .olderThan(.some(let oldestCallTimestamp)):
            return callRecordsDescending.filter { $0.callBeganTimestamp < oldestCallTimestamp }
        case .newerThan(let newestCallTimestamp):
            return callRecordsAscending.filter { $0.callBeganTimestamp > newestCallTimestamp }
        }
    }

    func loadCallRecords(loadDirection: LoadDirection, tx: DBReadTransaction) -> CallRecordCursor {
        return Cursor(applyLoadDirection(loadDirection), direction: loadDirection)
    }
}