Files
suixinkan_uikit/suixinkanTests/MessageCenterViewModelTests.swift
汉秋 caeeb9a1cf feat: 接入消息未读数、全部已读与首页红点角标同步。
固定消息中心为首页入口,并在推送到达、已读后刷新桌面角标。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 18:02:43 +08:00

247 lines
9.1 KiB
Swift

//
// MessageCenterViewModelTests.swift
// suixinkanTests
//
import Foundation
import XCTest
@testable import suixinkan
/// ViewModel
@MainActor
final class MessageCenterViewModelTests: XCTestCase {
func testInitialLoadAndLoadMoreUseLastIdUntilNoMore() async {
let api = MockMessageCenterAPI()
api.listResponses = [
MessageListResponse(hasMore: true, lastId: 8, items: [makeMessage(id: 1), makeMessage(id: 8)]),
MessageListResponse(hasMore: false, lastId: 0, items: [makeMessage(id: 9)]),
]
let viewModel = MessageCenterViewModel()
await viewModel.loadInitial(api: api)
await viewModel.loadMoreIfNeeded(lastVisibleIndex: 1, api: api)
await viewModel.loadMoreIfNeeded(lastVisibleIndex: 2, api: api)
XCTAssertEqual(api.listCalls.map(\.lastId), [0, 8])
XCTAssertEqual(api.listCalls.map(\.limit), [20, 20])
XCTAssertEqual(api.listCalls.map(\.unread), [0, 0])
XCTAssertEqual(viewModel.items.map(\.id), [1, 8, 9])
XCTAssertFalse(viewModel.canLoadMore)
}
func testUnreadSelectionMarksReadAndOpensDetail() async {
let api = MockMessageCenterAPI()
api.listResponses = [MessageListResponse(hasMore: false, lastId: 0, items: [makeMessage(id: 3, isRead: false)])]
api.readResponse = MessageUnreadCountResponse(unreadCount: 4)
let viewModel = MessageCenterViewModel()
var opened: MessageItem?
var callbackCount: Int?
viewModel.onOpenDetail = { opened = $0 }
viewModel.onUnreadMessageCountChange = { callbackCount = $0 }
let unreadChanged = expectation(
forNotification: NotificationName.unreadMessageCountDidChange,
object: nil,
handler: { notification in
notification.userInfo?[NotificationUserInfoKey.unreadCount] as? Int == 4
}
)
await viewModel.loadInitial(api: api)
await viewModel.selectMessage(id: 3, api: api)
await fulfillment(of: [unreadChanged], timeout: 1)
XCTAssertEqual(api.readCalls, [3])
XCTAssertEqual(opened?.id, 3)
XCTAssertEqual(opened?.isRead, true)
XCTAssertEqual(viewModel.items.first?.isRead, true)
XCTAssertEqual(callbackCount, 4)
}
func testMarkAllAsReadUpdatesItemsAndPublishesLatestCount() async {
let api = MockMessageCenterAPI()
api.listResponses = [MessageListResponse(items: [
makeMessage(id: 1, isRead: false),
makeMessage(id: 2, isRead: true),
])]
api.readAllResponse = MessageReadAllResponse(updatedCount: 1, unreadCount: 0)
let viewModel = MessageCenterViewModel()
var callbackCount: Int?
var message: String?
viewModel.onUnreadMessageCountChange = { callbackCount = $0 }
viewModel.onShowMessage = { message = $0 }
let unreadChanged = expectation(
forNotification: NotificationName.unreadMessageCountDidChange,
object: nil,
handler: { notification in
notification.userInfo?[NotificationUserInfoKey.unreadCount] as? Int == 0
}
)
await viewModel.loadInitial(api: api)
let response = await viewModel.markAllAsRead(api: api)
await fulfillment(of: [unreadChanged], timeout: 1)
XCTAssertEqual(api.readAllCallCount, 1)
XCTAssertEqual(response, MessageReadAllResponse(updatedCount: 1, unreadCount: 0))
XCTAssertTrue(viewModel.items.allSatisfy(\.isRead))
XCTAssertEqual(callbackCount, 0)
XCTAssertEqual(message, "已全部标记为已读")
}
func testMarkAllAsReadFailureKeepsUnreadState() async {
let api = MockMessageCenterAPI()
api.listResponses = [MessageListResponse(items: [makeMessage(id: 6, isRead: false)])]
api.readAllError = TestError(message: "fail")
let viewModel = MessageCenterViewModel()
var message: String?
viewModel.onShowMessage = { message = $0 }
await viewModel.loadInitial(api: api)
let response = await viewModel.markAllAsRead(api: api)
XCTAssertNil(response)
XCTAssertEqual(api.readAllCallCount, 1)
XCTAssertEqual(viewModel.items.first?.isRead, false)
XCTAssertEqual(message, "全部标记已读失败,请稍后重试")
}
func testReadSelectionOpensDetailWithoutCallingReadAPI() async {
let api = MockMessageCenterAPI()
api.listResponses = [MessageListResponse(hasMore: false, lastId: 0, items: [makeMessage(id: 4, isRead: true)])]
let viewModel = MessageCenterViewModel()
var opened: MessageItem?
viewModel.onOpenDetail = { opened = $0 }
await viewModel.loadInitial(api: api)
await viewModel.selectMessage(id: 4, api: api)
XCTAssertTrue(api.readCalls.isEmpty)
XCTAssertEqual(opened?.id, 4)
}
func testUnreadSelectionFailureShowsMessageAndDoesNotOpenDetail() async {
let api = MockMessageCenterAPI()
api.listResponses = [MessageListResponse(hasMore: false, lastId: 0, items: [makeMessage(id: 5, isRead: false)])]
api.readError = TestError(message: "fail")
let viewModel = MessageCenterViewModel()
var message: String?
var didOpen = false
viewModel.onShowMessage = { message = $0 }
viewModel.onOpenDetail = { _ in didOpen = true }
await viewModel.loadInitial(api: api)
await viewModel.selectMessage(id: 5, api: api)
XCTAssertEqual(message, "标记已读失败")
XCTAssertFalse(didOpen)
XCTAssertEqual(viewModel.items.first?.isRead, false)
}
func testDeleteSuccessRemovesMessageAndReportsDeletedId() async {
let api = MockMessageCenterAPI()
let listViewModel = MessageCenterViewModel()
api.listResponses = [MessageListResponse(items: [makeMessage(id: 7)])]
await listViewModel.loadInitial(api: api)
let detailViewModel = MessageDetailViewModel(message: makeMessage(id: 7))
var deletedId: Int?
var message: String?
detailViewModel.onDeleted = { deletedId = $0 }
detailViewModel.onShowMessage = { message = $0 }
await detailViewModel.delete(api: api)
listViewModel.removeMessage(id: deletedId ?? 0)
XCTAssertEqual(api.deleteCalls, [7])
XCTAssertEqual(deletedId, 7)
XCTAssertEqual(message, "删除成功")
XCTAssertTrue(listViewModel.items.isEmpty)
}
func testDeleteInvalidIdAndFailureMessages() async {
let api = MockMessageCenterAPI()
let invalidViewModel = MessageDetailViewModel(message: makeMessage(id: 0))
var invalidMessage: String?
invalidViewModel.onShowMessage = { invalidMessage = $0 }
await invalidViewModel.delete(api: api)
XCTAssertEqual(invalidMessage, "消息ID无效")
XCTAssertTrue(api.deleteCalls.isEmpty)
api.deleteError = TestError(message: "server down")
let failingViewModel = MessageDetailViewModel(message: makeMessage(id: 9))
var failureMessage: String?
failingViewModel.onShowMessage = { failureMessage = $0 }
await failingViewModel.delete(api: api)
XCTAssertEqual(failureMessage, "删除失败: server down")
}
}
@MainActor
private final class MockMessageCenterAPI: MessageCenterServing {
var listResponses: [MessageListResponse] = []
var listCalls: [(lastId: Int, limit: Int, unread: Int)] = []
var readCalls: [Int] = []
var readResponse = MessageUnreadCountResponse()
var readAllResponse = MessageReadAllResponse()
var readAllCallCount = 0
var deleteCalls: [Int] = []
var readError: Error?
var readAllError: Error?
var deleteError: Error?
func list(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse {
listCalls.append((lastId, limit, unread))
guard !listResponses.isEmpty else { return MessageListResponse() }
return listResponses.removeFirst()
}
func unreadCount() async throws -> MessageUnreadCountResponse {
MessageUnreadCountResponse()
}
func markAsRead(messageId: Int) async throws -> MessageUnreadCountResponse {
readCalls.append(messageId)
if let readError { throw readError }
return readResponse
}
func markAllAsRead() async throws -> MessageReadAllResponse {
readAllCallCount += 1
if let readAllError { throw readAllError }
return readAllResponse
}
func delete(messageId: Int) async throws {
deleteCalls.append(messageId)
if let deleteError { throw deleteError }
}
}
private struct TestError: LocalizedError {
let message: String
var errorDescription: String? { message }
}
private func makeMessage(id: Int, isRead: Bool = false) -> MessageItem {
MessageItem(
id: id,
receiverId: 1,
receiverType: "staff",
type: 1,
typeName: "系统通知",
title: "系统通知",
content: "消息内容",
pushAt: "",
isRead: isRead,
readAt: "",
pushChannel: 1,
pushChannelName: "站内信",
createdAt: "2026-07-09 10:00:00",
updatedAt: "2026-07-09 10:00:00"
)
}