// // MessageCenterViewModelTests.swift // suixinkanTests // // Created by Codex on 2026/6/25. // import XCTest @testable import suixinkan_ios @MainActor /// 消息中心测试,覆盖分页、筛选、已读和删除。 final class MessageCenterViewModelTests: XCTestCase { func testReloadFirstPageMapsMessagesAndPaginationState() async { let api = MessageCenterMock() api.listResponses = [ MessageListResponse(hasMore: true, lastId: 101, items: [ MessageEntity(id: 101, type: 1, typeName: "", title: "新订单提醒", content: "有新订单", pushAt: "2026-06-25 10:00", isRead: false), MessageEntity(id: 100, type: 2, typeName: "核销", title: "", content: "核销完成", pushAt: "2026-06-25 09:00", isRead: true) ]) ] let viewModel = MessageCenterViewModel() viewModel.selectedFilter = .unread await viewModel.reloadFirstPage(api: api) XCTAssertEqual(api.listRequests.map(\.unread), [1]) XCTAssertEqual(viewModel.messages.map(\.id), ["msg_101", "msg_100"]) XCTAssertEqual(viewModel.messages[0].title, "新订单提醒") XCTAssertEqual(viewModel.messages[0].type, .order) XCTAssertEqual(viewModel.messages[1].title, "核销") XCTAssertEqual(viewModel.messages[1].type, .writeOff) XCTAssertEqual(viewModel.unreadCount, 1) XCTAssertTrue(viewModel.hasMoreMessages) XCTAssertEqual(viewModel.lastId, 101) } func testLoadMoreDeduplicatesSortsAndStopsAtTail() async { let api = MessageCenterMock() api.listResponses = [ MessageListResponse(hasMore: true, lastId: 101, items: [ MessageEntity(id: 101, type: 1, title: "A", content: "A", pushAt: "2026-06-25 10:00"), MessageEntity(id: 100, type: 3, title: "B", content: "B", pushAt: "2026-06-25 09:00") ]), MessageListResponse(hasMore: false, lastId: 99, items: [ MessageEntity(id: 100, type: 3, title: "B2", content: "B2", pushAt: "2026-06-25 09:00"), MessageEntity(id: 99, type: 0, title: "C", content: "C", pushAt: "2026-06-24 08:00") ]) ] let viewModel = MessageCenterViewModel() await viewModel.reloadFirstPage(api: api) await viewModel.loadMore(api: api) await viewModel.loadMore(api: api) XCTAssertEqual(api.listRequests.map(\.lastId), [0, 101]) XCTAssertEqual(viewModel.messages.map(\.id), ["msg_101", "msg_100", "msg_99"]) XCTAssertEqual(viewModel.messages[1].title, "B2") XCTAssertFalse(viewModel.hasMoreMessages) XCTAssertEqual(viewModel.lastId, 99) } func testReloadFailureClearsStaleMessagesAndPaging() async { let api = MessageCenterMock() api.listResponses = [ MessageListResponse(hasMore: true, lastId: 101, items: [ MessageEntity(id: 101, type: 1, title: "A") ]) ] let viewModel = MessageCenterViewModel() await viewModel.reloadFirstPage(api: api) api.listError = TestError.sample await viewModel.reloadFirstPage(api: api) XCTAssertTrue(viewModel.messages.isEmpty) XCTAssertFalse(viewModel.hasMoreMessages) XCTAssertEqual(viewModel.lastId, 0) XCTAssertTrue(viewModel.loadFailed) } func testMarkReadAllReadAndDeleteUpdateOnlyAfterSuccess() async throws { let api = MessageCenterMock() api.listResponses = [ MessageListResponse(items: [ MessageEntity(id: 101, type: 1, title: "A", isRead: false), MessageEntity(id: 100, type: 0, title: "B", isRead: false) ]) ] let viewModel = MessageCenterViewModel() await viewModel.reloadFirstPage(api: api) try await viewModel.markAsRead(api: api, item: try XCTUnwrap(viewModel.messages.first)) XCTAssertEqual(api.readIds, [101]) XCTAssertEqual(viewModel.messages.map(\.isRead), [true, false]) api.readIds = [] try await viewModel.markAllAsRead(api: api) XCTAssertEqual(api.readIds, [100]) XCTAssertTrue(viewModel.messages.allSatisfy(\.isRead)) let item = try XCTUnwrap(viewModel.messages.first) try await viewModel.deleteMessage(api: api, item: item) XCTAssertEqual(api.deleteIds, [101]) XCTAssertFalse(viewModel.messages.contains(where: { $0.id == item.id })) } func testFailedReadAndDeleteKeepLocalState() async throws { let api = MessageCenterMock() api.listResponses = [MessageListResponse(items: [MessageEntity(id: 101, type: 1, title: "A", isRead: false)])] let viewModel = MessageCenterViewModel() await viewModel.reloadFirstPage(api: api) let item = try XCTUnwrap(viewModel.messages.first) api.readError = TestError.sample await XCTAssertThrowsErrorAsync(try await viewModel.markAsRead(api: api, item: item)) XCTAssertFalse(try XCTUnwrap(viewModel.messages.first).isRead) api.readError = nil api.deleteError = TestError.sample await XCTAssertThrowsErrorAsync(try await viewModel.deleteMessage(api: api, item: item)) XCTAssertEqual(viewModel.messages.map(\.id), [item.id]) } } @MainActor private final class MessageCenterMock: MessageCenterServing { var listResponses: [MessageListResponse] = [] var listError: Error? var readError: Error? var deleteError: Error? var readIds: [Int] = [] var deleteIds: [Int] = [] private(set) var listRequests: [(lastId: Int, limit: Int, unread: Int)] = [] func messageList(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse { listRequests.append((lastId, limit, unread)) if let listError { throw listError } return listResponses.isEmpty ? MessageListResponse() : listResponses.removeFirst() } func messageRead(id: Int) async throws { if let readError { throw readError } readIds.append(id) } func messageDelete(id: Int) async throws { if let deleteError { throw deleteError } deleteIds.append(id) } } private enum TestError: LocalizedError { case sample var errorDescription: String? { "测试错误" } } private func XCTAssertThrowsErrorAsync( _ expression: @autoclosure () async throws -> Void, file: StaticString = #filePath, line: UInt = #line ) async { do { try await expression() XCTFail("Expected error", file: file, line: line) } catch {} }