// // 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)])] let viewModel = MessageCenterViewModel() var opened: MessageItem? viewModel.onOpenDetail = { opened = $0 } await viewModel.loadInitial(api: api) await viewModel.selectMessage(id: 3, api: api) XCTAssertEqual(api.readCalls, [3]) XCTAssertEqual(opened?.id, 3) XCTAssertEqual(opened?.isRead, true) XCTAssertEqual(viewModel.items.first?.isRead, true) } 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 deleteCalls: [Int] = [] var readError: 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 markAsRead(messageId: Int) async throws { readCalls.append(messageId) if let readError { throw readError } } 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" ) }