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