765 lines
42 KiB
Swift
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") }
|
|
}
|