TM-SGNL-iOS/Signal/test/CallsTab/CallsListViewController+ViewModelLoaderTest.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

638 lines
29 KiB
Swift

//
// 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..<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(kHourInterval) + 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)
}
}