Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import Foundation
import Testing
import WordPressKit
@testable import WordPress
@testable import WordPressData

@MainActor
@Suite("LikesListViewModel Tests")
struct LikesListViewModelTests {
private let contextManager = ContextManager.forTesting()
private let siteID = NSNumber(value: 1)
private let postID = NSNumber(value: 2)

// MARK: - First load

@Test("First load seeds the cached likes from Core Data")
func firstLoadSeedsCache() {
// Offline so the remote fetch is skipped and only the cache seed is observed.
makeNetworkUnavailable()
let cached = [makeUser(id: 10), makeUser(id: 11)]
let service = FakePostLikesService()
service.storedUsers = cached

let viewModel = LikesListViewModel(siteID: siteID, postID: postID, totalLikes: 2, service: service)
viewModel.loadMore()

#expect(viewModel.users.map { $0.userID } == [10, 11])
#expect(viewModel.error == nil)
#expect(service.getLikesCallCount == 0)
}

@Test("Offline with an empty cache surfaces the error state")
func offlineEmptyCacheShowsError() {
makeNetworkUnavailable()
let service = FakePostLikesService()

let viewModel = LikesListViewModel(siteID: siteID, postID: postID, totalLikes: 0, service: service)
viewModel.loadMore()

#expect(viewModel.users.isEmpty)
#expect(viewModel.error != nil)
#expect(viewModel.error?.subtitle == nil)
}

// MARK: - Pagination cursor

@Test("The second page sends the +1s before cursor and the excluded IDs")
func paginationCursor() {
makeNetworkAvailable()

let boundaryDate = Date(timeIntervalSince1970: 1_000_000)
let page1 = [makeUser(id: 20), makeUser(id: 21, dateLiked: boundaryDate)]
let page2 = [makeUser(id: 20), makeUser(id: 21, dateLiked: boundaryDate), makeUser(id: 22)]
let excluded = [makeUser(id: 21, dateLiked: boundaryDate)]

let service = FakePostLikesService()
service.totalLikes = 3
service.pages = [page1, page2]
service.storedUsersAfter = excluded

let viewModel = LikesListViewModel(siteID: siteID, postID: postID, totalLikes: 3, service: service)

// First page: no cursor, no exclusions, purges existing.
viewModel.loadMore()
#expect(service.getLikesCallCount == 1)
#expect(service.lastBefore == nil)
#expect(service.lastExcludingIDs == nil)
#expect(service.lastPurgeExisting == true)

// Second page: cursor is the boundary date + 1 second, plus the excluded IDs.
viewModel.loadMore()
#expect(service.getLikesCallCount == 2)
#expect(service.lastBefore == expectedBeforeString(from: boundaryDate))
#expect(service.lastExcludingIDs == [NSNumber(value: 21)])
#expect(service.lastPurgeExisting == false)
}

@Test("No further page is fetched once every like has been loaded")
func hasMoreGuardStopsPaging() {
makeNetworkAvailable()

let page = [makeUser(id: 30), makeUser(id: 31)]
let service = FakePostLikesService()
service.totalLikes = 2
service.pages = [page]

let viewModel = LikesListViewModel(siteID: siteID, postID: postID, totalLikes: 2, service: service)
viewModel.loadMore()
#expect(service.getLikesCallCount == 1)
#expect(viewModel.hasMoreLikes == false)

// Displaying the last row must not trigger another fetch.
viewModel.loadMoreIfNeeded(displaying: page[1])
#expect(service.getLikesCallCount == 1)
}

// MARK: - Error mapping

@Test("An authorization-required failure maps to the private-blog message")
func privateBlogErrorMapping() {
makeNetworkAvailable()

let service = FakePostLikesService()
service.failureError = NSError(
domain: WordPressComRestApiEndpointError.errorDomain,
code: WordPressComRestApiErrorCode.authorizationRequired.rawValue
)

let viewModel = LikesListViewModel(siteID: siteID, postID: postID, totalLikes: 0, service: service)
viewModel.loadMore()

#expect(viewModel.error != nil)
#expect(viewModel.error?.subtitle == "You don't have permission to view this private blog.")
}

@Test("A generic failure has no subtitle")
func genericErrorMapping() {
makeNetworkAvailable()

let service = FakePostLikesService()
service.failureError = NSError(domain: "test", code: 500)

let viewModel = LikesListViewModel(siteID: siteID, postID: postID, totalLikes: 0, service: service)
viewModel.loadMore()

#expect(viewModel.error != nil)
#expect(viewModel.error?.subtitle == nil)
}

// MARK: - Helpers

private func makeUser(id: Int64, dateLiked: Date = Date(timeIntervalSince1970: 0)) -> LikeUser {
let user = LikeUser(context: contextManager.mainContext)
user.userID = id
user.username = "user\(id)"
user.displayName = "User \(id)"
user.avatarUrl = ""
user.likedSiteID = siteID.int64Value
user.likedPostID = postID.int64Value
user.dateLiked = dateLiked
user.dateLikedString = "date-\(id)"
user.dateFetched = Date(timeIntervalSince1970: 0)
return user
}

/// Replicates the view model's cursor formatting: the boundary date bumped by one
/// second, formatted "YYYY-MM-DD HH:MM:SS" (no T/Z).
private func expectedBeforeString(from date: Date) -> String {
let bumped = Calendar.current.date(byAdding: .second, value: 1, to: date)!
return ISO8601DateFormatter()
.string(from: bumped)
.replacingOccurrences(of: "T", with: " ")
.replacingOccurrences(of: "Z", with: "")
}
}

/// A fake `PostLikesServing` that returns queued pages and records the pagination cursor.
/// Nonisolated to match the production `PostService`, which is not main-actor isolated.
private final class FakePostLikesService: PostLikesServing {
var storedUsers: [LikeUser] = []
var storedUsersAfter: [LikeUser] = []
var pages: [[LikeUser]] = []
var totalLikes = 0
var failureError: Error?

private(set) var getLikesCallCount = 0
private(set) var lastBefore: String?
private(set) var lastExcludingIDs: [NSNumber]?
private(set) var lastPurgeExisting: Bool?

func likeUsersFor(postID: NSNumber, siteID: NSNumber, after: Date?) -> [LikeUser] {
return after == nil ? storedUsers : storedUsersAfter
}

func getLikesFor(postID: NSNumber,
siteID: NSNumber,
count: Int,
before: String?,
excludingIDs: [NSNumber]?,
purgeExisting: Bool,
success: @escaping (([LikeUser], Int, Int) -> Void),
failure: @escaping ((Error?) -> Void)) {
getLikesCallCount += 1
lastBefore = before
lastExcludingIDs = excludingIDs
lastPurgeExisting = purgeExisting

if let failureError {
failure(failureError)
return
}

let users = pages.isEmpty ? [] : pages.removeFirst()
success(users, totalLikes, count)
}
}
65 changes: 65 additions & 0 deletions WordPress/Classes/ViewRelated/Likes/LikeUserRowView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import SwiftUI
import WordPressData
import WordPressUI

/// SwiftUI port of `LikeUserTableViewCell`: a 46pt circular avatar, the display name,
/// the `@username`, and a hairline bottom divider. Used in both the compact (single
/// column) and regular (multi-column) layouts of ``LikesListView``.
struct LikeUserRowView: View {
let user: LikeUser

/// Whether to draw the hairline bottom divider. The multi-column grid layout hides it
/// so cells don't carry stray separators; the single-column layout keeps it to match
/// the table it replaces.
var showsDivider = true

var body: some View {
VStack(spacing: 0) {
HStack(spacing: Metrics.avatarSpacing) {
// The avatar sizes itself (it scales with Dynamic Type via an internal
// @ScaledMetric), so it must not be pinned to a fixed frame here or it
// would overflow and overlap the text at large accessibility sizes.
AvatarView(
style: .single(URL(string: user.avatarUrl)),
diameter: Metrics.avatarDiameter,
placeholderImage: Image("gravatar").resizable()
)
.accessibilityHidden(true)

VStack(alignment: .leading, spacing: Metrics.labelSpacing) {
Text(user.displayName)
.font(.body)
.foregroundStyle(.primary)
Text(String(format: Strings.usernameFormat, user.username))
.font(.subheadline)
.foregroundStyle(.secondary)
}

Spacer(minLength: 0)
}
.padding(.horizontal, Metrics.horizontalPadding)
.padding(.vertical, Metrics.verticalPadding)

if showsDivider {
Divider()
.padding(.leading, Metrics.horizontalPadding)
}
}
}

private enum Metrics {
static let avatarDiameter: CGFloat = 46
static let avatarSpacing: CGFloat = 12
static let labelSpacing: CGFloat = 2
static let horizontalPadding: CGFloat = 20
static let verticalPadding: CGFloat = 12
}

private enum Strings {
static let usernameFormat = NSLocalizedString(
"@%1$@",
comment:
"Label displaying the user's username preceded by an '@' symbol. %1$@ is a placeholder for the username."
)
}
}
120 changes: 120 additions & 0 deletions WordPress/Classes/ViewRelated/Likes/LikesListHostViewController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import Combine
import SwiftUI
import UIKit
import WordPressData
import WordPressShared

/// Hosts the shared SwiftUI ``LikesListView`` for the Stats and Reader post-likes screens,
/// replacing the near-duplicate `StatsLikesListViewController` and
/// `ReaderDetailLikesListController`. It owns the navigation title, forwards the
/// per-feature analytics, and presents the user profile sheet on row taps.
final class LikesListHostViewController: UIHostingController<LikesListView> {

/// Per-feature analytics identifiers, preserving the events emitted by the previous hosts.
struct Configuration {
let likeListOpenedSource: String
let userProfileSheetShownSource: String
let blogUrlPreviewedSource: String

static let stats = Configuration(
likeListOpenedSource: "stats_post_details",
userProfileSheetShownSource: "stats_post_likes_list",
blogUrlPreviewedSource: "stats_post_likes_list_user_profile"
)

static let reader = Configuration(
likeListOpenedSource: "like_reader_list",
userProfileSheetShownSource: "like_reader_list",
blogUrlPreviewedSource: "reader_like_list_user_profile"
)
}

private let viewModel: LikesListViewModel
private let configuration: Configuration
private var cancellables = Set<AnyCancellable>()

// MARK: - Init

init(viewModel: LikesListViewModel, configuration: Configuration) {
self.viewModel = viewModel
self.configuration = configuration
super.init(rootView: LikesListView(viewModel: viewModel, onSelectUser: { _, _ in }))

// `self` cannot be captured before `super.init`, so wire the callback now.
rootView = LikesListView(viewModel: viewModel, onSelectUser: { [weak self] user, sourceRect in
self?.displayUserProfile(user, sourceRect: sourceRect)
})
}

/// Stats entry point.
convenience init(siteID: NSNumber, postID: NSNumber, totalLikes: Int) {
self.init(
viewModel: LikesListViewModel(siteID: siteID, postID: postID, totalLikes: totalLikes),
configuration: .stats
)
}

/// Reader entry point. Fails when the post lacks the IDs needed to fetch likes.
convenience init?(post: ReaderPost) {
guard let viewModel = LikesListViewModel(post: post) else {
return nil
}
self.init(viewModel: viewModel, configuration: .reader)
}

@MainActor required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

// MARK: - View

override func viewDidLoad() {
super.viewDidLoad()

// Keep the navigation title in sync with the total likes count.
viewModel.$totalLikes
.sink { [weak self] totalLikes in
self?.updateTitle(totalLikes: totalLikes)
}
.store(in: &cancellables)

WPAnalytics.track(.likeListOpened, properties: ["list_type": "post", "source": configuration.likeListOpenedSource])

viewModel.loadMore()
}

// MARK: - Helpers

private func updateTitle(totalLikes: Int) {
let titleFormat = totalLikes == 1 ? TitleFormats.singular : TitleFormats.plural
navigationItem.title = String(format: titleFormat, totalLikes)
}

private func displayUserProfile(_ user: LikeUser, sourceRect: CGRect) {
let userProfileVC = UserProfileSheetViewController(user: user)
userProfileVC.blogUrlPreviewedSource = configuration.blogUrlPreviewedSource
userProfileVC.modalPresentationStyle = .popover
userProfileVC.popoverPresentationController?.sourceView = view
// Anchor the popover to the tapped row on iPad. `sourceRect` arrives in global
// (window) coordinates; convert it into `view`'s space. On iPhone this adapts to a sheet.
if view.window != nil, sourceRect != .zero {
userProfileVC.popoverPresentationController?.sourceRect = view.convert(sourceRect, from: nil)
}
userProfileVC.popoverPresentationController?.adaptiveSheetPresentationController.prefersGrabberVisible = true
userProfileVC.popoverPresentationController?.adaptiveSheetPresentationController.detents = [.medium()]
present(userProfileVC, animated: true)

WPAnalytics.track(.userProfileSheetShown, properties: ["source": configuration.userProfileSheetShownSource])
}

private enum TitleFormats {
static let singular = NSLocalizedString(
"%1$d Like",
comment: "Singular format string for view title displaying the number of post likes. %1$d is the number of likes."
)
static let plural = NSLocalizedString(
"%1$d Likes",
comment: "Plural format string for view title displaying the number of post likes. %1$d is the number of likes."
)
}
}
Loading