feat: 接入消息未读数、全部已读与首页红点角标同步。

固定消息中心为首页入口,并在推送到达、已读后刷新桌面角标。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-21 18:02:43 +08:00
parent e7f1d777dd
commit caeeb9a1cf
18 changed files with 748 additions and 37 deletions

View File

@ -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,

View File

@ -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() {

View File

@ -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 {

View File

@ -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)
}

View File

@ -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 {

View File

@ -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 {