4 Commits

Author SHA1 Message Date
1cdce616e4 chore: 更新 iOS 版本号至 1.2.1 2026-07-31 15:25:59 +08:00
212607fc82 fix: 增加登录失效确认弹窗 2026-07-31 15:25:12 +08:00
0cf0a41a60 fix: 修正账号姓名展示与资料缓存 2026-07-31 15:19:22 +08:00
f863004c8d fix: 优化收款码加载态,避免首屏错误占位闪现
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 14:07:05 +08:00
15 changed files with 375 additions and 55 deletions

View File

@ -436,7 +436,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1010501;
CURRENT_PROJECT_VERSION = 1020101;
DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
@ -460,7 +460,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1.5;
MARKETING_VERSION = 1.2.1;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@ -502,7 +502,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1010501;
CURRENT_PROJECT_VERSION = 1020101;
DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
@ -526,7 +526,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1.5;
MARKETING_VERSION = 1.2.1;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@ -741,7 +741,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1010501;
CURRENT_PROJECT_VERSION = 1020101;
DEVELOPMENT_TEAM = 56GVN5RNVN;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GENERATE_INFOPLIST_FILE = YES;
@ -761,7 +761,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1.5;
MARKETING_VERSION = 1.2.1;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",

View File

@ -70,10 +70,10 @@ enum AuthSessionHelper {
AppStore.shared.session.userId = String(user.businessUserId)
AppStore.shared.session.accountType = .scenicUser
AppStore.shared.session.accountDisplayName = firstNonEmpty(
user.scenicName, user.nickname, user.realName, user.username
user.scenicName, user.nickname, user.realName
)
AppStore.shared.session.userName = firstNonEmpty(user.nickname, user.username, user.realName)
AppStore.shared.session.realName = firstNonEmpty(user.realName, user.nickname, user.username)
AppStore.shared.session.userName = firstNonEmpty(user.nickname, user.realName)
AppStore.shared.session.realName = user.realName
AppStore.shared.session.phone = user.phone
AppStore.shared.session.currentScenicId = user.scenicId
AppStore.shared.session.currentScenicName = user.scenicName
@ -90,10 +90,10 @@ enum AuthSessionHelper {
AppStore.shared.session.userId = String(user.businessUserId)
AppStore.shared.session.accountType = .storeUser
AppStore.shared.session.accountDisplayName = firstNonEmpty(
user.storeName, user.scenicName, user.realName, user.userName, user.username
user.storeName, user.scenicName, user.realName
)
AppStore.shared.session.userName = firstNonEmpty(user.userName, user.username, user.realName)
AppStore.shared.session.realName = firstNonEmpty(user.realName, user.userName, user.username)
AppStore.shared.session.userName = user.realName
AppStore.shared.session.realName = user.realName
AppStore.shared.session.phone = user.phone
AppStore.shared.session.avatar = user.avatar
AppStore.shared.session.currentScenicId = user.scenicId

View File

@ -196,7 +196,6 @@ struct V9ScenicUser: Decodable, Equatable {
let userId: Int
let scenicUserId: Int
let ssUserId: Int
let username: String
let realName: String
let nickname: String
let phone: String
@ -212,7 +211,7 @@ struct V9ScenicUser: Decodable, Equatable {
}
var displayName: String {
scenicName.nonEmpty ?? nickname.nonEmpty ?? realName.nonEmpty ?? username
scenicName.nonEmpty ?? nickname.nonEmpty ?? realName
}
func toAccountSwitchAccount() -> AccountSwitchAccount {
@ -238,8 +237,6 @@ struct V9ScenicUser: Decodable, Equatable {
case userId = "user_id"
case scenicUserId = "scenic_user_id"
case ssUserId = "ss_user_id"
case username = "user_name"
case legacyUsername = "username"
case realName = "real_name"
case nickname
case phone
@ -258,9 +255,6 @@ struct V9ScenicUser: Decodable, Equatable {
userId = try container.decodeLossyInt(forKey: .userId) ?? 0
scenicUserId = try container.decodeLossyInt(forKey: .scenicUserId) ?? 0
ssUserId = try container.decodeLossyInt(forKey: .ssUserId) ?? 0
let preferredUsername = try container.decodeLossyString(forKey: .username)
let legacyUsername = try container.decodeLossyString(forKey: .legacyUsername)
username = preferredUsername.nonEmpty ?? legacyUsername
realName = try container.decodeLossyString(forKey: .realName)
nickname = try container.decodeLossyString(forKey: .nickname)
phone = try container.decodeLossyString(forKey: .phone)
@ -281,8 +275,6 @@ struct V9StoreUser: Decodable, Equatable {
let id: Int
let userId: Int
let storeUserId: Int
let username: String
let userName: String
let realName: String
let phone: String
let avatar: String
@ -300,14 +292,14 @@ struct V9StoreUser: Decodable, Equatable {
}
var displayName: String {
storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? username
storeName.nonEmpty ?? scenicName.nonEmpty ?? realName
}
func toAccountSwitchAccount() -> AccountSwitchAccount {
AccountSwitchAccount(
accountType: accountType.nonEmpty ?? Self.accountTypeValue,
businessUserId: businessUserId,
title: storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? userName.nonEmpty ?? "门店账号",
title: storeName.nonEmpty ?? scenicName.nonEmpty ?? realName.nonEmpty ?? "门店账号",
subtitle: joinAccountSubtitle(scenicName, roleName),
phone: phone,
realName: realName,
@ -325,8 +317,6 @@ struct V9StoreUser: Decodable, Equatable {
case id
case userId = "user_id"
case storeUserId = "store_user_id"
case username
case userName = "user_name"
case realName = "real_name"
case phone
case avatar
@ -346,8 +336,6 @@ struct V9StoreUser: Decodable, Equatable {
id = try container.decodeLossyInt(forKey: .id) ?? 0
userId = try container.decodeLossyInt(forKey: .userId) ?? 0
storeUserId = try container.decodeLossyInt(forKey: .storeUserId) ?? 0
username = try container.decodeLossyString(forKey: .username)
userName = try container.decodeLossyString(forKey: .userName)
realName = try container.decodeLossyString(forKey: .realName)
phone = try container.decodeLossyString(forKey: .phone)
avatar = try container.decodeLossyString(forKey: .avatar)

View File

@ -69,9 +69,9 @@ enum PayPageBrandingPolicy {
///
enum PaymentAccountDisplayFormatter {
/// 使
static func photographerName(userName: String, realName: String) -> String {
userName.trimmedNonEmpty ?? realName.trimmedNonEmpty ?? "-"
///
static func photographerName(realName: String) -> String {
realName.trimmedNonEmpty ?? "-"
}
///

View File

@ -28,6 +28,7 @@ final class PaymentCollectionDetailsViewModel {
private var staticPayURL = ""
private var dynamicPayURL = ""
private var hasCompletedPayCodeRequest = false
private let appStore: AppStore
private let payPageConfigCache: PayPageConfigCaching
@ -50,6 +51,11 @@ final class PaymentCollectionDetailsViewModel {
qrImage != nil && !staticPayURL.isEmpty
}
///
var isPayCodeLoading: Bool {
loading || !hasCompletedPayCodeRequest
}
var displayScenicName: String {
scenicName.isEmpty ? "暂无景区" : scenicName
}
@ -60,7 +66,6 @@ final class PaymentCollectionDetailsViewModel {
var displayPhotographerName: String {
PaymentAccountDisplayFormatter.photographerName(
userName: appStore.session.userName,
realName: appStore.session.realName
)
}
@ -85,11 +90,11 @@ final class PaymentCollectionDetailsViewModel {
func loadPayCode(api: PaymentAPI) async {
guard !loading else { return }
refreshLocalState()
loading = true
notifyStateChange()
refreshLocalState()
defer {
loading = false
hasCompletedPayCodeRequest = true
notifyStateChange()
}

View File

@ -17,11 +17,11 @@ final class ProfileViewModel {
var onStateChange: (() -> Void)?
var displayNickname: String {
nonEmpty(userInfo?.nickname) ?? "未设置昵称"
nonEmpty(userInfo?.nickname) ?? AppStore.shared.session.userName.nonEmpty ?? "未设置昵称"
}
var displayRealName: String {
nonEmpty(userInfo?.realName) ?? "--"
nonEmpty(userInfo?.realName) ?? AppStore.shared.session.realName.nonEmpty ?? "--"
}
var displayPhone: String {
@ -37,6 +37,17 @@ final class ProfileViewModel {
return uid.isEmpty ? "--" : uid
}
///
var hasCachedBasicInfo: Bool {
[
AppStore.shared.session.userName,
AppStore.shared.session.realName,
AppStore.shared.session.phone,
AppStore.shared.session.avatar,
AppStore.shared.session.userId,
].contains { nonEmpty($0) != nil }
}
var accountDisplayName: String {
let name = AppStore.shared.session.accountDisplayName.trimmingCharacters(in: .whitespacesAndNewlines)
return name.isEmpty ? "--" : name
@ -112,6 +123,7 @@ final class ProfileViewModel {
if !info.roleName.isEmpty {
AppStore.shared.session.roleName = info.roleName
}
notifyStateChange()
if showPhotographerFields {
async let realName = api.realNameInfo()

View File

@ -8,6 +8,7 @@ import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private var sessionExpiredDialog: SessionExpiredDialogViewController?
func scene(
_ scene: UIScene,
@ -86,18 +87,36 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
}
@objc private func handleSessionDidExpire() {
guard AppStore.shared.session.isLoggedIn else { return }
guard sessionExpiredDialog == nil else { return }
GlobalLoadingManager.shared.hideAll()
let dialog = SessionExpiredDialogViewController { [weak self] in
self?.transitionToLogin()
}
sessionExpiredDialog = dialog
guard let presenter = topViewController(from: window?.rootViewController) else {
sessionExpiredDialog = nil
return
}
presenter.present(dialog, animated: true)
}
private func transitionToLogin() {
sessionExpiredDialog = nil
PushNotificationManager.shared.handleLogout()
AppStore.shared.logout()
AppRouter.setRoot(.login, on: window)
}
@objc private func handleUserDidLogout() {
PushNotificationManager.shared.handleLogout()
AppStore.shared.logout()
AppRouter.setRoot(.login, on: window)
transitionToLogin()
}
@objc private func handleUserDidLogin() {
sessionExpiredDialog?.dismiss(animated: false)
sessionExpiredDialog = nil
AppRouter.setRoot(.mainTab, on: window)
DispatchQueue.main.async {
PushNotificationManager.shared.handleLoginCompleted()
@ -108,4 +127,18 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
@objc private func handleAccountDidSwitch() {
PushNotificationManager.shared.handleAccountSwitched()
}
private func topViewController(from viewController: UIViewController?) -> UIViewController? {
guard let viewController else { return nil }
if let presented = viewController.presentedViewController {
return topViewController(from: presented)
}
if let navigationController = viewController as? UINavigationController {
return topViewController(from: navigationController.visibleViewController)
}
if let tabBarController = viewController as? UITabBarController {
return topViewController(from: tabBarController.selectedViewController)
}
return viewController
}
}

View File

@ -0,0 +1,103 @@
//
// SessionExpiredDialogViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// 退
@MainActor
final class SessionExpiredDialogViewController: UIViewController {
private let onConfirm: () -> Void
private let confirmButton = AppButton(title: "确认并退出登录")
private var didConfirm = false
///
init(onConfirm: @escaping () -> Void) {
self.onConfirm = onConfirm
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .overFullScreen
modalTransitionStyle = .crossDissolve
isModalInPresentation = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
}
private func setupUI() {
view.backgroundColor = UIColor.black.withAlphaComponent(0.32)
view.accessibilityViewIsModal = true
view.accessibilityIdentifier = "sessionExpired.dialog"
let cardView = UIView()
cardView.backgroundColor = AppColor.cardBackground
cardView.layer.cornerRadius = AppRadius.lg
cardView.clipsToBounds = true
let warningImageView = UIImageView(
image: UIImage(systemName: "exclamationmark.circle.fill")
)
warningImageView.tintColor = UIColor(hex: 0xF05A5A)
warningImageView.contentMode = .scaleAspectFit
warningImageView.isAccessibilityElement = true
warningImageView.accessibilityLabel = "登录状态异常"
let titleLabel = UILabel()
titleLabel.text = "登录提醒"
titleLabel.textColor = AppColor.textPrimary
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
titleLabel.textAlignment = .center
let messageLabel = UILabel()
messageLabel.text = "当前用户已在其他设备登录,\n请重新登录"
messageLabel.textColor = UIColor(hex: 0x4B5563)
messageLabel.font = .systemFont(ofSize: 14)
messageLabel.textAlignment = .center
messageLabel.numberOfLines = 0
confirmButton.accessibilityIdentifier = "sessionExpired.confirmButton"
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
let contentStack = UIStackView(arrangedSubviews: [
warningImageView,
titleLabel,
messageLabel,
confirmButton,
])
contentStack.axis = .vertical
contentStack.alignment = .fill
contentStack.spacing = 16
contentStack.setCustomSpacing(4, after: titleLabel)
contentStack.setCustomSpacing(20, after: messageLabel)
view.addSubview(cardView)
cardView.addSubview(contentStack)
cardView.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(32)
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(24)
}
warningImageView.snp.makeConstraints { make in
make.height.equalTo(64)
}
}
@objc private func confirmTapped() {
guard !didConfirm else { return }
didConfirm = true
confirmButton.isEnabled = false
onConfirm()
}
}

View File

@ -28,6 +28,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
private let qrContainerView = UIView()
private let qrImageView = UIImageView()
private let qrPlaceholderView = UIView()
private let qrLoadingIndicator = UIActivityIndicatorView(style: .medium)
private let qrErrorLabel = UILabel()
private let refreshButton = UIButton(type: .system)
private let setAmountButton = UIButton(type: .system)
@ -127,6 +128,10 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
qrPlaceholderView.layer.cornerRadius = usesBranding ? 10 : 24
qrPlaceholderView.isHidden = true
qrLoadingIndicator.color = AppColor.primary
qrLoadingIndicator.hidesWhenStopped = true
qrLoadingIndicator.accessibilityLabel = "收款码加载中"
qrErrorLabel.text = "未选景区或网络问题"
qrErrorLabel.font = .systemFont(ofSize: 14)
qrErrorLabel.textColor = AppColor.danger
@ -150,6 +155,9 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
trailing: 24
)
}
actionRow.alpha = 0
actionRow.isUserInteractionEnabled = false
actionRow.accessibilityElementsHidden = true
configureLinkButton(setAmountButton, title: "设置金额")
configureLinkButton(saveQRButton, title: "保存二维码")
@ -206,6 +214,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
qrContentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.lg
qrCardView.addSubview(qrContentStack)
qrPlaceholderView.addSubview(qrLoadingIndicator)
qrPlaceholderView.addSubview(qrErrorLabel)
qrPlaceholderView.addSubview(refreshButton)
@ -275,6 +284,9 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
qrPlaceholderView.snp.makeConstraints { make in
make.width.height.equalTo(usesBranding ? brandQRContainerSize : 182)
}
qrLoadingIndicator.snp.makeConstraints { make in
make.center.equalToSuperview()
}
qrErrorLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(56)
make.leading.trailing.equalToSuperview().inset(AppSpacing.sm)
@ -370,9 +382,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
private func loadData() async {
async let pageConfig: Void = viewModel.loadPayPageConfig(api: paymentAPI)
showLoading()
await viewModel.loadPayCode(api: paymentAPI)
hideLoading()
await pageConfig
}
@ -387,17 +397,33 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
voiceSwitch.isOn = viewModel.isVoiceBroadcastOpen
applyBrandConfigIfNeeded()
if let image = viewModel.qrImage {
if viewModel.isPayCodeLoading {
qrImageView.isHidden = true
qrContainerView.isHidden = true
qrPlaceholderView.backgroundColor = viewModel.usesNalatiBranding
? .white
: AppColor.inputBackground
qrPlaceholderView.isHidden = false
qrErrorLabel.isHidden = true
refreshButton.isHidden = true
qrLoadingIndicator.startAnimating()
setActionRowAvailable(false)
} else if let image = viewModel.qrImage {
qrImageView.image = image
qrImageView.isHidden = false
qrContainerView.isHidden = !viewModel.usesNalatiBranding
qrPlaceholderView.isHidden = true
actionRow.isHidden = false
qrLoadingIndicator.stopAnimating()
setActionRowAvailable(true)
} else {
qrImageView.isHidden = true
qrContainerView.isHidden = true
qrPlaceholderView.backgroundColor = AppColor.inputBackground
qrPlaceholderView.isHidden = false
actionRow.isHidden = true
qrErrorLabel.isHidden = false
refreshButton.isHidden = false
qrLoadingIndicator.stopAnimating()
setActionRowAvailable(false)
}
if viewModel.showAmountDialog {
@ -408,6 +434,12 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
}
}
private func setActionRowAvailable(_ isAvailable: Bool) {
actionRow.alpha = isAvailable ? 1 : 0
actionRow.isUserInteractionEnabled = isAvailable
actionRow.accessibilityElementsHidden = !isAvailable
}
private func presentAmountDialogIfNeeded() {
guard amountDialog == nil else {
amountDialog?.apply(amount: viewModel.amount, remark: viewModel.remark)
@ -625,9 +657,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController {
@objc private func refreshTapped() {
Task {
showLoading()
await viewModel.refresh(api: paymentAPI)
hideLoading()
}
}

View File

@ -38,6 +38,11 @@ final class ProfileViewController: BaseViewController {
title = "我的"
}
override func viewDidLoad() {
super.viewDidLoad()
applyViewModel()
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF7FAFF)
@ -104,12 +109,10 @@ final class ProfileViewController: BaseViewController {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
let showLoading = !hasLoadedProfileOnce
let showLoading = !hasLoadedProfileOnce && !viewModel.hasCachedBasicInfo
Task {
await reloadProfile(showGlobalLoading: showLoading)
if showLoading {
hasLoadedProfileOnce = true
}
hasLoadedProfileOnce = true
}
}

View File

@ -62,6 +62,7 @@ final class AuthModelsTests: XCTestCase {
XCTAssertEqual(response.token, "person-temp-token")
XCTAssertEqual(response.accounts.map(\.businessUserId), [101, 201])
XCTAssertEqual(response.accounts.map(\.subtitle), ["示例景区 · 摄影师", "示例景区 · 店长"])
XCTAssertEqual(response.accounts.map(\.realName), ["张三", "李四"])
}
func testV9AuthResponseDecodesMissingAccountListsAsEmpty() throws {
@ -137,4 +138,39 @@ final class AuthModelsTests: XCTestCase {
XCTAssertEqual(user.toAccountSwitchAccount().realName, "张三")
XCTAssertEqual(user.toAccountSwitchAccount().toSetUserRequest(), SetUserRequest(storeUserId: 201))
}
func testV9AccountModelsIgnoreBackendIdentifiersAndUseRealNameFallback() throws {
let json = """
{
"token": "person-temp-token",
"scenic_users": [
{
"account_type": "scenic_user",
"ss_user_id": 101,
"username": "backend_scenic_account",
"user_name": "backend_scenic_unique_id",
"real_name": ""
}
],
"store_users": [
{
"account_type": "store_user",
"store_user_id": 201,
"username": "backend_store_account",
"user_name": "backend_store_unique_id",
"real_name": ""
}
]
}
""".data(using: .utf8)!
let response = try JSONDecoder().decode(V9AuthResponse.self, from: json)
XCTAssertEqual(response.scenicUsers.first?.realName, "景区真实姓名")
XCTAssertEqual(response.scenicUsers.first?.displayName, "景区真实姓名")
XCTAssertEqual(response.scenicUsers.first?.toAccountSwitchAccount().title, "景区真实姓名")
XCTAssertEqual(response.storeUsers.first?.realName, "门店真实姓名")
XCTAssertEqual(response.storeUsers.first?.displayName, "门店真实姓名")
XCTAssertEqual(response.storeUsers.first?.toAccountSwitchAccount().title, "门店真实姓名")
}
}

View File

@ -69,6 +69,26 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
XCTAssertFalse(viewModel.showAmountDialog)
}
func testPayCodeLoadingStateCoversInitialRenderAndRequest() async {
let payCodeJSON = """
{"code":100000,"msg":"success","data":{"static_pay_url":"https://pay.example.com/static","dynamic_pay_url":"https://pay.example.com/dynamic"}}
""".data(using: .utf8)!
let session = MockURLSession(responses: [payCodeJSON])
let api = PaymentAPI(client: APIClient(environment: .testing, session: session))
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
var loadingStates = [viewModel.isPayCodeLoading]
viewModel.onStateChange = {
loadingStates.append(viewModel.isPayCodeLoading)
}
await viewModel.loadPayCode(api: api)
XCTAssertEqual(loadingStates.first, true)
XCTAssertTrue(loadingStates.dropLast().allSatisfy { $0 })
XCTAssertEqual(loadingStates.last, false)
XCTAssertTrue(viewModel.hasPayCode)
}
func testToggleReceiveVoicePersistsInAppStore() {
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
viewModel.refreshLocalState()
@ -79,8 +99,9 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
XCTAssertTrue(viewModel.isVoiceBroadcastOpen)
}
func testNalatiAccountInfoUsesLoggedInPhotographerAndAllPermissionStores() {
appStore.session.userName = ""
func testNalatiAccountInfoUsesPhotographerRealNameAndAllPermissionStores() {
appStore.session.userName = ""
appStore.session.realName = ""
appStore.permissions.saveRolePermissionList([
RolePermissionResponse(
role: RoleInfo(id: 41, name: "", roleCode: "photographer"),
@ -93,10 +114,19 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
XCTAssertEqual(viewModel.displayPhotographerName, "")
XCTAssertEqual(viewModel.displayPhotographerName, "")
XCTAssertEqual(viewModel.displayStoreNames, "")
}
func testPhotographerNameDoesNotFallBackToNicknameWhenRealNameMissing() {
appStore.session.userName = ""
appStore.session.realName = " "
let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore)
XCTAssertEqual(viewModel.displayPhotographerName, "-")
}
func testLoadPayCodeClearsQRWhenScenicMissing() async {
appStore.session.currentScenicId = 0
let session = MockURLSession(responses: [])
@ -105,6 +135,7 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase {
await viewModel.loadPayCode(api: api)
XCTAssertFalse(viewModel.isPayCodeLoading)
XCTAssertFalse(viewModel.hasPayCode)
XCTAssertEqual(session.requests.count, 0)
}

View File

@ -55,11 +55,12 @@ final class PaymentModelsTests: XCTestCase {
XCTAssertFalse(PayPageBrandingPolicy.isNalati(scenicId: 0))
}
func testPaymentAccountDisplayFormatterUsesCurrentPhotographerAndAllUniqueStores() {
func testPaymentAccountDisplayFormatterUsesRealNameAndAllUniqueStores() {
XCTAssertEqual(
PaymentAccountDisplayFormatter.photographerName(userName: " 当前摄影师 ", realName: "实名"),
"当前摄影师"
PaymentAccountDisplayFormatter.photographerName(realName: " 摄影师真实姓名 "),
"摄影师真实姓名"
)
XCTAssertEqual(PaymentAccountDisplayFormatter.photographerName(realName: " "), "-")
XCTAssertEqual(
PaymentAccountDisplayFormatter.storeNames(["一号店", " 二号店 ", "一号店", ""]),
"一号店、二号店"

View File

@ -26,6 +26,22 @@ final class ProfileViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.displayNickname, "未设置昵称")
}
func testColdStartBasicInfoUsesPersistedSessionCache() {
AppStore.shared.session.userName = "缓存昵称"
AppStore.shared.session.realName = "缓存真实姓名"
AppStore.shared.session.phone = "186****7230"
AppStore.shared.session.avatar = "https://cdn.example.com/cached-avatar.jpg"
let viewModel = ProfileViewModel()
XCTAssertTrue(viewModel.hasCachedBasicInfo)
XCTAssertEqual(viewModel.displayNickname, "缓存昵称")
XCTAssertEqual(viewModel.displayRealName, "缓存真实姓名")
XCTAssertEqual(viewModel.displayPhone, "186****7230")
XCTAssertEqual(viewModel.displayAvatarURL, "https://cdn.example.com/cached-avatar.jpg")
XCTAssertEqual(viewModel.displayUID, "profile-user")
}
func testShowPhotographerFieldsWhenRoleCodeIsPhotographer() {
AppStore.shared.session.roleCode = AppRoleCode.photographer.rawValue
let viewModel = ProfileViewModel()

View File

@ -0,0 +1,62 @@
//
// SessionExpiredDialogViewControllerTests.swift
// suixinkanTests
//
import UIKit
import XCTest
@testable import suixinkan
/// Android
@MainActor
final class SessionExpiredDialogViewControllerTests: XCTestCase {
func testDialogUsesForcedPresentationAndAndroidCopy() throws {
let dialog = SessionExpiredDialogViewController(onConfirm: {})
dialog.loadViewIfNeeded()
XCTAssertEqual(dialog.modalPresentationStyle, .overFullScreen)
XCTAssertEqual(dialog.modalTransitionStyle, .crossDissolve)
XCTAssertTrue(dialog.isModalInPresentation)
XCTAssertTrue(dialog.view.accessibilityViewIsModal)
let labels = dialog.view.allSubviews(of: UILabel.self)
XCTAssertTrue(labels.contains { $0.text == "登录提醒" })
XCTAssertTrue(labels.contains { $0.text == "当前用户已在其他设备登录,\n请重新登录" })
let button = try XCTUnwrap(
dialog.view.allSubviews(of: UIButton.self).first {
$0.accessibilityIdentifier == "sessionExpired.confirmButton"
}
)
XCTAssertEqual(button.title(for: .normal), "确认并退出登录")
}
func testConfirmCallbackOnlyRunsOnce() throws {
var confirmationCount = 0
let dialog = SessionExpiredDialogViewController {
confirmationCount += 1
}
dialog.loadViewIfNeeded()
let button = try XCTUnwrap(
dialog.view.allSubviews(of: UIButton.self).first {
$0.accessibilityIdentifier == "sessionExpired.confirmButton"
}
)
button.sendActions(for: .touchUpInside)
button.sendActions(for: .touchUpInside)
XCTAssertEqual(confirmationCount, 1)
XCTAssertFalse(button.isEnabled)
}
}
private extension UIView {
func allSubviews<View: UIView>(of type: View.Type) -> [View] {
subviews.flatMap { subview -> [View] in
let current = (subview as? View).map { [$0] } ?? []
return current + subview.allSubviews(of: type)
}
}
}