Signal-iOS/Signal/test/CallsTab/CallsListViewController+ViewModelLoaderTest.swift
2024-03-21 11:07:36 -07:00

651 lines
29 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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: MockDB!
private var mockCallRecordLoader: MockCallRecordLoader!
private lazy var createCallViewModelBlock: ViewModelLoader.CreateCallViewModelBlock! = {
self.createCallViewModel(primaryCallRecord: $0, coalescedCallRecords: $1, tx: $2)
}
private lazy var fetchCallRecordBlock: ViewModelLoader.FetchCallRecordBlock! = { (callRecordId, tx) -> CallRecord? in
return self.mockCallRecordLoader.callRecordsById[callRecordId]
}
private func createCallViewModel(
primaryCallRecord: CallRecord,
coalescedCallRecords: [CallRecord],
tx: DBReadTransaction
) -> CallViewModel {
let recipientType: CallViewModel.RecipientType = {
switch primaryCallRecord.callStatus {
case .individual:
return .individual(type: .audio, contactThread: TSContactThread(
contactUUID: UUID().uuidString,
contactPhoneNumber: nil
))
case .group:
return .group(groupThread: TSGroupThread.forUnitTest())
}
}()
let direction: CallViewModel.Direction = {
if primaryCallRecord.callStatus.isMissedCall {
return .missed
}
switch primaryCallRecord.callDirection {
case .incoming: return .incoming
case .outgoing: return .outgoing
}
}()
return CallViewModel(
primaryCallRecord: primaryCallRecord,
coalescedCallRecords: coalescedCallRecords,
title: "Hey, I just met you, and this is crazy, but here's my number, so call me maybe?",
recipientType: recipientType,
direction: direction,
state: .ended
)
}
private func setUpViewModelLoader(
viewModelPageSize: UInt,
maxCachedViewModelCount: Int,
maxCoalescedCallsInOneViewModel: UInt = 100
) {
viewModelLoader = ViewModelLoader(
callRecordLoader: mockCallRecordLoader,
createCallViewModelBlock: { self.createCallViewModelBlock($0, $1, $2) },
fetchCallRecordBlock: { self.fetchCallRecordBlock($0, $1) },
viewModelPageSize: viewModelPageSize,
maxCachedViewModelCount: maxCachedViewModelCount,
maxCoalescedCallsInOneViewModel: maxCoalescedCallsInOneViewModel
)
}
private var loadedCallIds: [[UInt64]] {
return viewModelLoader.loadedViewModelReferences.allElements.map { reference -> [UInt64] in
return reference.containedIds.map { $0.callId }
}
}
private func loadMore(direction: ViewModelLoader.LoadDirection) -> Bool {
return mockDB.read { viewModelLoader.loadMore(direction: direction, tx: $0) }
}
private func loadUntilCached(loadedViewModelReferenceIndex: Int) {
mockDB.read {
viewModelLoader.loadUntilCached(loadedViewModelReferenceIndex: loadedViewModelReferenceIndex, tx: $0)
}
}
private func assertCached(loadedViewModelReferenceIndices: Range<Int>) {
XCTAssertFalse(
viewModelLoader.hasCachedViewModel(loadedViewModelReferenceIndex: loadedViewModelReferenceIndices.lowerBound - 1),
"Had cached view model outside given range! Idx: \(loadedViewModelReferenceIndices.lowerBound - 1)"
)
XCTAssertFalse(
viewModelLoader.hasCachedViewModel(loadedViewModelReferenceIndex: loadedViewModelReferenceIndices.upperBound),
"Had cached view model outside given range! Idx: \(loadedViewModelReferenceIndices.upperBound)"
)
for index in loadedViewModelReferenceIndices {
XCTAssertTrue(
viewModelLoader.hasCachedViewModel(loadedViewModelReferenceIndex: index),
"Missing cached view model for index \(index)!"
)
}
}
private func assertCachedCallIds(
_ callIds: [UInt64],
atLoadedViewModelReferenceIndex loadedViewModelReferenceIndex: Int
) {
guard let cachedViewModel = viewModelLoader.getCachedViewModel(loadedViewModelReferenceIndex: loadedViewModelReferenceIndex) else {
XCTFail("Missing cached view model entirely!")
return
}
XCTAssertEqual(callIds, cachedViewModel.allCallRecords.map { $0.callId })
}
private func assertLoadedCallIds(_ callIdsByReference: [UInt64]...) {
var callIdsByReference = callIdsByReference
for reference in viewModelLoader.loadedViewModelReferences.allElements {
let expectedCallIds = callIdsByReference.popFirst()
XCTAssertEqual(expectedCallIds, reference.containedIds.map { $0.callId })
}
XCTAssertTrue(callIdsByReference.isEmpty)
}
override func setUp() {
mockDB = MockDB()
mockCallRecordLoader = MockCallRecordLoader()
}
func testLoadingNoCallRecords() {
setUpViewModelLoader(viewModelPageSize: 10, maxCachedViewModelCount: 30)
XCTAssertFalse(loadMore(direction: .older))
XCTAssertTrue(viewModelLoader.loadedViewModelReferences.isEmpty)
XCTAssertFalse(viewModelLoader.hasCachedViewModel(loadedViewModelReferenceIndex: 0))
XCTAssertFalse(loadMore(direction: .newer))
XCTAssertTrue(viewModelLoader.loadedViewModelReferences.isEmpty)
XCTAssertFalse(viewModelLoader.hasCachedViewModel(loadedViewModelReferenceIndex: 0))
}
func testBasicCoalescingRules() {
setUpViewModelLoader(viewModelPageSize: 100, maxCachedViewModelCount: 300)
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(.ringingMissed)),
.fixture(callId: 97, timestamp: timestamp.coalescable(), threadRowId: 0, direction: .incoming, status: .group(.ringingMissed)),
.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],
])
assertCached(loadedViewModelReferenceIndices: 0..<viewModelLoader.loadedViewModelReferences.count)
}
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, maxCachedViewModelCount: 6)
/// Scroll backwards three pages, thereby dropping the first-loaded view
/// models.
XCTAssertTrue(loadMore(direction: .older))
assertLoadedCallIds([1], [2], [3, 3000])
assertCached(loadedViewModelReferenceIndices: 0..<3)
assertCachedCallIds([1], atLoadedViewModelReferenceIndex: 0)
assertCachedCallIds([2], atLoadedViewModelReferenceIndex: 1)
assertCachedCallIds([3, 3000], atLoadedViewModelReferenceIndex: 2)
XCTAssertTrue(loadMore(direction: .older))
assertLoadedCallIds([1], [2], [3, 3000], [4], [5], [6, 6000])
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)
XCTAssertTrue(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)
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])
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)
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, maxCachedViewModelCount: 300)
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 * 5001), 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))
assertCached(loadedViewModelReferenceIndices: 4700..<5000)
loadUntilCached(loadedViewModelReferenceIndex: 0)
assertCached(loadedViewModelReferenceIndices: 0..<300)
loadUntilCached(loadedViewModelReferenceIndex: 4999)
assertCached(loadedViewModelReferenceIndices: 4700..<5000)
}
func testNewerCallInserted() {
setUpViewModelLoader(viewModelPageSize: 2, maxCachedViewModelCount: 4)
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))
assertCached(loadedViewModelReferenceIndices: 0..<2)
assertCachedCallIds([3, 4, 5], atLoadedViewModelReferenceIndex: 0)
assertCachedCallIds([6], atLoadedViewModelReferenceIndex: 1)
XCTAssertFalse(loadMore(direction: .newer))
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))
assertCached(loadedViewModelReferenceIndices: 0..<2)
assertCachedCallIds([2, 3, 4, 5], atLoadedViewModelReferenceIndex: 0)
assertCachedCallIds([6], atLoadedViewModelReferenceIndex: 1)
/// If we then insert multiple new call records that can be coalesced,
/// those should *not* be merged into the existing view model. That's a
/// quirk of how the view model loader works see comments in the
/// implementation about it but since it's an intentional behavior
/// let's test it.
mockCallRecordLoader.callRecords.insert(.fixture(callId: 1, timestamp: timestampToInsert1), at: 0)
mockCallRecordLoader.callRecords.insert(.fixture(callId: 0, timestamp: timestampToInsert0), at: 0)
XCTAssertTrue(loadMore(direction: .newer))
assertCached(loadedViewModelReferenceIndices: 0..<4)
assertCachedCallIds([0], atLoadedViewModelReferenceIndex: 0)
assertCachedCallIds([1], atLoadedViewModelReferenceIndex: 1)
assertCachedCallIds([2, 3, 4, 5], atLoadedViewModelReferenceIndex: 2)
assertCachedCallIds([6], atLoadedViewModelReferenceIndex: 3)
/// And now, finally, there's nothing new to load.
XCTAssertFalse(loadMore(direction: .newer))
assertCached(loadedViewModelReferenceIndices: 0..<4)
assertCachedCallIds([0], atLoadedViewModelReferenceIndex: 0)
assertCachedCallIds([1], atLoadedViewModelReferenceIndex: 1)
assertCachedCallIds([2, 3, 4, 5], atLoadedViewModelReferenceIndex: 2)
assertCachedCallIds([6], atLoadedViewModelReferenceIndex: 3)
}
func testRefreshingViewModels() {
// Cache the default block, since we're gonna override it.
let defaultFetchCallRecordBlock = fetchCallRecordBlock!
setUpViewModelLoader(viewModelPageSize: 2, maxCachedViewModelCount: 2)
var timestamp = SequentialTimestampBuilder()
mockCallRecordLoader.callRecords = [
.fixture(callId: 0, timestamp: timestamp.uncoalescable()),
.fixture(callId: 1, timestamp: timestamp.coalescable()),
.fixture(callId: 2, timestamp: timestamp.uncoalescable()),
.fixture(callId: 3, timestamp: timestamp.uncoalescable()),
]
XCTAssertTrue(loadMore(direction: .older))
assertCached(loadedViewModelReferenceIndices: 0..<2)
assertCachedCallIds([0, 1], atLoadedViewModelReferenceIndex: 0)
assertCachedCallIds([2], atLoadedViewModelReferenceIndex: 1)
var fetchedCallIds = [UInt64]()
fetchCallRecordBlock = { callRecordId, tx -> CallRecord? in
fetchedCallIds.append(callRecordId.callId)
return defaultFetchCallRecordBlock(callRecordId, tx)
}
mockDB.read { tx in
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.refreshViewModels(
callRecordIds: [callRecordId], tx: tx
),
[
.coalescedCalls(
primary: .fixture(callId: 0),
coalesced: [.fixture(callId: 1)]
)
]
)
XCTAssertEqual(fetchedCallIds, [0, 1])
fetchedCallIds = []
}
XCTAssertEqual(
viewModelLoader.refreshViewModels(callRecordIds: [.fixture(callId: 2)], tx: tx),
[.singleCall(.fixture(callId: 2))]
)
XCTAssertEqual(fetchedCallIds, [2])
fetchedCallIds = []
}
/// Load across a partial page and drop that first coalesced view model.
XCTAssertTrue(loadMore(direction: .older))
assertCached(loadedViewModelReferenceIndices: 1..<3)
assertCachedCallIds([2], atLoadedViewModelReferenceIndex: 1)
assertCachedCallIds([3], atLoadedViewModelReferenceIndex: 2)
/// If we ask to recreate for a call record ID that's not part of
/// any cached view models, nothing should happen.
fetchCallRecordBlock = { (_, _) in XCTFail("Unexpectedly tried to fetch!"); return nil }
mockDB.read { tx in
XCTAssertEqual(
viewModelLoader.refreshViewModels(callRecordIds: [.fixture(callId: 0)], tx: tx),
[]
)
XCTAssertEqual(fetchedCallIds, [])
}
}
func testDroppingViewModels() {
setUpViewModelLoader(viewModelPageSize: 6, maxCachedViewModelCount: 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])
assertCached(loadedViewModelReferenceIndices: 0..<6)
XCTAssertTrue(loadMore(direction: .older))
assertLoadedCallIds([99], [98], [0], [97], [1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11])
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: 1..<5)
assertCachedCallIds([0], atLoadedViewModelReferenceIndex: 1)
assertCachedCallIds([2, 3], atLoadedViewModelReferenceIndex: 2)
assertCachedCallIds([4, 6], atLoadedViewModelReferenceIndex: 3)
assertCachedCallIds([10, 11], atLoadedViewModelReferenceIndex: 4)
}
func testMaxCoalescedCallsInOneViewModel() {
setUpViewModelLoader(viewModelPageSize: 3, maxCachedViewModelCount: 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])
assertCached(loadedViewModelReferenceIndices: 0..<3)
}
}
// 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(
callId: callId,
threadRowId: threadRowId
)
}
}
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
}
}(),
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)
}
}