Merge branch 'develop' into flescio/1040-poll_detail

# Conflicts:
#	Riot/Generated/Strings.swift
#	RiotSwiftUI/Modules/Room/PollHistory/Coordinator/PollHistoryCoordinator.swift
#	RiotSwiftUI/Modules/Room/PollHistory/PollHistoryModels.swift
#	RiotSwiftUI/Modules/Room/PollHistory/PollHistoryViewModel.swift
This commit is contained in:
Flavio Alescio
2023-01-27 15:22:15 +01:00
22 changed files with 445 additions and 182 deletions
@@ -26,8 +26,11 @@ enum MockPollHistoryScreenState: MockScreenState, CaseIterable {
// mock that screen.
case active
case past
case activeEmpty
case pastEmpty
case activeNoMoreContent
case contentLoading
case empty
case emptyLoading
case emptyNoMoreContent
case loading
/// The associated screen
@@ -37,34 +40,40 @@ enum MockPollHistoryScreenState: MockScreenState, CaseIterable {
/// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) {
let pollHistoryMode: PollHistoryMode
var pollHistoryMode: PollHistoryMode = .active
let pollService = MockPollHistoryService()
switch self {
case .active:
pollHistoryMode = .active
case .activeNoMoreContent:
pollHistoryMode = .active
pollService.hasNextBatch = false
case .past:
pollHistoryMode = .past
case .activeEmpty:
case .contentLoading:
pollService.nextBatchPublishers.append(MockPollPublisher.loadingPolls)
case .empty:
pollHistoryMode = .active
pollService.nextBatchPublisher = Empty(completeImmediately: true,
outputType: TimelinePollDetails.self,
failureType: Error.self).eraseToAnyPublisher()
case .pastEmpty:
pollHistoryMode = .past
pollService.nextBatchPublisher = Empty(completeImmediately: true,
outputType: TimelinePollDetails.self,
failureType: Error.self).eraseToAnyPublisher()
pollService.nextBatchPublishers = [MockPollPublisher.emptyPolls]
case .emptyLoading:
pollService.nextBatchPublishers = [MockPollPublisher.emptyPolls, MockPollPublisher.loadingPolls]
case .emptyNoMoreContent:
pollService.hasNextBatch = false
pollService.nextBatchPublishers = [MockPollPublisher.emptyPolls]
case .loading:
pollHistoryMode = .active
pollService.nextBatchPublisher = Empty(completeImmediately: false,
outputType: TimelinePollDetails.self,
failureType: Error.self).eraseToAnyPublisher()
pollService.nextBatchPublishers = [MockPollPublisher.loadingPolls]
}
let viewModel = PollHistoryViewModel(mode: pollHistoryMode, pollService: pollService)
// can simulate service and viewModel actions here if needs be.
switch self {
case .contentLoading, .emptyLoading:
viewModel.process(viewAction: .loadMoreContent)
default:
break
}
return (
[pollHistoryMode, viewModel],
@@ -73,3 +82,17 @@ enum MockPollHistoryScreenState: MockScreenState, CaseIterable {
)
}
}
enum MockPollPublisher {
static var emptyPolls: AnyPublisher<TimelinePollDetails, Error> {
Empty<TimelinePollDetails, Error>(completeImmediately: true).eraseToAnyPublisher()
}
static var loadingPolls: AnyPublisher<TimelinePollDetails, Error> {
Empty<TimelinePollDetails, Error>(completeImmediately: false).eraseToAnyPublisher()
}
static var failure: AnyPublisher<TimelinePollDetails, Error> {
Fail(error: NSError(domain: "fake", code: 1)).eraseToAnyPublisher()
}
}
@@ -33,6 +33,7 @@ enum PollHistoryMode: CaseIterable {
struct PollHistoryViewBindings {
var mode: PollHistoryMode
var alertInfo: AlertInfo<Bool>?
}
struct PollHistoryViewState: BindableState {
@@ -44,10 +45,13 @@ struct PollHistoryViewState: BindableState {
var isLoading = false
var canLoadMoreContent = true
var polls: [TimelinePollDetails]?
var syncStartDate: Date = .init()
var syncedUpTo: Date = .distantFuture
}
enum PollHistoryViewAction {
case viewAppeared
case segmentDidChange
case showPollDetail(poll: TimelinePollDetails)
case loadMoreContent
}
@@ -29,6 +29,7 @@ final class PollHistoryViewModel: PollHistoryViewModelType, PollHistoryViewModel
init(mode: PollHistoryMode, pollService: PollHistoryServiceProtocol) {
self.pollService = pollService
super.init(initialViewState: PollHistoryViewState(mode: mode))
state.canLoadMoreContent = pollService.hasNextBatch
}
// MARK: - Public
@@ -37,32 +38,47 @@ final class PollHistoryViewModel: PollHistoryViewModelType, PollHistoryViewModel
switch viewAction {
case .viewAppeared:
setupUpdateSubscriptions()
fetchFirstBatch()
fetchContent()
case .segmentDidChange:
updateViewState()
case .showPollDetail(let poll):
completion?(.showPollDetail(poll: poll))
case .loadMoreContent:
fetchContent()
}
}
}
private extension PollHistoryViewModel {
func fetchFirstBatch() {
func fetchContent() {
state.isLoading = true
pollService
.nextBatch()
.collect()
.sink { [weak self] _ in
#warning("Handle errors")
self?.state.isLoading = false
.sink { [weak self] completion in
self?.handleBatchEnded(completion: completion)
} receiveValue: { [weak self] polls in
self?.polls = polls
self?.updateViewState()
self?.add(polls: polls)
}
.store(in: &subcriptions)
}
func handleBatchEnded(completion: Subscribers.Completion<Error>) {
state.isLoading = false
state.canLoadMoreContent = pollService.hasNextBatch
switch completion {
case .finished:
break
case .failure:
polls = polls ?? []
state.bindings.alertInfo = .init(id: true, title: VectorL10n.pollHistoryFetchingError)
}
updateViewState()
}
func setupUpdateSubscriptions() {
subcriptions.removeAll()
@@ -75,9 +91,15 @@ private extension PollHistoryViewModel {
.store(in: &subcriptions)
pollService
.pollErrors
.sink { detail in
#warning("Handle errors")
.fetchedUpTo
.weakAssign(to: \.state.syncedUpTo, on: self)
.store(in: &subcriptions)
pollService
.livePolls
.sink { [weak self] livePoll in
self?.add(polls: [livePoll])
self?.updateViewState()
}
.store(in: &subcriptions)
}
@@ -90,6 +112,10 @@ private extension PollHistoryViewModel {
polls?[pollIndex] = poll
}
func add(polls: [TimelinePollDetails]) {
self.polls = (self.polls ?? []) + polls
}
func updateViewState() {
let renderedPolls: [TimelinePollDetails]?
@@ -106,17 +132,22 @@ private extension PollHistoryViewModel {
extension PollHistoryViewModel.Context {
var emptyPollsText: String {
let days = PollHistoryConstants.chunkSizeInDays
switch (viewState.bindings.mode, viewState.canLoadMoreContent) {
case (.active, true):
return VectorL10n.pollHistoryNoActivePollPeriodText("\(days)")
return VectorL10n.pollHistoryNoActivePollPeriodText("\(syncedPastDays)")
case (.active, false):
return VectorL10n.pollHistoryNoActivePollText
case (.past, true):
return VectorL10n.pollHistoryNoPastPollPeriodText("\(days)")
return VectorL10n.pollHistoryNoPastPollPeriodText("\(syncedPastDays)")
case (.past, false):
return VectorL10n.pollHistoryNoPastPollText
}
}
var syncedPastDays: Int {
guard let days = Calendar.current.dateComponents([.day], from: viewState.syncedUpTo, to: viewState.syncStartDate).day else {
return 0
}
return max(0, days)
}
}
@@ -22,58 +22,105 @@ final class PollHistoryService: PollHistoryServiceProtocol {
private let room: MXRoom
private let timeline: MXEventTimeline
private let chunkSizeInDays: UInt
private var timelineListener: Any?
private let updatesSubject: PassthroughSubject<TimelinePollDetails, Never> = .init()
private let pollErrorsSubject: PassthroughSubject<Error, Never> = .init()
private var timelineListener: Any?
private var roomListener: Any?
private var pollAggregators: [String: PollAggregator] = [:]
private var targetTimestamp: Date
private var oldestEventDate: Date = .distantFuture
// polls aggregation
private var pollAggregationContexts: [String: PollAggregationContext] = [:]
// polls
private var currentBatchSubject: PassthroughSubject<TimelinePollDetails, Error>?
private var livePollsSubject: PassthroughSubject<TimelinePollDetails, Never> = .init()
// polls updates
private let updatesSubject: PassthroughSubject<TimelinePollDetails, Never> = .init()
// timestamps
private var targetTimestamp: Date = .init()
private var oldestEventDateSubject: CurrentValueSubject<Date, Never> = .init(.init())
var updates: AnyPublisher<TimelinePollDetails, Never> {
updatesSubject.eraseToAnyPublisher()
}
var pollErrors: AnyPublisher<Error, Never> {
pollErrorsSubject.eraseToAnyPublisher()
}
init(room: MXRoom, chunkSizeInDays: UInt) {
self.room = room
self.chunkSizeInDays = chunkSizeInDays
timeline = MXRoomEventTimeline(room: room, andInitialEventId: nil)
targetTimestamp = Date().addingTimeInterval(-TimeInterval(chunkSizeInDays) * Constants.oneDayInSeconds)
setup(timeline: timeline)
setupTimeline()
setupLiveUpdates()
}
func nextBatch() -> AnyPublisher<TimelinePollDetails, Error> {
currentBatchSubject?.eraseToAnyPublisher() ?? startPagination()
}
var hasNextBatch: Bool {
timeline.canPaginate(.backwards)
}
var fetchedUpTo: AnyPublisher<Date, Never> {
oldestEventDateSubject.eraseToAnyPublisher()
}
var livePolls: AnyPublisher<TimelinePollDetails, Never> {
livePollsSubject.eraseToAnyPublisher()
}
deinit {
guard let roomListener = roomListener else {
return
}
room.removeListener(roomListener)
}
class PollAggregationContext {
var pollAggregator: PollAggregator?
let isLivePoll: Bool
var published: Bool
init(pollAggregator: PollAggregator? = nil, isLivePoll: Bool, published: Bool = false) {
self.pollAggregator = pollAggregator
self.isLivePoll = isLivePoll
self.published = published
}
}
}
private extension PollHistoryService {
enum Constants {
static let pageSize: UInt = 500
static let oneDayInSeconds: TimeInterval = 8.6 * 10e3
static let pageSize: UInt = 250
}
func setup(timeline: MXEventTimeline) {
func setupTimeline() {
timeline.resetPagination()
timelineListener = timeline.listenToEvents { [weak self] event, _, _ in
if event.eventType == .pollStart {
self?.aggregatePoll(pollStartEvent: event)
self?.aggregatePoll(pollStartEvent: event, isLivePoll: false)
}
self?.updateTimestamp(event: event)
}
}
func setupLiveUpdates() {
roomListener = room.listen(toEventsOfTypes: [kMXEventTypeStringPollStart, kMXEventTypeStringPollStartMSC3381]) { [weak self] event, _, _ in
if event.eventType == .pollStart {
self?.aggregatePoll(pollStartEvent: event, isLivePoll: true)
}
}
}
func updateTimestamp(event: MXEvent) {
oldestEventDate = min(event.originServerDate, oldestEventDate)
}
func startPagination() -> AnyPublisher<TimelinePollDetails, Error> {
let startingTimestamp = oldestEventDate
targetTimestamp = startingTimestamp.subtractingDays(chunkSizeInDays) ?? startingTimestamp
let batchSubject = PassthroughSubject<TimelinePollDetails, Error>()
currentBatchSubject = batchSubject
@@ -81,14 +128,13 @@ private extension PollHistoryService {
guard let self = self else {
return
}
self.timeline.resetPagination()
self.paginate(timeline: self.timeline)
self.paginate()
}
return batchSubject.eraseToAnyPublisher()
}
func paginate(timeline: MXEventTimeline) {
func paginate() {
timeline.paginate(Constants.pageSize, direction: .backwards, onlyFromStore: false) { [weak self] response in
guard let self = self else {
return
@@ -96,8 +142,8 @@ private extension PollHistoryService {
switch response {
case .success:
if timeline.canPaginate(.backwards), self.timestampTargetReached == false {
self.paginate(timeline: timeline)
if self.timeline.canPaginate(.backwards), self.timestampTargetReached == false {
self.paginate()
} else {
self.completeBatch(completion: .finished)
}
@@ -112,21 +158,41 @@ private extension PollHistoryService {
currentBatchSubject = nil
}
func aggregatePoll(pollStartEvent: MXEvent) {
guard pollAggregators[pollStartEvent.eventId] == nil else {
func aggregatePoll(pollStartEvent: MXEvent, isLivePoll: Bool) {
let eventId: String = pollStartEvent.eventId
guard pollAggregationContexts[eventId] == nil else {
return
}
guard let aggregator = try? PollAggregator(session: room.mxSession, room: room, pollEvent: pollStartEvent, delegate: self) else {
return
}
let newContext: PollAggregationContext = .init(isLivePoll: isLivePoll)
pollAggregationContexts[eventId] = newContext
pollAggregators[pollStartEvent.eventId] = aggregator
do {
newContext.pollAggregator = try PollAggregator(session: room.mxSession, room: room, pollEvent: pollStartEvent, delegate: self)
} catch {
pollAggregationContexts.removeValue(forKey: eventId)
}
}
var timestampTargetReached: Bool {
oldestEventDate <= targetTimestamp
}
var oldestEventDate: Date {
get {
oldestEventDateSubject.value
}
set {
oldestEventDateSubject.send(newValue)
}
}
}
private extension Date {
func subtractingDays(_ days: UInt) -> Date? {
Calendar.current.date(byAdding: DateComponents(day: -Int(days)), to: self)
}
}
private extension MXEvent {
@@ -138,17 +204,30 @@ private extension MXEvent {
// MARK: - PollAggregatorDelegate
extension PollHistoryService: PollAggregatorDelegate {
func pollAggregatorDidStartLoading(_ aggregator: PollAggregator) {}
func pollAggregatorDidStartLoading(_ aggregator: PollAggregator) { }
func pollAggregator(_ aggregator: PollAggregator, didFailWithError: Error) { }
func pollAggregatorDidEndLoading(_ aggregator: PollAggregator) {
currentBatchSubject?.send(.init(poll: aggregator.poll, represent: .started))
}
func pollAggregator(_ aggregator: PollAggregator, didFailWithError: Error) {
pollErrorsSubject.send(didFailWithError)
guard let context = pollAggregationContexts[aggregator.poll.id], context.published == false else {
return
}
context.published = true
let newPoll: TimelinePollDetails = .init(poll: aggregator.poll, represent: .started)
if context.isLivePoll {
livePollsSubject.send(newPoll)
} else {
currentBatchSubject?.send(newPoll)
}
}
func pollAggregatorDidUpdateData(_ aggregator: PollAggregator) {
guard let context = pollAggregationContexts[aggregator.poll.id], context.published else {
return
}
updatesSubject.send(.init(poll: aggregator.poll, represent: .started))
}
}
@@ -17,29 +17,38 @@
import Combine
final class MockPollHistoryService: PollHistoryServiceProtocol {
lazy var nextBatchPublishers: [AnyPublisher<TimelinePollDetails, Error>] = [
(activePollsData + pastPollsData)
.publisher
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
]
func nextBatch() -> AnyPublisher<TimelinePollDetails, Error> {
nextBatchPublishers.isEmpty ? Empty().eraseToAnyPublisher() : nextBatchPublishers.removeFirst()
}
var updatesPublisher: AnyPublisher<TimelinePollDetails, Never> = Empty().eraseToAnyPublisher()
var updates: AnyPublisher<TimelinePollDetails, Never> {
updatesPublisher
}
var pollErrorPublisher: AnyPublisher<Error, Never> = Empty().eraseToAnyPublisher()
var pollErrors: AnyPublisher<Error, Never> {
pollErrorPublisher
var hasNextBatch = true
var fetchedUpToPublisher: AnyPublisher<Date, Never> = Just(.init()).eraseToAnyPublisher()
var fetchedUpTo: AnyPublisher<Date, Never> {
fetchedUpToPublisher
}
lazy var nextBatchPublisher: AnyPublisher<TimelinePollDetails, Error> = (activePollsData + pastPollsData)
.publisher
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
func nextBatch() -> AnyPublisher<TimelinePollDetails, Error> {
nextBatchPublisher
var livePollsPublisher: AnyPublisher<TimelinePollDetails, Never> = Empty().eraseToAnyPublisher()
var livePolls: AnyPublisher<TimelinePollDetails, Never> {
livePollsPublisher
}
}
private extension MockPollHistoryService {
var activePollsData: [TimelinePollDetails] {
(1...10)
(1...3)
.map { index in
TimelinePollDetails(id: "a\(index)",
question: "Do you like the active poll number \(index)?",
@@ -56,7 +65,7 @@ private extension MockPollHistoryService {
}
var pastPollsData: [TimelinePollDetails] {
(1...10)
(1...3)
.map { index in
TimelinePollDetails(id: "p\(index)",
question: "Do you like the active poll number \(index)?",
@@ -21,10 +21,17 @@ protocol PollHistoryServiceProtocol {
/// Implementations should return the same publisher if `nextBatch()` is called again before the previous publisher completes.
func nextBatch() -> AnyPublisher<TimelinePollDetails, Error>
/// Publishes updates for the polls previously pusblished by the `nextBatch()` publishers.
/// Publishes updates for the polls previously pusblished by the `nextBatch()` or `livePolls` publishers.
var updates: AnyPublisher<TimelinePollDetails, Never> { get }
/// Publishes errors regarding poll aggregations.
/// Note: `nextBatch()` will continue to publish new polls even if some poll isn't being aggregated correctly.
var pollErrors: AnyPublisher<Error, Never> { get }
/// Publishes live polls not related with the current batch.
var livePolls: AnyPublisher<TimelinePollDetails, Never> { get }
/// Returns true every time the service can fetch another batch.
/// There is no guarantee the `nextBatch()` returned publisher will publish something anyway.
var hasNextBatch: Bool { get }
/// Publishes the date up to the service is synced (in the past).
/// This date doesn't need to be related with any poll event.
var fetchedUpTo: AnyPublisher<Date, Never> { get }
}
@@ -24,6 +24,7 @@ final class PollHistoryUITests: MockScreenTestCase {
let emptyText = app.staticTexts["PollHistory.emptyText"]
let items = app.staticTexts["PollListItem.title"]
let selectedSegment = app.buttons[VectorL10n.pollHistoryActiveSegmentTitle]
let loadMoreButton = app.buttons["PollHistory.loadMore"]
let winningOption = app.staticTexts["PollListData.winningOption"]
XCTAssertEqual(title, VectorL10n.pollHistoryTitle)
@@ -31,6 +32,7 @@ final class PollHistoryUITests: MockScreenTestCase {
XCTAssertFalse(emptyText.exists)
XCTAssertTrue(selectedSegment.exists)
XCTAssertEqual(selectedSegment.value as? String, VectorL10n.accessibilitySelected)
XCTAssertTrue(loadMoreButton.exists)
XCTAssertFalse(winningOption.exists)
}
@@ -40,6 +42,7 @@ final class PollHistoryUITests: MockScreenTestCase {
let emptyText = app.staticTexts["PollHistory.emptyText"]
let items = app.staticTexts["PollListItem.title"]
let selectedSegment = app.buttons[VectorL10n.pollHistoryPastSegmentTitle]
let loadMoreButton = app.buttons["PollHistory.loadMore"]
let winningOption = app.buttons["PollAnswerOption0"]
XCTAssertEqual(title, VectorL10n.pollHistoryTitle)
@@ -47,33 +50,66 @@ final class PollHistoryUITests: MockScreenTestCase {
XCTAssertFalse(emptyText.exists)
XCTAssertTrue(selectedSegment.exists)
XCTAssertEqual(selectedSegment.value as? String, VectorL10n.accessibilitySelected)
XCTAssertTrue(loadMoreButton.exists)
XCTAssertTrue(winningOption.exists)
}
func testPastPollHistoryIsEmpty() {
app.goToScreenWithIdentifier(MockPollHistoryScreenState.pastEmpty.title)
func testActivePollHistoryHasContentAndCantLoadMore() {
app.goToScreenWithIdentifier(MockPollHistoryScreenState.activeNoMoreContent.title)
let emptyText = app.staticTexts["PollHistory.emptyText"]
let items = app.staticTexts["PollListItem.title"]
let loadMoreButton = app.buttons["PollHistory.loadMore"]
XCTAssertTrue(items.exists)
XCTAssertFalse(emptyText.exists)
XCTAssertFalse(loadMoreButton.exists)
}
func testActivePollHistoryHasContentAndCanLoadMore() {
app.goToScreenWithIdentifier(MockPollHistoryScreenState.contentLoading.title)
let title = app.navigationBars.firstMatch.identifier
let emptyText = app.staticTexts["PollHistory.emptyText"]
let items = app.staticTexts["PollListItem.title"]
let selectedSegment = app.buttons[VectorL10n.pollHistoryPastSegmentTitle]
let winningOption = app.staticTexts["PollListData.winningOption"]
let loadMoreButton = app.buttons["PollHistory.loadMore"]
XCTAssertEqual(title, VectorL10n.pollHistoryTitle)
XCTAssertFalse(items.exists)
XCTAssertTrue(emptyText.exists)
XCTAssertTrue(selectedSegment.exists)
XCTAssertEqual(selectedSegment.value as? String, VectorL10n.accessibilitySelected)
XCTAssertFalse(winningOption.exists)
XCTAssertTrue(items.exists)
XCTAssertFalse(emptyText.exists)
XCTAssertTrue(loadMoreButton.exists)
XCTAssertFalse(loadMoreButton.isEnabled)
}
func testLoaderIsShowing() {
app.goToScreenWithIdentifier(MockPollHistoryScreenState.loading.title)
let title = app.navigationBars.firstMatch.identifier
let loadingText = app.staticTexts["PollHistory.loadingText"]
func testActivePollHistoryEmptyAndCanLoadMore() {
app.goToScreenWithIdentifier(MockPollHistoryScreenState.empty.title)
let emptyText = app.staticTexts["PollHistory.emptyText"]
let items = app.staticTexts["PollListItem.title"]
let loadMoreButton = app.buttons["PollHistory.loadMore"]
XCTAssertEqual(title, VectorL10n.pollHistoryTitle)
XCTAssertFalse(items.exists)
XCTAssertTrue(loadingText.exists)
XCTAssertTrue(emptyText.exists)
XCTAssertTrue(loadMoreButton.exists)
XCTAssertTrue(loadMoreButton.isEnabled)
}
func testActivePollHistoryEmptyAndLoading() {
app.goToScreenWithIdentifier(MockPollHistoryScreenState.emptyLoading.title)
let emptyText = app.staticTexts["PollHistory.emptyText"]
let items = app.staticTexts["PollListItem.title"]
let loadMoreButton = app.buttons["PollHistory.loadMore"]
XCTAssertFalse(items.exists)
XCTAssertTrue(emptyText.exists)
XCTAssertTrue(loadMoreButton.exists)
XCTAssertFalse(loadMoreButton.isEnabled)
}
func testActivePollHistoryEmptyAndCantLoadMore() {
app.goToScreenWithIdentifier(MockPollHistoryScreenState.emptyNoMoreContent.title)
let emptyText = app.staticTexts["PollHistory.emptyText"]
let items = app.staticTexts["PollListItem.title"]
let loadMoreButton = app.buttons["PollHistory.loadMore"]
XCTAssertFalse(items.exists)
XCTAssertTrue(emptyText.exists)
XCTAssertFalse(loadMoreButton.exists)
}
}
@@ -1,4 +1,4 @@
//
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
@@ -42,9 +42,11 @@ final class PollHistoryViewModelTests: XCTestCase {
func testLoadingStateIsTrueWhileLoading() {
XCTAssertFalse(viewModel.state.isLoading)
pollHistoryService.nextBatchPublisher = Empty(completeImmediately: false, outputType: TimelinePollDetails.self, failureType: Error.self).eraseToAnyPublisher()
pollHistoryService.nextBatchPublishers = [MockPollPublisher.loadingPolls, MockPollPublisher.emptyPolls]
viewModel.process(viewAction: .viewAppeared)
XCTAssertTrue(viewModel.state.isLoading)
viewModel.process(viewAction: .viewAppeared)
XCTAssertFalse(viewModel.state.isLoading)
}
func testUpdatesAreHandled() throws {
@@ -79,6 +81,35 @@ final class PollHistoryViewModelTests: XCTestCase {
let pollDates = try polls.map(\.startDate)
XCTAssertEqual(pollDates, pollDates.sorted(by: { $0 > $1 }))
}
func testLivePollsAreHandled() throws {
pollHistoryService.nextBatchPublishers = [MockPollPublisher.emptyPolls]
pollHistoryService.livePollsPublisher = Just(mockPoll).eraseToAnyPublisher()
viewModel.process(viewAction: .viewAppeared)
XCTAssertEqual(viewModel.state.polls?.count, 1)
XCTAssertEqual(viewModel.state.polls?.first?.id, "id")
}
func testLivePollsDontChangeLoadingState() throws {
let livePolls = PassthroughSubject<TimelinePollDetails, Never>()
pollHistoryService.nextBatchPublishers = [MockPollPublisher.loadingPolls]
pollHistoryService.livePollsPublisher = livePolls.eraseToAnyPublisher()
viewModel.process(viewAction: .viewAppeared)
XCTAssertTrue(viewModel.state.isLoading)
XCTAssertNil(viewModel.state.polls)
livePolls.send(mockPoll)
XCTAssertTrue(viewModel.state.isLoading)
XCTAssertNotNil(viewModel.state.polls)
XCTAssertEqual(viewModel.state.polls?.count, 1)
}
func testAfterFailureCompletionIsCalled() throws {
pollHistoryService.nextBatchPublishers = [MockPollPublisher.failure]
viewModel.process(viewAction: .viewAppeared)
XCTAssertFalse(viewModel.state.isLoading)
XCTAssertNotNil(viewModel.state.polls)
XCTAssertNotNil(viewModel.state.bindings.alertInfo)
}
}
private extension PollHistoryViewModelTests {
@@ -87,4 +118,18 @@ private extension PollHistoryViewModelTests {
try XCTUnwrap(viewModel.state.polls)
}
}
var mockPoll: TimelinePollDetails {
.init(id: "id",
question: "Do you like polls?",
answerOptions: [],
closed: false,
startDate: .init(),
totalAnswerCount: 3,
type: .undisclosed,
eventType: .started,
maxAllowedSelections: 1,
hasBeenEdited: false,
hasDecryptionError: false)
}
}
@@ -44,6 +44,9 @@ struct PollHistory: View {
.onChange(of: viewModel.mode) { _ in
viewModel.send(viewAction: .segmentDidChange)
}
.alert(item: $viewModel.alertInfo) {
$0.alert
}
}
@ViewBuilder
@@ -76,19 +79,23 @@ struct PollHistory: View {
}
}
@ViewBuilder
private var loadMoreButton: some View {
HStack(spacing: 8) {
if viewModel.viewState.isLoading {
spinner
if viewModel.viewState.canLoadMoreContent {
HStack(spacing: 8) {
if viewModel.viewState.isLoading {
spinner
}
Button {
viewModel.send(viewAction: .loadMoreContent)
} label: {
Text(VectorL10n.pollHistoryLoadMore)
.font(theme.fonts.body)
}
.accessibilityIdentifier("PollHistory.loadMore")
.disabled(viewModel.viewState.isLoading)
}
Button {
#warning("handle action in next ticket")
} label: {
Text(VectorL10n.pollHistoryLoadMore)
.font(theme.fonts.body)
}
.disabled(viewModel.viewState.isLoading)
}
}