feat: 接入消息未读数、全部已读与首页红点角标同步。
固定消息中心为首页入口,并在推送到达、已读后刷新桌面角标。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -29,7 +29,7 @@ final class AllFunctionsViewModelTests: XCTestCase {
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testLoadFunctionsUsesDefaultFirstFourWhenNoSavedURIs() {
|
||||
func testLoadFunctionsUsesDefaultFirstFourAndExcludesMessageCenter() {
|
||||
appStore.permissions.savePermissionItems([
|
||||
HomePermissionItem(id: "1", name: "钱包", uri: "wallet"),
|
||||
HomePermissionItem(id: "2", name: "云盘", uri: "cloud_management"),
|
||||
@ -44,7 +44,7 @@ final class AllFunctionsViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(viewModel.commonMenus.map(\.uri), [
|
||||
"wallet", "cloud_management", "task_management", "system_settings",
|
||||
])
|
||||
XCTAssertEqual(viewModel.moreMenus.map(\.uri), ["message_center"])
|
||||
XCTAssertTrue(viewModel.moreMenus.isEmpty)
|
||||
XCTAssertEqual(
|
||||
menuStore.savedCommonURIs(
|
||||
accountScope: appStore.session.accountCachePrefix,
|
||||
|
||||
@ -23,14 +23,26 @@ final class HomeCommonMenuStoreTests: XCTestCase {
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testDefaultCommonURIsTakeFirstFour() {
|
||||
let permissions = (1...6).map {
|
||||
func testDefaultCommonURIsTakeFirstFourAndExcludeMessageCenter() {
|
||||
let permissions = [
|
||||
HomePermissionItem(id: "0", name: "消息", uri: "message_center"),
|
||||
] + (1...5).map {
|
||||
HomePermissionItem(id: "\($0)", name: "菜单\($0)", uri: "uri_\($0)")
|
||||
}
|
||||
let uris = HomeCommonMenuStore.defaultCommonURIs(from: permissions)
|
||||
XCTAssertEqual(uris, ["uri_1", "uri_2", "uri_3", "uri_4"])
|
||||
}
|
||||
|
||||
func testSavedMessageCenterIsRemovedFromLegacyCommonURIs() {
|
||||
let key = "scenic_user_u1_role_photographer_common_uris"
|
||||
defaults.set(["message_center", "wallet"], forKey: key)
|
||||
|
||||
let uris = store.savedCommonURIs(accountScope: "scenic_user_u1", roleCode: "photographer")
|
||||
|
||||
XCTAssertEqual(uris, ["wallet"])
|
||||
XCTAssertEqual(defaults.stringArray(forKey: key), ["wallet"])
|
||||
}
|
||||
|
||||
func testSavedCommonURIsOverrideDefault() {
|
||||
let permissions = [
|
||||
HomePermissionItem(id: "1", name: "钱包", uri: "wallet"),
|
||||
@ -65,8 +77,12 @@ final class HomeCommonMenuStoreTests: XCTestCase {
|
||||
HomeMenuItem(uri: "wallet", title: "钱包", iconName: "wallet.pass"),
|
||||
HomeMenuItem(uri: "cloud_management", title: "云盘", iconName: "icloud"),
|
||||
HomeMenuItem(uri: "task_management", title: "任务", iconName: "checklist"),
|
||||
HomeMenuItem(uri: "message_center", title: "消息中心", iconName: "bell"),
|
||||
]
|
||||
let split = HomeCommonMenuStore.splitMenus(from: menus, commonURIs: ["wallet", "task_management"])
|
||||
let split = HomeCommonMenuStore.splitMenus(
|
||||
from: menus,
|
||||
commonURIs: ["wallet", "task_management", "message_center"]
|
||||
)
|
||||
XCTAssertEqual(split.common.map(\.uri), ["wallet", "task_management"])
|
||||
XCTAssertEqual(split.more.map(\.uri), ["cloud_management"])
|
||||
}
|
||||
@ -75,7 +91,7 @@ final class HomeCommonMenuStoreTests: XCTestCase {
|
||||
let item = HomeMenuCatalog.item(for: "report_photographer")
|
||||
|
||||
XCTAssertEqual(item?.title, "举报摄影师")
|
||||
XCTAssertEqual(item?.iconName, "shield.fill")
|
||||
XCTAssertEqual(item?.iconName, "shield.lefthalf.filled")
|
||||
}
|
||||
|
||||
func testHomeMenuCatalogUsesIOS16CompatibleIcons() {
|
||||
|
||||
@ -90,10 +90,10 @@ final class HomeViewModelTests: XCTestCase {
|
||||
let menuStore = HomeCommonMenuStore(defaults: defaults)
|
||||
appStore.permissions.savePermissionItems([
|
||||
HomePermissionItem(id: "1", name: "钱包", uri: "wallet"),
|
||||
HomePermissionItem(id: "2", name: "云盘", uri: "cloud_management"),
|
||||
HomePermissionItem(id: "3", name: "任务", uri: "task_management"),
|
||||
HomePermissionItem(id: "4", name: "设置", uri: "system_settings"),
|
||||
HomePermissionItem(id: "5", name: "消息", uri: "message_center"),
|
||||
HomePermissionItem(id: "2", name: "消息", uri: "message_center"),
|
||||
HomePermissionItem(id: "3", name: "云盘", uri: "cloud_management"),
|
||||
HomePermissionItem(id: "4", name: "任务", uri: "task_management"),
|
||||
HomePermissionItem(id: "5", name: "设置", uri: "system_settings"),
|
||||
])
|
||||
let viewModel = HomeViewModel(appStore: appStore, commonMenuStore: menuStore)
|
||||
|
||||
@ -231,6 +231,50 @@ final class HomeViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(session.requests.count, 1)
|
||||
}
|
||||
|
||||
func testUnreadMessageStatusUsesCountEndpointAndUpdatesBadgeState() async {
|
||||
let api = HomeMessageCenterAPIMock()
|
||||
api.unreadCountResponses = [
|
||||
MessageUnreadCountResponse(unreadCount: 9),
|
||||
MessageUnreadCountResponse(unreadCount: 0),
|
||||
]
|
||||
let viewModel = HomeViewModel(appStore: appStore)
|
||||
|
||||
await viewModel.refreshUnreadMessageStatus(api: api)
|
||||
XCTAssertTrue(viewModel.hasUnreadMessages)
|
||||
XCTAssertEqual(viewModel.unreadMessageCount, 9)
|
||||
|
||||
await viewModel.refreshUnreadMessageStatus(api: api)
|
||||
XCTAssertFalse(viewModel.hasUnreadMessages)
|
||||
XCTAssertEqual(viewModel.unreadMessageCount, 0)
|
||||
XCTAssertEqual(api.unreadCountCallCount, 2)
|
||||
}
|
||||
|
||||
func testScenicHeaderMessageButtonShowsUnreadStateAndHandlesTap() throws {
|
||||
let headerView = HomeScenicHeaderView(frame: CGRect(x: 0, y: 0, width: 390, height: 100))
|
||||
var tapCount = 0
|
||||
headerView.onMessageTap = { tapCount += 1 }
|
||||
|
||||
headerView.apply(scenicName: "测试景区", hasUnreadMessages: true)
|
||||
headerView.layoutIfNeeded()
|
||||
|
||||
let button = try XCTUnwrap(
|
||||
findSubview(in: headerView) { $0.accessibilityIdentifier == "home_message_button" } as? UIButton
|
||||
)
|
||||
let unreadDot = try XCTUnwrap(
|
||||
findSubview(in: headerView) { $0.accessibilityIdentifier == "home_message_unread_dot" }
|
||||
)
|
||||
XCTAssertEqual(button.bounds.size, CGSize(width: 44, height: 44))
|
||||
XCTAssertEqual(button.accessibilityLabel, "消息中心,有未读消息")
|
||||
XCTAssertFalse(unreadDot.isHidden)
|
||||
|
||||
button.sendActions(for: .touchUpInside)
|
||||
XCTAssertEqual(tapCount, 1)
|
||||
|
||||
headerView.apply(scenicName: "测试景区", hasUnreadMessages: false)
|
||||
XCTAssertEqual(button.accessibilityLabel, "消息中心")
|
||||
XCTAssertTrue(unreadDot.isHidden)
|
||||
}
|
||||
|
||||
func testRefreshLocalDisplayStateClearsWhenNameMissing() {
|
||||
appStore.session.currentScenicId = 10
|
||||
appStore.session.currentScenicName = ""
|
||||
@ -283,6 +327,45 @@ final class HomeViewModelTests: XCTestCase {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func findSubview(in view: UIView, matching predicate: (UIView) -> Bool) -> UIView? {
|
||||
if predicate(view) {
|
||||
return view
|
||||
}
|
||||
for subview in view.subviews {
|
||||
if let match = findSubview(in: subview, matching: predicate) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 首页未读消息查询替身。
|
||||
@MainActor
|
||||
private final class HomeMessageCenterAPIMock: MessageCenterServing {
|
||||
var unreadCountResponses: [MessageUnreadCountResponse] = []
|
||||
var unreadCountCallCount = 0
|
||||
|
||||
func list(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse {
|
||||
MessageListResponse()
|
||||
}
|
||||
|
||||
func unreadCount() async throws -> MessageUnreadCountResponse {
|
||||
unreadCountCallCount += 1
|
||||
guard !unreadCountResponses.isEmpty else { return MessageUnreadCountResponse() }
|
||||
return unreadCountResponses.removeFirst()
|
||||
}
|
||||
|
||||
func markAsRead(messageId: Int) async throws -> MessageUnreadCountResponse {
|
||||
MessageUnreadCountResponse()
|
||||
}
|
||||
|
||||
func markAllAsRead() async throws -> MessageReadAllResponse {
|
||||
MessageReadAllResponse()
|
||||
}
|
||||
|
||||
func delete(messageId: Int) async throws {}
|
||||
}
|
||||
|
||||
private extension UIColor {
|
||||
|
||||
@ -53,21 +53,37 @@ final class MessageCenterAPITests: XCTestCase {
|
||||
XCTAssertEqual(response.items.first?.extraData?["order_no"], .string("NO123"))
|
||||
}
|
||||
|
||||
func testReadAndDeleteUseAndroidPathsAndBody() async throws {
|
||||
let session = MockURLSession(responses: [envelopeJSON("{}"), envelopeJSON("{}")])
|
||||
func testUnreadCountReadAllReadAndDeleteUseExpectedContracts() async throws {
|
||||
let session = MockURLSession(responses: [
|
||||
envelopeJSON(#"{"unread_count":"12"}"#),
|
||||
envelopeJSON(#"{"unread_count":11}"#),
|
||||
envelopeJSON(#"{"updated_count":11,"unread_count":0}"#),
|
||||
envelopeJSON("{}"),
|
||||
])
|
||||
let api = MessageCenterAPI(client: APIClient(environment: .testing, session: session))
|
||||
|
||||
try await api.markAsRead(messageId: 21)
|
||||
let unread = try await api.unreadCount()
|
||||
let read = try await api.markAsRead(messageId: 21)
|
||||
let readAll = try await api.markAllAsRead()
|
||||
try await api.delete(messageId: 21)
|
||||
|
||||
XCTAssertEqual(session.requests[0].httpMethod, "POST")
|
||||
XCTAssertEqual(session.requests[0].url?.path, "/api/app/msg/read")
|
||||
let readQuery = URLComponents(url: try XCTUnwrap(session.requests[0].url), resolvingAgainstBaseURL: false)?.queryItems
|
||||
XCTAssertEqual(unread.unreadCount, 12)
|
||||
XCTAssertEqual(session.requests[0].httpMethod, "GET")
|
||||
XCTAssertEqual(session.requests[0].url?.path, "/api/app/msg/unread-count")
|
||||
|
||||
XCTAssertEqual(read.unreadCount, 11)
|
||||
XCTAssertEqual(session.requests[1].httpMethod, "POST")
|
||||
XCTAssertEqual(session.requests[1].url?.path, "/api/app/msg/read")
|
||||
let readQuery = URLComponents(url: try XCTUnwrap(session.requests[1].url), resolvingAgainstBaseURL: false)?.queryItems
|
||||
XCTAssertEqual(readQuery?.first { $0.name == "id" }?.value, "21")
|
||||
|
||||
XCTAssertEqual(session.requests[1].httpMethod, "POST")
|
||||
XCTAssertEqual(session.requests[1].url?.path, "/api/app/msg/delete")
|
||||
let deleteBody = try bodyJSON(session.requests[1])
|
||||
XCTAssertEqual(readAll, MessageReadAllResponse(updatedCount: 11, unreadCount: 0))
|
||||
XCTAssertEqual(session.requests[2].httpMethod, "POST")
|
||||
XCTAssertEqual(session.requests[2].url?.path, "/api/app/msg/read-all")
|
||||
|
||||
XCTAssertEqual(session.requests[3].httpMethod, "POST")
|
||||
XCTAssertEqual(session.requests[3].url?.path, "/api/app/msg/delete")
|
||||
let deleteBody = try bodyJSON(session.requests[3])
|
||||
XCTAssertEqual(deleteBody["id"] as? Int, 21)
|
||||
}
|
||||
|
||||
|
||||
@ -32,17 +32,77 @@ final class MessageCenterViewModelTests: XCTestCase {
|
||||
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 {
|
||||
@ -125,8 +185,12 @@ 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 {
|
||||
@ -135,9 +199,20 @@ private final class MockMessageCenterAPI: MessageCenterServing {
|
||||
return listResponses.removeFirst()
|
||||
}
|
||||
|
||||
func markAsRead(messageId: Int) async throws {
|
||||
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 {
|
||||
|
||||
@ -158,6 +158,28 @@ final class PushNotificationTests: XCTestCase {
|
||||
XCTAssertEqual(router.destinations, [.task])
|
||||
}
|
||||
|
||||
func testRemoteNotificationNotifiesUnreadMessageStateChange() async {
|
||||
let manager = makeManager()
|
||||
let unreadChanged = expectation(
|
||||
forNotification: NotificationName.unreadMessageCountDidChange,
|
||||
object: nil
|
||||
)
|
||||
|
||||
manager.handleRemoteNotification(["route": "message_center"])
|
||||
|
||||
await fulfillment(of: [unreadChanged], timeout: 1)
|
||||
}
|
||||
|
||||
func testApplicationIconBadgeUsesLatestNonnegativeCount() async {
|
||||
let badgeSetter = ApplicationIconBadgeSetterMock()
|
||||
let manager = makeManager(badgeSetter: badgeSetter)
|
||||
|
||||
await manager.updateApplicationIconBadgeCount(12)
|
||||
await manager.updateApplicationIconBadgeCount(-2)
|
||||
|
||||
XCTAssertEqual(badgeSetter.counts, [12, 0])
|
||||
}
|
||||
|
||||
func testRouteCoordinatorWaitsForLoginAndDeduplicatesColdStartResponse() {
|
||||
let coordinator = PushRouteCoordinator(appStore: appStore)
|
||||
let window = UIWindow(frame: UIScreen.main.bounds)
|
||||
@ -189,7 +211,8 @@ final class PushNotificationTests: XCTestCase {
|
||||
private func makeManager(
|
||||
sdk: PushSDKMock? = nil,
|
||||
api: PushRegistrationAPIMock? = nil,
|
||||
router: PushRouterMock? = nil
|
||||
router: PushRouterMock? = nil,
|
||||
badgeSetter: ApplicationIconBadgeSetterMock? = nil
|
||||
) -> PushNotificationManager {
|
||||
let resolvedSDK: PushSDKMock
|
||||
if let sdk {
|
||||
@ -217,7 +240,8 @@ final class PushNotificationTests: XCTestCase {
|
||||
api: resolvedAPI,
|
||||
appStore: appStore,
|
||||
defaults: defaults,
|
||||
router: resolvedRouter
|
||||
router: resolvedRouter,
|
||||
applicationIconBadgeSetter: badgeSetter ?? ApplicationIconBadgeSetterMock()
|
||||
)
|
||||
}
|
||||
|
||||
@ -233,6 +257,16 @@ final class PushNotificationTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录 App 桌面图标角标更新的测试替身。
|
||||
@MainActor
|
||||
private final class ApplicationIconBadgeSetterMock: ApplicationIconBadgeSetting {
|
||||
private(set) var counts: [Int] = []
|
||||
|
||||
func setBadgeCount(_ count: Int) async {
|
||||
counts.append(count)
|
||||
}
|
||||
}
|
||||
|
||||
/// 可控的极光 SDK 测试替身,记录初始化与通知授权行为。
|
||||
@MainActor
|
||||
private final class PushSDKMock: PushSDKProviding {
|
||||
|
||||
Reference in New Issue
Block a user