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

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

385 lines
16 KiB
Swift

//
// HomeViewModelTests.swift
// suixinkanTests
//
import UIKit
import XCTest
@testable import suixinkan
@MainActor
/// ViewModel
final class HomeViewModelTests: XCTestCase {
private var appStore: AppStore!
private var defaults: UserDefaults!
override func setUp() {
defaults = UserDefaults(suiteName: "HomeViewModelTests")!
defaults.removePersistentDomain(forName: "HomeViewModelTests")
appStore = AppStore(defaults: defaults)
appStore.session.userId = "2001"
appStore.session.accountType = .scenicUser
appStore.session.roleCode = AppRoleCode.photographer.rawValue
appStore.session.roleName = "摄影师"
appStore.session.currentScenicId = 10
appStore.session.currentScenicName = "测试景区"
}
override func tearDown() {
appStore.logout()
super.tearDown()
}
func testMinimalTopRoleFlag() {
appStore.session.roleCode = AppRoleCode.storeAdmin.rawValue
let viewModel = HomeViewModel(appStore: appStore)
viewModel.refreshLocalDisplayState()
XCTAssertTrue(viewModel.isMinimalTopRole)
appStore.session.roleCode = AppRoleCode.photographer.rawValue
viewModel.refreshLocalDisplayState()
XCTAssertFalse(viewModel.isMinimalTopRole)
}
func testLocationReportPromptDefaultsToFixedHomeCopy() {
let viewModel = HomeViewModel(appStore: appStore)
XCTAssertEqual(viewModel.locationInfoText, HomeViewModel.locationReportPromptText)
XCTAssertEqual(viewModel.locationInfoText, "您已进入打卡范围")
}
func testQuickActionsOnlineEntrySyncsOfflineStyle() throws {
let view = HomeQuickActionsView()
view.apply(isOnline: false)
let statusContainer = try statusActionContainer(in: view)
XCTAssertEqual(statusContainer.backgroundColor?.homeTestHexRGB, AppColor.cardBackground.homeTestHexRGB)
XCTAssertEqual(statusContainer.layer.borderWidth, 1)
XCTAssertEqual(statusLabel(in: statusContainer)?.text, "离线")
XCTAssertEqual(statusLabel(in: statusContainer)?.textColor.homeTestHexRGB, AppColor.textTabInactive.homeTestHexRGB)
XCTAssertEqual(statusIcon(in: statusContainer)?.accessibilityIdentifier, "wifi.slash")
XCTAssertEqual(statusIcon(in: statusContainer)?.tintColor.homeTestHexRGB, AppColor.textTabInactive.homeTestHexRGB)
}
func testQuickActionsOnlineEntrySyncsOnlineStyle() throws {
let view = HomeQuickActionsView()
view.apply(isOnline: true)
let statusContainer = try statusActionContainer(in: view)
XCTAssertEqual(statusContainer.backgroundColor?.homeTestHexRGB, AppColor.online.homeTestHexRGB)
XCTAssertEqual(statusLabel(in: statusContainer)?.text, "在线")
XCTAssertEqual(statusLabel(in: statusContainer)?.textColor.homeTestHexRGB, AppColor.successBackground.homeTestHexRGB)
XCTAssertEqual(statusIcon(in: statusContainer)?.accessibilityIdentifier, "wifi")
XCTAssertEqual(statusIcon(in: statusContainer)?.tintColor.homeTestHexRGB, AppColor.successBackground.homeTestHexRGB)
}
func testRebuildCommonMenusFiltersUnknownURIs() {
appStore.permissions.savePermissionItems([
HomePermissionItem(id: "1", name: "钱包", uri: "wallet"),
HomePermissionItem(id: "2", name: "未知", uri: "unknown_uri"),
])
let viewModel = HomeViewModel(appStore: appStore)
viewModel.rebuildCommonMenus()
XCTAssertEqual(viewModel.commonMenus.map(\.uri), ["wallet"])
}
func testRebuildCommonMenusPersistsDefaultURIsOnFirstLoad() {
let menuStore = HomeCommonMenuStore(defaults: defaults)
appStore.permissions.savePermissionItems([
HomePermissionItem(id: "1", name: "钱包", uri: "wallet"),
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)
viewModel.rebuildCommonMenus()
XCTAssertEqual(viewModel.commonMenus.map(\.uri), [
"wallet", "cloud_management", "task_management", "system_settings",
])
XCTAssertEqual(
menuStore.savedCommonURIs(
accountScope: appStore.session.accountCachePrefix,
roleCode: appStore.session.roleCode
),
["wallet", "cloud_management", "task_management", "system_settings"]
)
}
func testEmptyRolePermissionShowsPermissionDialog() async throws {
let responseData = try TestJSON.envelope(data: [RolePermissionResponse]())
let session = MockURLSession(responses: [responseData])
let api = HomeAPI(client: APIClient(session: session))
let viewModel = HomeViewModel(appStore: appStore)
await viewModel.loadPermissions(api: api, force: true)
XCTAssertTrue(viewModel.showPermissionDialog)
XCTAssertTrue(viewModel.commonMenus.isEmpty)
}
func testPermissionApplyDestinationRoutesReviewingAndRejectedToStatus() async throws {
let reviewingJSON = """
{"code":100000,"msg":"success","data":[{"code":"AP001","role_id":1,"role_name":"","scenic_list":[],"status":1,"status_label":"","created_at":"","audit_note":"","audited_at":null,"audited_by":null}]}
""".data(using: .utf8)!
let rejectedJSON = """
{"code":100000,"msg":"success","data":[{"code":"AP002","role_id":1,"role_name":"","scenic_list":[],"status":3,"status_label":"","created_at":"","audit_note":"","audited_at":null,"audited_by":null}]}
""".data(using: .utf8)!
let session = MockURLSession(responses: [reviewingJSON, rejectedJSON])
let api = HomeAPI(client: APIClient(environment: .testing, session: session))
let viewModel = HomeViewModel(appStore: appStore)
let reviewingDestination = await viewModel.resolvePermissionApplyDestination(api: api)
let rejectedDestination = await viewModel.resolvePermissionApplyDestination(api: api)
XCTAssertEqual(reviewingDestination, .status(applyCode: "AP001"))
XCTAssertEqual(rejectedDestination, .status(applyCode: "AP002"))
}
func testPermissionApplyDestinationFallsBackToApply() async throws {
let completedJSON = """
{"code":100000,"msg":"success","data":[{"code":"AP003","role_id":1,"role_name":"","scenic_list":[],"status":2,"status_label":"","created_at":"","audit_note":"","audited_at":null,"audited_by":null}]}
""".data(using: .utf8)!
let emptyJSON = #"{"code":100000,"msg":"success","data":[]}"#.data(using: .utf8)!
let session = MockURLSession(responses: [completedJSON, emptyJSON])
let api = HomeAPI(client: APIClient(environment: .testing, session: session))
let viewModel = HomeViewModel(appStore: appStore)
let completedDestination = await viewModel.resolvePermissionApplyDestination(api: api)
let emptyDestination = await viewModel.resolvePermissionApplyDestination(api: api)
XCTAssertEqual(completedDestination, .apply)
XCTAssertEqual(emptyDestination, .apply)
}
func testPermissionApplyDestinationFallsBackToApplyOnError() async {
let response = HTTPURLResponse(
url: URL(string: "https://api-test.zhifly.cn/mock")!,
statusCode: 500,
httpVersion: nil,
headerFields: nil
)!
let session = MockURLSession(results: [.success((Data(), response))])
let api = HomeAPI(client: APIClient(environment: .testing, session: session))
let viewModel = HomeViewModel(appStore: appStore)
let destination = await viewModel.resolvePermissionApplyDestination(api: api)
XCTAssertEqual(destination, .apply)
}
func testRolePermissionBuildsCommonMenusAndStoreCardRole() async throws {
appStore.session.roleCode = AppRoleCode.storeAdmin.rawValue
let permissionResponse = RolePermissionResponse(
role: RoleInfo(
id: 46,
name: "店铺管理员",
roleCode: AppRoleCode.storeAdmin.rawValue,
notes: nil,
permission: [
PermissionItem(id: 1, pid: 0, name: "钱包", uri: "wallet", iconSrc: nil, children: nil),
PermissionItem(id: 2, pid: 0, name: "云盘", uri: "cloud_management", iconSrc: nil, children: nil),
]
),
scenic: [ScenicInfo(id: 10, name: "测试景区", status: 1)]
)
let storeResponse = StoreListResponse(
total: 1,
list: [StoreItem(id: 5, scenicId: 10, name: "测试门店", address: "地址", status: 1, statusText: "营业中")]
)
let permissionJSON = try TestJSON.envelope(data: [permissionResponse])
let storeJSON = try TestJSON.envelope(data: storeResponse)
let session = MockURLSession(responses: [permissionJSON, storeJSON])
let api = HomeAPI(client: APIClient(session: session))
let viewModel = HomeViewModel(appStore: appStore)
await viewModel.loadPermissions(api: api, force: true)
XCTAssertFalse(viewModel.showPermissionDialog)
XCTAssertTrue(viewModel.isMinimalTopRole)
XCTAssertEqual(viewModel.commonMenus.count, 2)
XCTAssertEqual(viewModel.storeItem?.name, "测试门店")
}
func testMarkNeedsPermissionReload() async throws {
let permissionResponse = RolePermissionResponse(
role: RoleInfo(
id: 41,
name: "摄影师",
roleCode: AppRoleCode.photographer.rawValue,
notes: nil,
permission: [
PermissionItem(id: 1, pid: 0, name: "钱包", uri: "wallet", iconSrc: nil, children: nil),
]
),
scenic: []
)
appStore.permissions.savePermissionItems([HomePermissionItem(id: "1", name: "钱包", uri: "wallet")])
let permissionJSON = try TestJSON.envelope(data: [permissionResponse])
let session = MockURLSession(responses: [permissionJSON])
let api = HomeAPI(client: APIClient(session: session))
let viewModel = HomeViewModel(appStore: appStore)
viewModel.markNeedsPermissionReload()
await viewModel.reloadIfNeeded(api: api)
XCTAssertEqual(viewModel.commonMenus.map(\.uri), ["wallet"])
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 = ""
let viewModel = HomeViewModel(appStore: appStore)
viewModel.refreshLocalDisplayState()
XCTAssertEqual(viewModel.currentScenicName, "")
XCTAssertEqual(appStore.session.currentScenicId, 0)
XCTAssertEqual(appStore.session.currentScenicName, "")
}
func testRefreshLocalDisplayStateClearsWhenIdMissing() {
appStore.session.currentScenicId = 0
appStore.session.currentScenicName = "残留名称"
let viewModel = HomeViewModel(appStore: appStore)
viewModel.refreshLocalDisplayState()
XCTAssertEqual(viewModel.currentScenicName, "")
XCTAssertEqual(appStore.session.currentScenicName, "")
}
private func statusActionContainer(in view: HomeQuickActionsView) throws -> UIView {
let stackView = try XCTUnwrap(view.subviews.compactMap { $0 as? UIStackView }.first)
XCTAssertEqual(stackView.arrangedSubviews.count, 3)
return stackView.arrangedSubviews[2]
}
private func statusLabel(in view: UIView) -> UILabel? {
if let label = view as? UILabel {
return label
}
for subview in view.subviews {
if let label = statusLabel(in: subview) {
return label
}
}
return nil
}
private func statusIcon(in view: UIView) -> UIImageView? {
if let imageView = view as? UIImageView {
return imageView
}
for subview in view.subviews {
if let imageView = statusIcon(in: subview) {
return imageView
}
}
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 {
var homeTestHexRGB: UInt {
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
getRed(&red, green: &green, blue: &blue, alpha: &alpha)
let r = Int(round(red * 255))
let g = Int(round(green * 255))
let b = Int(round(blue * 255))
return UInt(r << 16 | g << 8 | b)
}
}