// // Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import XCTest import SignalServiceKit @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.. Bool { return mockDB.read { tx in let (hasChanges, _) = viewModelLoader.loadCallHistoryItemReferences(direction: direction, tx: tx) return hasChanges } } private func assertCached(loadedViewModelReferenceIndices: Range) { // 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) } }