feat: 优化账号注销流程

This commit is contained in:
2026-08-31 15:56:16 +08:00
parent e529bb5942
commit 4444d328df
16 changed files with 982 additions and 208 deletions
@@ -21,7 +21,7 @@ final class StoreAccountDeregistrationAccessTests: XCTestCase {
}
}
/// 在 iPhone 上渲染冷静期页面,确认撤销入口可见;不调用真实网络或修改接口。
/// 在 iPhone 上渲染不可返回的冷静期页面,只保留退出和撤销入口。
func testCoolingPageRendersCancellationEntryOnPhysicalDevice() async throws {
let name = "DeregistrationCoolingPage.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
@@ -30,6 +30,7 @@ final class StoreAccountDeregistrationAccessTests: XCTestCase {
session.accountType = .storeUser
session.userId = "101"
session.token = "isolated-token"
session.accountDisplayName = "示例门店"
let identity = try StoreAccountDeregistrationIdentity(session: session)
let service = try DeregistrationCancellationStub()
let controller = StoreAccountDeregistrationAccessViewController(identity: identity, api: service, session: session) { _ in
@@ -50,11 +51,16 @@ final class StoreAccountDeregistrationAccessTests: XCTestCase {
window.layoutIfNeeded()
XCTAssertFalse(cancel.isHidden)
XCTAssertTrue(cancel.isEnabled)
XCTAssertTrue(message.text?.contains("处于冷静期") == true)
XCTAssertTrue(message.text?.contains("当前身份:") == true)
let deadline = try XCTUnwrap(views.first { $0.accessibilityIdentifier == "deregister.access.deadline" } as? UILabel)
XCTAssertEqual(deadline.text, "2026-09-04 14:23:40")
XCTAssertFalse(message.text?.contains("604776") == true)
XCTAssertTrue(window.bounds.contains(cancel.convert(cancel.bounds, to: window)))
XCTAssertTrue(controller.navigationItem.hidesBackButton)
XCTAssertEqual((window.rootViewController as? UINavigationController)?.interactivePopGestureRecognizer?.isEnabled, false)
let texts = views.compactMap { ($0 as? UILabel)?.text }
XCTAssertTrue(texts.contains("重新登录该身份自动撤销申请"))
XCTAssertNil(views.first { $0.accessibilityIdentifier == "deregister.access.conditions" })
let attachment = XCTAttachment(image: UIGraphicsImageRenderer(bounds: window.bounds).image { context in
window.layer.render(in: context.cgContext)
})
@@ -193,32 +193,47 @@ final class StoreAccountDeregistrationCombinedTests: XCTestCase {
/// 两步页面的实际UIKit布局和交互测试;独立窗口不触碰用户真实会话。
@MainActor
final class StoreAccountDeregistrationRedesignTests: XCTestCase {
/// 明确受理后立即调用一次退出,不等待后续GET;在途连点及成功后再次点击均不重发。
func testAcceptedSubmissionExitsOnceWithoutPostSubmissionQuery() async throws {
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
service.onApply = { service.readError = APIError.networkFailed("成功后不应再查询") }
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: { logoutCount += 1 })
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: "不再使用")
controller.submitConfirmedApplication(smsCode: "654321", reason: " 不再使用 ")
await waitUntil { service.applyContinuation != nil }
controller.submitConfirmedApplication(smsCode: "654321", reason: "不再使用")
controller.submitConfirmedApplication(smsCode: "654321", reason: " 不再使用 ")
XCTAssertEqual(logoutCount, 0)
XCTAssertTrue(model.isBusy)
XCTAssertTrue(GlobalLoadingManager.shared.isShowing)
@@ -226,13 +241,16 @@ final class StoreAccountDeregistrationRedesignTests: XCTestCase {
capture(window, name: "deregister-submitting-global-loading")
service.finishApply()
await waitUntil { logoutCount == 1 }
controller.submitConfirmedApplication(smsCode: "654321", reason: "不再使用")
controller.submitConfirmedApplication(smsCode: "654321", reason: " 不再使用 ")
XCTAssertEqual(logoutCount, 1)
XCTAssertEqual(unresolvedCount, 0)
XCTAssertEqual(service.mutations, ["apply"])
XCTAssertEqual(service.statusCount, initialStatusCount + 1, "只保留提交前校验")
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)
@@ -250,17 +268,29 @@ final class StoreAccountDeregistrationRedesignTests: XCTestCase {
var unresolvedCount = 0
let controller = StoreAccountDeregistrationViewController(
identityName: "测试门店", viewModel: model, api: service,
onUnresolvedSubmission: { unresolvedCount += 1 }, onSubmissionAccepted: { logoutCount += 1 })
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)
}
@@ -281,7 +311,7 @@ final class StoreAccountDeregistrationRedesignTests: XCTestCase {
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)
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)
@@ -324,13 +354,16 @@ final class StoreAccountDeregistrationRedesignTests: XCTestCase {
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("同手机号的其他身份不受影响"))
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
@@ -338,7 +371,8 @@ final class StoreAccountDeregistrationRedesignTests: XCTestCase {
let model = StoreAccountDeregistrationViewModel(storeUserID: 101)
var logoutCount = 0
let controller = StoreAccountDeregistrationViewController(
identityName: "当前测试身份", viewModel: model, api: service, onSubmissionAccepted: { logoutCount += 1 })
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 }
@@ -346,23 +380,107 @@ final class StoreAccountDeregistrationRedesignTests: XCTestCase {
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)
let reason = try XCTUnwrap(find(controller.view, "deregister.reason") as? UITextView)
code.text = "654321"
reason.text = "不再使用"
reason.sendActions(for: .editingChanged)
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 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), ["暂不提交", "确认提交申请"])
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()
@@ -383,16 +501,21 @@ final class StoreAccountDeregistrationRedesignTests: XCTestCase {
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)
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
@@ -406,24 +529,37 @@ final class StoreAccountDeregistrationRedesignTests: XCTestCase {
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? 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 = "测试"
reason.sendActions(for: .editingChanged)
controller.textViewDidChange(reason)
XCTAssertTrue(submit.isEnabled)
XCTAssertTrue(reasonError.isHidden)
let navigationFrame = controller.navigationController?.view.frame
code.becomeFirstResponder()
await waitUntil { controller.view.keyboardLayoutGuide.layoutFrame.height > controller.view.safeAreaInsets.bottom + 80 }
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)
@@ -515,6 +651,14 @@ final class StoreAccountDeregistrationRedesignTests: XCTestCase {
}
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 }
@@ -559,6 +703,7 @@ private final class CombinedDeregistrationService: StoreAccountDeregistrationSer
var applyContinuation: CheckedContinuation<Void, Never>?
var holdNextStatus = false
var statusContinuation: CheckedContinuation<Void, Never>?
var lastReason: String?
func eligibility() async throws -> StoreAccountDeregistrationEligibility {
eligibilityCount += 1
@@ -600,6 +745,7 @@ private final class CombinedDeregistrationService: StoreAccountDeregistrationSer
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?()
@@ -44,7 +44,7 @@ final class StoreAccountDeregistrationRootTests: XCTestCase {
await waitUntil { context.message.contains("2026-09-04 15:27:24") }
XCTAssertNotNil(context.coordinator.accessController)
XCTAssertFalse(context.window.rootViewController === businessRoot)
XCTAssertTrue(context.message.contains("处于冷静期"))
XCTAssertTrue(context.message.contains("注销申请已提交"))
XCTAssertFalse(context.message.contains("604681"))
XCTAssertTrue(context.bindingSuspended)
XCTAssertEqual(context.resumeCount, 1)
@@ -167,7 +167,7 @@ final class StoreAccountDeregistrationRootTests: XCTestCase {
let original = context.window.rootViewController
context.service.current = try StoreAccountDeregistrationFixtures.lifecycleStatus()
context.coordinator.recordRestriction(requestToken: context.session.token)
await waitUntil { context.message.contains("处于冷静期") }
await waitUntil { context.message.contains("注销申请已提交") }
XCTAssertTrue(context.bindingSuspended)
XCTAssertEqual(context.resumeCount, 1)
let gate = context.coordinator.accessController
@@ -231,7 +231,7 @@ final class StoreAccountDeregistrationRootTests: XCTestCase {
XCTAssertTrue(context.window.rootViewController === loginRoot)
context.service.current = try StoreAccountDeregistrationFixtures.lifecycleStatus()
context.service.finishRead()
await waitUntil { context.message.contains("处于冷静期") }
await waitUntil { context.message.contains("注销申请已提交") }
XCTAssertNotNil(context.coordinator.accessController)
XCTAssertEqual(context.service.statusCount, 2)
XCTAssertEqual(context.resumeCount, 0)
@@ -292,7 +292,7 @@ final class StoreAccountDeregistrationRootTests: XCTestCase {
defer { context.cleanUp() }
context.service.current = try StoreAccountDeregistrationFixtures.lifecycleStatus()
await context.startChecking()
await waitUntil { context.message.contains("处于冷静期") }
await waitUntil { context.message.contains("注销申请已提交") }
let gate = try XCTUnwrap(context.coordinator.accessController)
let cancel = try XCTUnwrap(context.button("deregister.access.cancel"))
cancel.sendActions(for: .touchUpInside)
@@ -267,6 +267,40 @@ final class StoreAccountDeregistrationViewModelTests: XCTestCase {
XCTAssertFalse(model.canContinue)
}
/// 验证码与原因在任何查询和提交意图之前校验;空白及超长原因不能触发网络请求。
func testSubmitRejectsMissingOrOversizedRequiredInputWithoutSideEffects() async throws {
let cases = [
(code: "", reason: "正常原因", message: "请输入收到的短信验证码"),
(code: "654321", reason: "", message: "请输入注销原因"),
(code: "654321", reason: " \n", message: "请输入注销原因"),
(code: "654321", reason: String(repeating: "原", count: 51), message: "注销原因不能超过 50 个字")
]
for item in cases {
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.ready()
let suite = "DeregistrationRequiredReason.\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let store = StoreAccountDeregistrationSubmissionStore(
storeUserID: "101", environment: .testing, defaults: defaults
)
let model = StoreAccountDeregistrationViewModel(storeUserID: 101, submissionStore: store)
await model.refresh(api: service)
model.beginVerification()
let statusCount = service.statusCount
let eligibilityCount = service.eligibilityCount
await model.submit(smsCode: item.code, reason: item.reason, api: service)
XCTAssertEqual(model.errorMessage, item.message)
XCTAssertTrue(service.mutations.isEmpty)
XCTAssertEqual(service.statusCount, statusCount)
XCTAssertEqual(service.eligibilityCount, eligibilityCount)
XCTAssertFalse(model.submissionAttempted)
XCTAssertFalse(store.hasUnresolvedSubmission)
}
}
/// 申请响应丢失可能已被服务端受理,不能直接重试或显示失败后可重新申请。
func testLostApplyResponsePreventsAutomaticRetry() async throws {
let service = try DeregistrationServiceStub()
@@ -285,8 +319,8 @@ final class StoreAccountDeregistrationViewModelTests: XCTestCase {
XCTAssertEqual(model.step, .unresolvedRequest)
}
/// 服务端明确拒绝验证码时允许重新核验,但不能保留之前的提交权限。
func testServerRejectionRequiresFreshEligibility() async throws {
/// 服务端明确拒绝验证码时保留已核验条件并停留在手机验证,不要求重新确认资产。
func testVerificationCodeRejectionStaysInVerification() async throws {
let service = try DeregistrationServiceStub()
service.current = try StoreAccountDeregistrationFixtures.ready()
service.applyError = APIError.serverCode(100001, "验证码错误")
@@ -295,10 +329,10 @@ final class StoreAccountDeregistrationViewModelTests: XCTestCase {
model.beginVerification()
await model.submit(smsCode: "907182", reason: "不再使用", api: service)
XCTAssertFalse(model.submissionAttempted)
XCTAssertFalse(model.canContinue)
XCTAssertEqual(model.errorMessage, "验证码错误")
await model.refresh(api: service)
XCTAssertTrue(model.canContinue)
XCTAssertEqual(model.step, .verification)
XCTAssertNotNil(model.eligibility)
XCTAssertEqual(model.errorMessage, "验证码错误")
}
/// 展示全部真实阻断项,初始加载不会调用任何修改接口。
@@ -471,15 +505,19 @@ private final class DeregistrationServiceStub: StoreAccountDeregistrationServing
var readError: Error?
var applyError: Error?
var mutations: [String] = []
var eligibilityCount = 0
var statusCount = 0
var lastCode: String?
var lastReason: String?
init() throws { current = try StoreAccountDeregistrationFixtures.eligibility() }
func eligibility() async throws -> StoreAccountDeregistrationEligibility {
eligibilityCount += 1
if let readError { throw readError }
return current
}
func status() async throws -> StoreAccountDeregistrationStatus {
statusCount += 1
if let readError { throw readError }
return currentStatus
}