import UIKit import XCTest @testable import suixinkan /// 合并入口仍调用两条旧接口,覆盖部分成功、快照变化与重复操作;所有请求只在内存中执行。 @MainActor final class StoreAccountDeregistrationCombinedTests: XCTestCase { /// 零资产也按现金、积分顺序分别确认,全部核实后才进入验证码页。 func testConfirmsBothZeroAssetsInOrder() async throws { let service = CombinedDeregistrationService() let model = try await readyModel(service) XCTAssertTrue(model.requiresAssetConfirmation) await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service) XCTAssertEqual(service.mutations, ["wallet", "points"]) XCTAssertEqual(model.step, .verification) XCTAssertTrue(model.canContinue) XCTAssertFalse(model.requiresAssetConfirmation) } /// 第二项失败保留现金确认结果,用户重试时不重复确认第一项。 func testPartialFailureOnlyRetriesUnconfirmedAsset() async throws { let service = CombinedDeregistrationService() service.pointsError = APIError.networkFailed("积分确认失败") let model = try await readyModel(service) await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service) XCTAssertEqual(model.step, .conditions) XCTAssertEqual(model.eligibility?.walletWaived, true) XCTAssertEqual(model.eligibility?.pointsWaived, false) XCTAssertTrue(model.errorMessage?.contains("现金余额已确认,积分尚未确认") == true) XCTAssertEqual(service.mutations, ["wallet", "points"]) service.pointsError = nil await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service) XCTAssertEqual(service.mutations, ["wallet", "points", "points"]) XCTAssertEqual(model.step, .verification) } /// 已确认资产不重复提交,两项均已确认时仅查询并进入下一步。 func testSkipsPreviouslyConfirmedAssets() async throws { for both in [false, true] { let service = CombinedDeregistrationService() service.walletWaived = true service.pointsWaived = both let model = try await readyModel(service) await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service) XCTAssertEqual(service.mutations, both ? [] : ["points"]) XCTAssertEqual(model.step, .verification) } } /// 用户看到的金额与提交前快照不同,不提交任何资产确认。 func testChangedBalanceBeforeConfirmationRequiresNewConsent() async throws { let service = CombinedDeregistrationService() let model = try await readyModel(service) let snapshot = try XCTUnwrap(model.eligibility) service.points = 10 await model.confirmAssetsAndContinue(snapshot: snapshot, api: service) XCTAssertTrue(service.mutations.isEmpty) XCTAssertEqual(model.eligibility?.pointsBalance, 10) XCTAssertTrue(model.requiresAssetConfirmation) XCTAssertEqual(model.step, .conditions) XCTAssertNotNil(model.errorMessage) } /// 两条接口之间余额变化时停止,不自动同意新余额。 func testBalanceChangeBetweenRequestsStopsSecondMutation() async throws { let service = CombinedDeregistrationService() service.onWallet = { service.points = 10 } let model = try await readyModel(service) await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service) XCTAssertEqual(service.mutations, ["wallet"]) XCTAssertEqual(model.eligibility?.pointsBalance, 10) XCTAssertEqual(model.step, .conditions) XCTAssertTrue(model.requiresAssetConfirmation) } /// 财务归属变化即使金额相同也必须重新核对。 func testFinanceIdentityChangeStopsConfirmation() async throws { let service = CombinedDeregistrationService() let model = try await readyModel(service) let snapshot = try XCTUnwrap(model.eligibility) service.financeID = 202 await model.confirmAssetsAndContinue(snapshot: snapshot, api: service) XCTAssertTrue(service.mutations.isEmpty) XCTAssertEqual(model.step, .conditions) XCTAssertTrue(model.requiresAssetConfirmation) } /// 请求期间身份变化,丢弃当前资料且不继续第二项。 func testIdentityChangeBetweenRequestsStopsFlow() async throws { let service = CombinedDeregistrationService() service.onWallet = { service.userID = 102 } let model = try await readyModel(service) await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service) XCTAssertEqual(service.mutations, ["wallet"]) XCTAssertNil(model.eligibility) XCTAssertFalse(model.canContinue) XCTAssertFalse(model.canConfirmAssetsAndContinue) } /// 连点不能启动第二组请求。 func testRepeatedTapWhileBusyDoesNotDuplicateMutations() async throws { let service = CombinedDeregistrationService() service.holdWallet = true let model = try await readyModel(service) let snapshot = try XCTUnwrap(model.eligibility) let first = Task { await model.confirmAssetsAndContinue(snapshot: snapshot, api: service) } for _ in 0..<100 { if service.walletContinuation != nil { break } try await Task.sleep(nanoseconds: 10_000_000) } XCTAssertNotNil(service.walletContinuation) await model.confirmAssetsAndContinue(snapshot: snapshot, api: service) XCTAssertEqual(service.mutations, ["wallet"]) service.walletContinuation?.resume() service.walletContinuation = nil await first.value XCTAssertEqual(service.mutations, ["wallet", "points"]) } /// 非资产阻断不能被合并按钮绕过,未知代码同样阻止继续。 func testBusinessBlockersPreventAnyConfirmation() async throws { for code in ["ORDER_UNFULFILLED", "RISK_WINDOW_NOT_EXPIRED", "UNKNOWN_NEW_RULE"] { let service = CombinedDeregistrationService() service.extraBlockers = [["code": code, "message": "需处理", "action": "contact_support"]] let model = try await readyModel(service) XCTAssertFalse(model.canConfirmAssetsAndContinue) await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service) XCTAssertTrue(service.mutations.isEmpty) } } /// POST成功但GET未确认时停止,不乐观置为完成。 func testUnconfirmedWalletResponseDoesNotProceedToPoints() async throws { let service = CombinedDeregistrationService() service.confirmWalletOnServer = false let model = try await readyModel(service) await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service) XCTAssertEqual(service.mutations, ["wallet"]) XCTAssertEqual(model.eligibility?.walletWaived, false) XCTAssertFalse(model.canContinue) XCTAssertEqual(model.step, .conditions) } /// 响应丢失后只读发现两项成功,不自动重发;下一次主动继续无需再确认。 func testLostPointsResponseReconcilesWithoutResubmitting() async throws { let service = CombinedDeregistrationService() service.pointsError = APIError.networkFailed("响应丢失") service.commitPointsBeforeError = true let model = try await readyModel(service) await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service) XCTAssertEqual(service.mutations, ["wallet", "points"]) XCTAssertEqual(model.step, .conditions) XCTAssertFalse(model.requiresAssetConfirmation) await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service) XCTAssertEqual(service.mutations, ["wallet", "points"]) XCTAssertEqual(model.step, .verification) } /// 已确认项在点击下一步期间失效时必须重新确认,不能无弹窗再次提交。 func testExpiredConfirmationRequiresFreshConsent() async throws { let service = CombinedDeregistrationService() service.walletWaived = true service.pointsWaived = true let model = try await readyModel(service) let snapshot = try XCTUnwrap(model.eligibility) service.walletWaived = false await model.confirmAssetsAndContinue(snapshot: snapshot, api: service) XCTAssertTrue(service.mutations.isEmpty) XCTAssertTrue(model.requiresAssetConfirmation) XCTAssertEqual(model.step, .conditions) } /// 确认后的查询失败时清空操作权限,避免使用旧金额继续。 func testReadFailureAfterFirstMutationDisablesContinue() async throws { let service = CombinedDeregistrationService() service.onWallet = { service.readError = APIError.networkFailed("offline") } let model = try await readyModel(service) await model.confirmAssetsAndContinue(snapshot: try XCTUnwrap(model.eligibility), api: service) XCTAssertEqual(service.mutations, ["wallet"]) XCTAssertNil(model.eligibility) XCTAssertFalse(model.canConfirmAssetsAndContinue) XCTAssertFalse(model.canContinue) } private func readyModel(_ service: CombinedDeregistrationService) async throws -> StoreAccountDeregistrationViewModel { let model = StoreAccountDeregistrationViewModel(storeUserID: 101) await model.refresh(api: service) _ = try XCTUnwrap(model.eligibility) return model } } /// 两步页面的实际UIKit布局和交互测试;独立窗口不触碰用户真实会话。 @MainActor final class StoreAccountDeregistrationRedesignTests: XCTestCase { /// 明确受理后立即调用一次退出,不等待后续GET;在途连点及成功后再次点击均不重发。 func testAcceptedSubmissionExitsOnceWithoutPostSubmissionQuery() async throws { let service = CombinedDeregistrationService() service.walletWaived = true service.pointsWaived = true service.holdApply = true service.onApply = { service.readError = APIError.networkFailed("成功后不应再查询") } let suite = "DeregistrationLogout.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! let store = StoreAccountDeregistrationSubmissionStore(storeUserID: "101", environment: .testing, defaults: defaults) defer { defaults.removePersistentDomain(forName: suite) } let model = StoreAccountDeregistrationViewModel(storeUserID: 101, submissionStore: store) var logoutCount = 0 var unresolvedCount = 0 let controller = StoreAccountDeregistrationViewController( identityName: "测试门店", viewModel: model, api: service, onUnresolvedSubmission: { unresolvedCount += 1 }, onSubmissionAccepted: { logoutCount += 1 }) let window = makeWindow(controller, size: UIScreen.main.bounds.size) defer { service.finishApply(); close(window) } await waitUntil { model.canContinue } model.beginVerification() let initialStatusCount = service.statusCount let initialEligibilityCount = service.eligibilityCount controller.submitConfirmedApplication(smsCode: "654321", reason: "不再使用") await waitUntil { service.applyContinuation != nil } controller.submitConfirmedApplication(smsCode: "654321", reason: "不再使用") XCTAssertEqual(logoutCount, 0) XCTAssertTrue(model.isBusy) XCTAssertTrue(GlobalLoadingManager.shared.isShowing) XCTAssertNotNil(allViews(window).first { $0 is GlobalLoadingOverlayView }) capture(window, name: "deregister-submitting-global-loading") service.finishApply() await waitUntil { logoutCount == 1 } controller.submitConfirmedApplication(smsCode: "654321", reason: "不再使用") XCTAssertEqual(logoutCount, 1) XCTAssertEqual(unresolvedCount, 0) XCTAssertEqual(service.mutations, ["apply"]) XCTAssertEqual(service.statusCount, initialStatusCount + 1, "只保留提交前校验") XCTAssertEqual(service.eligibilityCount, initialEligibilityCount + 1) XCTAssertTrue(model.submissionAccepted) XCTAssertNil(model.errorMessage) XCTAssertTrue(store.hasUnresolvedSubmission, "保留意图供下次登录核实") XCTAssertFalse(GlobalLoadingManager.shared.isShowing) } /// 业务拒绝不退出;网络响应丢失只转入状态核验,不当作成功或自动重试。 func testRejectedAndUncertainSubmissionDoNotLogout() async throws { for uncertain in [false, true] { let service = CombinedDeregistrationService() service.walletWaived = true service.pointsWaived = true service.applyError = uncertain ? APIError.networkFailed("响应丢失") : APIError.serverCode(100001, "验证码错误") let model = StoreAccountDeregistrationViewModel(storeUserID: 101) var logoutCount = 0 var unresolvedCount = 0 let controller = StoreAccountDeregistrationViewController( identityName: "测试门店", viewModel: model, api: service, onUnresolvedSubmission: { unresolvedCount += 1 }, onSubmissionAccepted: { logoutCount += 1 }) let window = makeWindow(controller, size: UIScreen.main.bounds.size) defer { close(window) } await waitUntil { model.canContinue } model.beginVerification() controller.submitConfirmedApplication(smsCode: "654321", reason: "不再使用") await waitUntil { model.errorMessage != nil && !model.isBusy } XCTAssertEqual(logoutCount, 0) XCTAssertEqual(unresolvedCount, uncertain ? 1 : 0) XCTAssertEqual(model.submissionAttempted, uncertain) XCTAssertFalse(model.submissionAccepted) XCTAssertEqual(service.mutations, ["apply"]) XCTAssertFalse(GlobalLoadingManager.shared.isShowing) } } /// 两步页面下拉只查询;使用统一加载,成功或失败都结束刷新且保留用户输入。 func testPullRefreshUsesGlobalLoadingAndPreservesVerificationInput() async throws { for failed in [false, true] { let service = CombinedDeregistrationService() service.walletWaived = true service.pointsWaived = true let model = StoreAccountDeregistrationViewModel(storeUserID: 101) let controller = StoreAccountDeregistrationViewController(identityName: "测试门店", viewModel: model, api: service) let window = makeWindow(controller, size: CGSize(width: 375, height: 667)) defer { service.finishStatus(); close(window) } await waitUntil { model.canContinue } let next = try XCTUnwrap(find(controller.view, "deregister.continue") as? UIButton) next.sendActions(for: .touchUpInside) await waitUntil { model.step == .verification } let code = try XCTUnwrap(find(controller.view, "deregister.code") as? UITextField) let reason = try XCTUnwrap(find(controller.view, "deregister.reason") as? UITextField) code.text = "654321" reason.text = "不再使用" let scroll = try XCTUnwrap(allViews(controller.view).first { $0 is UIScrollView } as? UIScrollView) let refresh = try XCTUnwrap(scroll.refreshControl) XCTAssertNil(controller.navigationItem.rightBarButtonItem) XCTAssertTrue(scroll.alwaysBounceVertical) let count = service.statusCount service.holdNextStatus = true refresh.beginRefreshing() refresh.sendActions(for: .valueChanged) await waitUntil { service.statusContinuation != nil } XCTAssertTrue(GlobalLoadingManager.shared.isShowing) XCTAssertFalse(next.isEnabled) refresh.sendActions(for: .valueChanged) XCTAssertEqual(service.statusCount, count + 1) if failed { service.readError = APIError.networkFailed("offline") } service.finishStatus() await waitUntil { !model.isBusy && !GlobalLoadingManager.shared.isShowing } XCTAssertFalse(refresh.isRefreshing) XCTAssertEqual(code.text, "654321") XCTAssertEqual(reason.text, "不再使用") XCTAssertEqual(model.errorMessage != nil, failed) XCTAssertTrue(service.mutations.isEmpty) } } /// 只读条件页同样支持下拉,不含旧导航刷新或其他身份分类提示。 func testReadOnlyConditionsHavePullRefreshAndSimplifiedCopy() async throws { let service = CombinedDeregistrationService() let model = StoreAccountDeregistrationViewModel(storeUserID: 101) let controller = StoreAccountDeregistrationViewController(identityName: "测试门店", viewModel: model, api: service, readOnly: true) let window = makeWindow(controller, size: CGSize(width: 375, height: 667)) defer { close(window) } await waitUntil { model.eligibility != nil } XCTAssertNil(controller.navigationItem.rightBarButtonItem) let scroll = try XCTUnwrap(allViews(controller.view).first { $0 is UIScrollView } as? UIScrollView) let refresh = try XCTUnwrap(scroll.refreshControl) let count = service.statusCount refresh.sendActions(for: .valueChanged) await waitUntil { service.statusCount == count + 1 && !model.isBusy } let texts = allViews(controller.view).compactMap { ($0 as? UILabel)?.text }.joined(separator: "\n") XCTAssertFalse(texts.contains("景区")) XCTAssertTrue(texts.contains("同手机号的其他身份不受影响")) XCTAssertFalse(StoreAccountDeregistrationError.unsupportedIdentity.localizedDescription.contains("景区")) XCTAssertTrue(service.mutations.isEmpty) XCTAssertFalse(refresh.isRefreshing) } /// 点击提交仅显示最终确认,明确退出登录和冷静期,不自动申请或发送短信。 func testSubmitRequiresFinalConfirmationWithLogoutNotice() async throws { let service = CombinedDeregistrationService() service.walletWaived = true service.pointsWaived = true let model = StoreAccountDeregistrationViewModel(storeUserID: 101) var logoutCount = 0 let controller = StoreAccountDeregistrationViewController( identityName: "当前测试身份", viewModel: model, api: service, onSubmissionAccepted: { logoutCount += 1 }) let window = makeWindow(controller, size: UIScreen.main.bounds.size) defer { close(window) } await waitUntil { model.canContinue } let next = try XCTUnwrap(find(controller.view, "deregister.continue") as? UIButton) next.sendActions(for: .touchUpInside) await waitUntil { model.step == .verification } let code = try XCTUnwrap(find(controller.view, "deregister.code") as? UITextField) let reason = try XCTUnwrap(find(controller.view, "deregister.reason") as? UITextField) code.text = "654321" reason.text = "不再使用" reason.sendActions(for: .editingChanged) let submit = try XCTUnwrap(find(controller.view, "deregister.submit") as? UIButton) submit.sendActions(for: .touchUpInside) await waitUntil { controller.presentedViewController is UIAlertController } let alert = try XCTUnwrap(controller.presentedViewController as? UIAlertController) XCTAssertTrue(alert.message?.contains("提交成功后将退出登录") == true) XCTAssertTrue(alert.message?.contains("当前测试身份") == true) XCTAssertTrue(alert.message?.contains("7 天冷静期") == true) XCTAssertTrue(alert.message?.contains("正式完成不可恢复") == true) XCTAssertEqual(alert.actions.map(\.title), ["暂不提交", "确认提交申请"]) XCTAssertTrue(service.mutations.isEmpty) XCTAssertEqual(logoutCount, 0) } /// 375pt小屏上仅一个主按钮,零资产确认也先弹窗,没有自动提交。 func testSingleAssetButtonShowsOneExplicitConfirmation() async throws { let service = CombinedDeregistrationService() let model = StoreAccountDeregistrationViewModel(storeUserID: 101) let controller = StoreAccountDeregistrationViewController(identityName: "北大科技园·测试身份", viewModel: model, api: service) let window = makeWindow(controller, size: CGSize(width: 375, height: 667)) defer { close(window) } await waitUntil { model.canConfirmAssetsAndContinue } window.layoutIfNeeded() let next = try XCTUnwrap(find(controller.view, "deregister.continue") as? UIButton) XCTAssertEqual(next.configuration?.title, "确认资产并继续") XCTAssertNil(controller.navigationItem.rightBarButtonItem) XCTAssertNotNil(find(controller.view, "deregister.refresh") as? UIRefreshControl) let texts = allViews(controller.view).compactMap { ($0 as? UILabel)?.text }.joined(separator: "\n") XCTAssertFalse(texts.contains("景区")) XCTAssertNil(find(controller.view, "deregister.wallet")) XCTAssertNil(find(controller.view, "deregister.points")) XCTAssertTrue(window.bounds.contains(next.convert(next.bounds, to: window))) capture(window, name: "redesign-assets-375") next.sendActions(for: .touchUpInside) await waitUntil { controller.presentedViewController is UIAlertController } let alert = try XCTUnwrap(controller.presentedViewController as? UIAlertController) XCTAssertEqual(alert.actions.map(\.title), ["暂不确认", "确认并继续"]) XCTAssertTrue(alert.message?.contains("现金余额:¥0.00") == true) XCTAssertTrue(alert.message?.contains("积分:0") == true) XCTAssertTrue(alert.message?.contains("确认自愿放弃以上现金余额,并确认自愿放弃以上积分") == true) XCTAssertTrue(service.mutations.isEmpty) } /// 已确认资产不再弹窗;验证码与获取按钮同行,输入完整前禁用提交,键盘不遮挡操作区。 func testVerificationLayoutInputAndKeyboard() async throws { let service = CombinedDeregistrationService() service.walletWaived = true service.pointsWaived = true let model = StoreAccountDeregistrationViewModel(storeUserID: 101) let controller = StoreAccountDeregistrationViewController(identityName: "北大科技园·测试身份", viewModel: model, api: service) let window = makeWindow(controller, size: UIScreen.main.bounds.size) defer { close(window) } await waitUntil { model.canContinue } let next = try XCTUnwrap(find(controller.view, "deregister.continue") as? UIButton) next.sendActions(for: .touchUpInside) await waitUntil { model.step == .verification } window.layoutIfNeeded() let code = try XCTUnwrap(find(controller.view, "deregister.code") as? UITextField) let reason = try XCTUnwrap(find(controller.view, "deregister.reason") as? UITextField) let sms = try XCTUnwrap(find(controller.view, "deregister.sms") as? UIButton) let submit = try XCTUnwrap(find(controller.view, "deregister.submit") as? UIButton) XCTAssertEqual(code.textContentType, .oneTimeCode) XCTAssertEqual(code.keyboardType, .numberPad) XCTAssertEqual(code.superview, sms.superview) XCTAssertFalse(submit.isEnabled) capture(window, name: "redesign-verification") code.text = "123456" code.sendActions(for: .editingChanged) XCTAssertFalse(submit.isEnabled) reason.text = "测试" reason.sendActions(for: .editingChanged) XCTAssertTrue(submit.isEnabled) let navigationFrame = controller.navigationController?.view.frame code.becomeFirstResponder() await waitUntil { controller.view.keyboardLayoutGuide.layoutFrame.height > controller.view.safeAreaInsets.bottom + 80 } window.layoutIfNeeded() let frame = submit.convert(submit.bounds, to: controller.view) XCTAssertLessThanOrEqual(frame.maxY, controller.view.keyboardLayoutGuide.layoutFrame.minY + 1) XCTAssertGreaterThan(frame.minY, 0) capture(window, name: "redesign-verification-keyboard") reason.becomeFirstResponder() let scroll = try XCTUnwrap(allViews(controller.view).first { $0 is UIScrollView } as? UIScrollView) await waitUntil { window.layoutIfNeeded() return scroll.bounds.contains(reason.convert(reason.bounds, to: scroll)) } XCTAssertEqual(controller.navigationController?.view.frame, navigationFrame) capture(window, name: "redesign-reason-keyboard") reason.resignFirstResponder() XCTAssertTrue(service.mutations.isEmpty) } /// 非零资产与长阻断说明仍可滚动,业务未满足时不能继续。 func testNonzeroAssetsAndBusinessBlockers() async throws { let service = CombinedDeregistrationService() service.walletFen = 129900 service.points = 1280 service.extraBlockers = [["code": "ORDER_UNFULFILLED", "message": "账户仍有未履约订单或带单,请处理完成后再申请注销。", "action": "complete_orders"]] let model = StoreAccountDeregistrationViewModel(storeUserID: 101) let controller = StoreAccountDeregistrationViewController(identityName: "较长的门店身份名称用于验证自动换行展示", viewModel: model, api: service) let window = makeWindow(controller, size: CGSize(width: 375, height: 667)) defer { close(window) } await waitUntil { model.eligibility != nil } window.layoutIfNeeded() let next = try XCTUnwrap(find(controller.view, "deregister.continue") as? UIButton) XCTAssertFalse(next.isEnabled) let blockers = try XCTUnwrap(find(controller.view, "deregister.blockers") as? UILabel) XCTAssertTrue(blockers.text?.contains("未履约订单") == true) let texts = allViews(controller.view).compactMap { ($0 as? UILabel)?.text }.joined(separator: "\n") XCTAssertTrue(texts.contains("¥1299.00")) XCTAssertTrue(texts.contains("1280")) XCTAssertTrue(window.bounds.contains(next.convert(next.bounds, to: window))) capture(window, name: "redesign-business-blockers") XCTAssertTrue(service.mutations.isEmpty) } /// 失败与未知状态给出重试入口,不展示调试字段或伪造注销完成。 func testUnknownAndFailedStatusPages() async throws { for failed in [false, true] { let service = CombinedDeregistrationService() service.statusOverride = .init(deregister: .object(["status": .number(123)])) service.readError = failed ? APIError.networkFailed("debug-internal-error") : nil let suite = "RedesignStatus.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! let session = AppSessionStore(defaults: defaults) session.accountType = .storeUser; session.userId = "101"; session.token = "mock-token" session.accountDisplayName = "测试门店" let controller = StoreAccountDeregistrationAccessViewController(identity: try .init(session: session), api: service, session: session) { _ in XCTFail("不能进入业务") } let window = makeWindow(controller, size: CGSize(width: 375, height: 667)) defer { close(window); defaults.removePersistentDomain(forName: suite) } let title = try XCTUnwrap(find(controller.view, "deregister.access.title") as? UILabel) await waitUntil { title.text == (failed ? "暂时无法查询" : "注销状态待确认") } let retry = try XCTUnwrap(find(controller.view, "deregister.access.retry") as? UIButton) let cancel = try XCTUnwrap(find(controller.view, "deregister.access.cancel") as? UIButton) XCTAssertTrue(retry.isEnabled) XCTAssertFalse(retry.isHidden) XCTAssertTrue(cancel.isHidden) let texts = allViews(controller.view).compactMap { ($0 as? UILabel)?.text }.joined(separator: "\n") XCTAssertFalse(texts.contains("debug-internal-error")) XCTAssertFalse(texts.contains("本机提交记录")) window.layoutIfNeeded() capture(window, name: failed ? "redesign-query-failed" : "redesign-status-unknown") } } private weak var previousKeyWindow: UIWindow? private func makeWindow(_ controller: UIViewController, size: CGSize) -> UIWindow { let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first previousKeyWindow = scene?.windows.first(where: \.isKeyWindow) let window = scene.map { UIWindow(windowScene: $0) } ?? UIWindow(frame: CGRect(origin: .zero, size: size)) window.rootViewController = UINavigationController(rootViewController: controller) window.makeKeyAndVisible() window.frame = CGRect(origin: .zero, size: size) window.layoutIfNeeded() controller.loadViewIfNeeded() return window } private func close(_ window: UIWindow) { window.endEditing(true) window.rootViewController?.dismiss(animated: false) window.isHidden = true window.rootViewController = nil previousKeyWindow?.makeKeyAndVisible() } private func allViews(_ view: UIView) -> [UIView] { view.subviews.flatMap { [$0] + allViews($0) } } private func find(_ view: UIView, _ id: String) -> UIView? { allViews(view).first { $0.accessibilityIdentifier == id } } private func waitUntil(_ condition: () -> Bool) async { for _ in 0..<150 { if condition() { return } try? await Task.sleep(nanoseconds: 10_000_000) } XCTFail("等待页面状态超时") } private func capture(_ window: UIWindow, name: String) { let attachment = XCTAttachment(image: UIGraphicsImageRenderer(bounds: window.bounds).image { _ in window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) }) attachment.name = name attachment.lifetime = .keepAlways add(attachment) } } /// 可控制部分成功与异步停顿的Mock;不会读写AppStore或调用真实接口。 @MainActor private final class CombinedDeregistrationService: StoreAccountDeregistrationServing { var userID = 101 var financeID = 201 var walletFen: Int64 = 0 var points: Int64 = 0 var walletWaived = false var pointsWaived = false var extraBlockers: [[String: String]] = [] var mutations: [String] = [] var pointsError: Error? var readError: Error? var onWallet: (() -> Void)? var confirmWalletOnServer = true var commitPointsBeforeError = false var holdWallet = false var walletContinuation: CheckedContinuation? var statusOverride: StoreAccountDeregistrationStatus? var statusCount = 0 var eligibilityCount = 0 var applyError: Error? var onApply: (() -> Void)? var holdApply = false var applyContinuation: CheckedContinuation? var holdNextStatus = false var statusContinuation: CheckedContinuation? func eligibility() async throws -> StoreAccountDeregistrationEligibility { eligibilityCount += 1 if let readError { throw readError } var blockers = extraBlockers if !walletWaived { blockers.append(["code": "WALLET_WAIVER_MISSING", "message": "请确认现金余额", "action": "confirm_wallet_waiver"]) } if !pointsWaived { blockers.append(["code": "POINTS_WAIVER_MISSING", "message": "请确认积分", "action": "confirm_points_waiver"]) } return try StoreAccountDeregistrationFixtures.eligibility(overrides: [ "store_user_id": userID, "finance_identity_id": financeID, "can_apply": blockers.isEmpty, "wallet_balance_fen": walletFen, "wallet_balance": String(format: "%.2f", Double(walletFen) / 100), "points_balance": points, "wallet_waived": walletWaived, "points_waived": pointsWaived, "unfulfilled_count": 0, "fulfillment_in_progress_count": 0, "risk_end_at": NSNull(), "eligible_at": NSNull(), "blockers": blockers, "deregister": StoreAccountDeregistrationFixtures.draftRecord() ]) } func status() async throws -> StoreAccountDeregistrationStatus { statusCount += 1 if holdNextStatus { holdNextStatus = false await withCheckedContinuation { statusContinuation = $0 } } if let readError { throw readError } return try statusOverride ?? StoreAccountDeregistrationFixtures.draftStatus() } func waiveWallet() async throws { mutations.append("wallet") if holdWallet { await withCheckedContinuation { walletContinuation = $0 } } walletWaived = confirmWalletOnServer onWallet?() } func waivePoints() async throws { mutations.append("points") if commitPointsBeforeError { pointsWaived = true } if let pointsError { throw pointsError } pointsWaived = true } func sendSMS() async throws { mutations.append("sms") } func apply(smsCode: String, reason: String) async throws { mutations.append("apply") if holdApply { await withCheckedContinuation { applyContinuation = $0 } } if let applyError { throw applyError } onApply?() } func finishApply() { let continuation = applyContinuation applyContinuation = nil continuation?.resume() } func finishStatus() { let continuation = statusContinuation statusContinuation = nil continuation?.resume() } func cancel() async throws { mutations.append("cancel") } }