Files
suixinkan_uikit/suixinkanTests/StoreAccountDeregistrationCombinedTests.swift
T

765 lines
42 KiB
Swift

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 {
override func setUp() {
super.setUp()
GlobalLoadingManager.shared.hideAll()
}
override func tearDown() {
GlobalLoadingManager.shared.hideAll()
super.tearDown()
}
/// 明确受理后只读获取截止时间并调用一次退出;在途连点及成功后再次点击均不重发。
func testAcceptedSubmissionCapturesCoolingDeadlineAndExitsOnce() async throws {
let service = CombinedDeregistrationService()
service.walletWaived = true
service.pointsWaived = true
service.holdApply = true
let coolingStatus = try StoreAccountDeregistrationFixtures.lifecycleStatus()
service.onApply = { service.statusOverride = coolingStatus }
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 acceptedDeadline: String?
var unresolvedCount = 0
let controller = StoreAccountDeregistrationViewController(
identityName: "测试门店", viewModel: model, api: service,
onUnresolvedSubmission: { unresolvedCount += 1 }, onSubmissionAccepted: { deadline in
acceptedDeadline = deadline
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.lastReason, "不再使用")
XCTAssertEqual(service.statusCount, initialStatusCount + 2, "提交前校验并在成功后只读获取截止时间")
XCTAssertEqual(service.eligibilityCount, initialEligibilityCount + 1)
XCTAssertTrue(model.submissionAccepted)
XCTAssertEqual(model.submittedCoolingUntil, coolingStatus.coolingUntil)
XCTAssertEqual(acceptedDeadline, coolingStatus.coolingUntil)
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: { _ in logoutCount += 1 })
let window = makeWindow(controller, size: UIScreen.main.bounds.size)
defer { close(window) }
await waitUntil { model.canContinue }
model.beginVerification()
let codeField = try XCTUnwrap(find(controller.view, "deregister.code") as? UITextField)
let reasonField = try XCTUnwrap(find(controller.view, "deregister.reason") as? UITextView)
codeField.text = "654321"
reasonField.text = "不再使用"
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(model.step, uncertain ? .unresolvedRequest : .verification)
XCTAssertEqual(model.canContinue, !uncertain)
let submit = try XCTUnwrap(find(controller.view, "deregister.submit") as? UIButton)
XCTAssertEqual(submit.isHidden, uncertain)
if !uncertain {
XCTAssertEqual(codeField.text, "654321")
XCTAssertEqual(reasonField.text, "不再使用")
}
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? UITextView)
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(texts.contains("同手机号的其他身份不受影响"))
XCTAssertFalse(texts.contains("到期由服务端复核"))
XCTAssertFalse(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: { _ in 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? UITextView)
code.text = "654321"
code.sendActions(for: .editingChanged)
let submit = try XCTUnwrap(find(controller.view, "deregister.submit") as? UIButton)
XCTAssertFalse(submit.isEnabled, "验证码和注销原因均为必填项")
reason.text = " 门店停业 "
controller.textViewDidChange(reason)
XCTAssertTrue(submit.isEnabled)
submit.sendActions(for: .touchUpInside)
await waitUntil { controller.presentedViewController is StoreAccountDeregistrationConfirmationSheetViewController }
let sheet = try XCTUnwrap(controller.presentedViewController as? StoreAccountDeregistrationConfirmationSheetViewController)
sheet.loadViewIfNeeded()
let texts = allViews(sheet.view).compactMap { ($0 as? UILabel)?.text }.joined(separator: "\n")
XCTAssertTrue(texts.contains("确认提交注销申请?"))
XCTAssertTrue(texts.contains("当前测试身份"))
XCTAssertTrue(texts.contains("7 天"))
XCTAssertTrue(texts.contains("注销原因"))
XCTAssertTrue(texts.contains("门店停业"))
XCTAssertTrue(texts.contains("正式完成后不可恢复"))
XCTAssertEqual((find(sheet.view, "deregister.sheet.cancel") as? UIButton)?.configuration?.title, "我再想想")
XCTAssertEqual((find(sheet.view, "deregister.sheet.confirm") as? UIButton)?.configuration?.title, "确认提交")
try? await Task.sleep(nanoseconds: 350_000_000)
try assertSheetHugsContent(sheet)
capture(window, name: "redesign-submit-confirmation")
XCTAssertTrue(service.mutations.isEmpty)
XCTAssertEqual(logoutCount, 0)
}
/// 申请受理后的退出态结果页只保留“完成”,不能返回或继续操作注销接口。
func testSubmittedPageOnlyAllowsCompletingToLogin() throws {
var doneCount = 0
let controller = StoreAccountDeregistrationSubmittedViewController(
identityName: " 当前测试身份 ",
coolingUntil: "2026-09-07 18:30:00"
) {
doneCount += 1
}
let window = makeWindow(controller, size: UIScreen.main.bounds.size)
defer { close(window) }
window.layoutIfNeeded()
let title = try XCTUnwrap(find(controller.view, "deregister.submitted.title") as? UILabel)
let identity = try XCTUnwrap(find(controller.view, "deregister.submitted.identity") as? UILabel)
let deadline = try XCTUnwrap(find(controller.view, "deregister.submitted.deadline") as? UILabel)
let done = try XCTUnwrap(find(controller.view, "deregister.submitted.done") as? UIButton)
XCTAssertEqual(title.text, "注销申请已提交")
XCTAssertEqual(identity.text, "当前身份:当前测试身份")
XCTAssertEqual(deadline.text, "2026-09-07 18:30:00")
XCTAssertEqual(done.configuration?.title, "完成")
XCTAssertNil(find(controller.view, "deregister.access.logout"))
XCTAssertNil(find(controller.view, "deregister.access.cancel"))
XCTAssertTrue(controller.navigationItem.hidesBackButton)
XCTAssertFalse(controller.navigationController?.interactivePopGestureRecognizer?.isEnabled ?? true)
XCTAssertTrue(window.bounds.contains(done.convert(done.bounds, to: window)))
capture(window, name: "redesign-submitted-signed-out")
done.sendActions(for: .touchUpInside)
done.sendActions(for: .touchUpInside)
XCTAssertEqual(doneCount, 1)
}
/// iPhone 11 画布按最终设计稿渲染资产页和资产确认弹层;步骤标题必须保持横排。
func testDesignReferenceAssetScreensOnPhysicalDevice() async throws {
let service = CombinedDeregistrationService()
service.walletFen = 1260
service.points = 20
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.canConfirmAssetsAndContinue }
window.layoutIfNeeded()
let labels = allViews(controller.view).compactMap { $0 as? UILabel }
for title in ["确认资产", "手机验证"] {
let label = try XCTUnwrap(labels.first { $0.text == title })
XCTAssertEqual(label.numberOfLines, 1)
XCTAssertLessThan(label.bounds.height, 28)
}
let assetStep = try XCTUnwrap(find(controller.view, "deregister.step.assets"))
let verificationStep = try XCTUnwrap(find(controller.view, "deregister.step.verification"))
XCTAssertEqual(assetStep.bounds.width, verificationStep.bounds.width, accuracy: 0.5)
XCTAssertEqual(assetStep.bounds.height, 46, accuracy: 0.5)
XCTAssertEqual(verificationStep.bounds.height, 46, accuracy: 0.5)
XCTAssertEqual(assetStep.backgroundColor, StoreAccountDeregistrationStyle.primary)
XCTAssertNotEqual(assetStep.backgroundColor, verificationStep.backgroundColor)
XCTAssertTrue(assetStep.accessibilityTraits.contains(.selected))
let pageTexts = labels.compactMap(\.text).joined(separator: "\n")
XCTAssertFalse(pageTexts.contains("同手机号的其他身份不受影响"))
XCTAssertFalse(pageTexts.contains("到期由服务端复核"))
XCTAssertFalse(pageTexts.contains("历史订单、财务及审计记录"))
capture(window, name: "redesign-assets-iphone11")
let next = try XCTUnwrap(find(controller.view, "deregister.continue") as? UIButton)
next.sendActions(for: .touchUpInside)
await waitUntil { controller.presentedViewController is StoreAccountDeregistrationConfirmationSheetViewController }
try? await Task.sleep(nanoseconds: 350_000_000)
let sheet = try XCTUnwrap(controller.presentedViewController as? StoreAccountDeregistrationConfirmationSheetViewController)
try assertSheetHugsContent(sheet)
capture(window, name: "redesign-asset-confirmation")
XCTAssertTrue(service.mutations.isEmpty)
}
/// 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 StoreAccountDeregistrationConfirmationSheetViewController }
let sheet = try XCTUnwrap(controller.presentedViewController as? StoreAccountDeregistrationConfirmationSheetViewController)
sheet.loadViewIfNeeded()
let sheetTexts = allViews(sheet.view).compactMap { ($0 as? UILabel)?.text }.joined(separator: "\n")
XCTAssertTrue(sheetTexts.contains("确认放弃账号资产"))
XCTAssertTrue(sheetTexts.contains("¥0.00"))
XCTAssertTrue(sheetTexts.contains("积分\n0"))
XCTAssertFalse(sheetTexts.contains("自愿放弃以上资产"))
XCTAssertFalse(sheetTexts.contains("零余额也需要确认"))
XCTAssertEqual((find(sheet.view, "deregister.sheet.cancel") as? UIButton)?.configuration?.title, "暂不确认")
XCTAssertEqual((find(sheet.view, "deregister.sheet.confirm") as? UIButton)?.configuration?.title, "确认放弃")
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 assetStep = try XCTUnwrap(find(controller.view, "deregister.step.assets"))
let verificationStep = try XCTUnwrap(find(controller.view, "deregister.step.verification"))
XCTAssertNotEqual(assetStep.backgroundColor, verificationStep.backgroundColor)
XCTAssertTrue(verificationStep.accessibilityTraits.contains(.selected))
XCTAssertFalse(assetStep.accessibilityTraits.contains(.selected))
let code = try XCTUnwrap(find(controller.view, "deregister.code") as? UITextField)
let reason = try XCTUnwrap(find(controller.view, "deregister.reason") as? UITextView)
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)
XCTAssertEqual(find(controller.view, "deregister.reason.title")?.accessibilityLabel, "注销原因,必填")
XCTAssertFalse(submit.isEnabled)
capture(window, name: "redesign-verification")
code.text = "123456"
code.sendActions(for: .editingChanged)
XCTAssertFalse(submit.isEnabled)
reason.becomeFirstResponder()
reason.resignFirstResponder()
controller.textViewDidEndEditing(reason)
let reasonError = try XCTUnwrap(find(controller.view, "deregister.reason.error") as? UILabel)
XCTAssertFalse(reasonError.isHidden)
XCTAssertEqual(reasonError.text, "请填写注销原因")
reason.text = "测试"
controller.textViewDidChange(reason)
XCTAssertTrue(submit.isEnabled)
XCTAssertTrue(reasonError.isHidden)
let navigationFrame = controller.navigationController?.view.frame
XCTAssertTrue(code.becomeFirstResponder())
try? await Task.sleep(nanoseconds: 100_000_000)
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 assertSheetHugsContent(_ sheet: StoreAccountDeregistrationConfirmationSheetViewController) throws {
sheet.view.layoutIfNeeded()
let confirm = try XCTUnwrap(find(sheet.view, "deregister.sheet.confirm"))
let buttonBottom = confirm.convert(confirm.bounds, to: sheet.view).maxY
let visualGap = sheet.view.bounds.height - sheet.view.safeAreaInsets.bottom - buttonBottom
XCTAssertGreaterThanOrEqual(visualGap, 10)
XCTAssertLessThanOrEqual(visualGap, 18, "弹层底部只应保留 12pt 操作间距")
}
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<Void, Never>?
var statusOverride: StoreAccountDeregistrationStatus?
var statusCount = 0
var eligibilityCount = 0
var applyError: Error?
var onApply: (() -> Void)?
var holdApply = false
var applyContinuation: CheckedContinuation<Void, Never>?
var holdNextStatus = false
var statusContinuation: CheckedContinuation<Void, Never>?
var lastReason: String?
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")
lastReason = reason
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") }
}