完善实名认证、钱包与打卡点模块 UI 与业务逻辑。

对齐 Android 实名认证流程与审核页、钱包提现/积分兑换界面,优化打卡点列表与详情交互,并补充相关资源、主题 token 与单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-13 16:28:10 +08:00
parent 07b2b9d459
commit 5138c1c11a
63 changed files with 3101 additions and 745 deletions

View File

@ -62,4 +62,54 @@ final class ProfileModelsTests: XCTestCase {
XCTAssertTrue(info.verified)
}
func testRealNameInfoDecodesCompleteAuditDetails() throws {
let json = """
{
"real_name": "张三",
"id_card_no": "11010519491231002X",
"audit_status": "3",
"audit_status_text": "审核驳回",
"reject_reason": "国徽面照片模糊",
"start_date": "2020-01-01",
"end_date": "2030-01-01",
"is_long_valid": "0",
"front_url": "https://cdn.example.com/front.jpg",
"back_url": "https://cdn.example.com/back.jpg",
"auditor_id": "8",
"auditor": { "id": "8", "name": "审核员" },
"audit_at": "2026-07-13 12:00:00"
}
""".data(using: .utf8)!
let info = try JSONDecoder().decode(RealNameInfo.self, from: json)
XCTAssertEqual(info.auditStatus, 3)
XCTAssertEqual(info.rejectReason, "")
XCTAssertEqual(info.frontUrl, "https://cdn.example.com/front.jpg")
XCTAssertEqual(info.backUrl, "https://cdn.example.com/back.jpg")
XCTAssertEqual(info.auditorId, 8)
XCTAssertEqual(info.auditor?.name, "审核员")
XCTAssertEqual(info.auditAt, "2026-07-13 12:00:00")
}
func testRealNameInfoRemainsCompatibleWhenOptionalAuditFieldsAreMissing() throws {
let json = """
{
"real_name": "",
"id_card_no": "11010519491231002X",
"audit_status": 1,
"is_long_valid": 1
}
""".data(using: .utf8)!
let info = try JSONDecoder().decode(RealNameInfo.self, from: json)
XCTAssertEqual(info.auditStatus, 1)
XCTAssertTrue(info.isLongValid)
XCTAssertNil(info.rejectReason)
XCTAssertNil(info.frontUrl)
XCTAssertNil(info.auditor)
XCTAssertNil(info.auditAt)
}
}

View File

@ -82,4 +82,22 @@ final class PunchPointModelsTests: XCTestCase {
XCTAssertTrue(image.isUploaded)
XCTAssertFalse(image.isUploading)
}
func testOperatingAndAuditStatusPresentationMatchAndroid() {
XCTAssertEqual(PunchPointDisplayFormatter.operatingStatus(1).title, "营运中")
XCTAssertEqual(PunchPointDisplayFormatter.operatingStatus(2).title, "已暂停")
XCTAssertEqual(PunchPointDisplayFormatter.operatingStatus(4).title, "未上线")
XCTAssertEqual(PunchPointDisplayFormatter.auditStatus(1).title, "已通过")
XCTAssertEqual(PunchPointDisplayFormatter.auditStatus(2).textColor, 0x22C55E)
XCTAssertEqual(PunchPointDisplayFormatter.auditStatus(3).title, "待审核")
XCTAssertEqual(PunchPointDisplayFormatter.auditStatus(4).title, "已驳回")
}
func testAllAndroidFiltersKeepExactStatusValuesAndTitles() {
XCTAssertEqual(PunchPointFilterType.allCases.map(\.apiStatus), [0, 1, 2, 3, 4])
XCTAssertEqual(
PunchPointFilterType.allCases.map(\.title),
["全部", "运营中", "已暂停", "未审核", "审核未通过"]
)
}
}

View File

@ -39,8 +39,10 @@ final class PunchPointViewModelTests: XCTestCase {
let viewModel = PunchPointListViewModel(currentScenicIdProvider: { 9 })
var messages: [String] = []
var qrcode: String?
var removalID: Int64?
viewModel.onShowMessage = { messages.append($0) }
viewModel.onShowQRCode = { qrcode = $0 }
viewModel.onBeginDeleteAnimation = { removalID = $0 }
await viewModel.loadInitial(api: api)
viewModel.showQRCode(for: viewModel.items[1])
@ -50,6 +52,9 @@ final class PunchPointViewModelTests: XCTestCase {
XCTAssertEqual(messages, ["未审核通过不可查看二维码", "暂无二维码"])
XCTAssertEqual(qrcode, "https://cdn/qr.png")
XCTAssertEqual(removalID, 1)
XCTAssertEqual(viewModel.items.map(\.id), [1, 2])
viewModel.completeDeleteAnimation(id: 1)
XCTAssertEqual(viewModel.items.map(\.id), [2])
XCTAssertEqual(api.deletedIds, [1])
}
@ -140,6 +145,59 @@ final class PunchPointViewModelTests: XCTestCase {
XCTAssertTrue(api.addRequests.isEmpty)
}
func testImageLimitAcceptsNineAndRejectsFurtherSelection() async {
let uploader = MockPunchPointUploader()
let viewModel = PunchPointFormViewModel(currentScenicIdProvider: { 7 })
var messages: [String] = []
viewModel.onShowMessage = { messages.append($0) }
let images = (0 ..< 10).map {
PunchPointImageState(data: Data([UInt8($0)]), fileName: "\($0).jpg")
}
await viewModel.addLocalImages(images, uploader: uploader)
await viewModel.addLocalImages(
[PunchPointImageState(data: Data([99]), fileName: "extra.jpg")],
uploader: uploader
)
XCTAssertEqual(viewModel.images.count, 9)
XCTAssertEqual(uploader.uploads.count, 9)
XCTAssertEqual(messages.last, "最多只能选择9张图片")
}
func testFailedImageCanRetryAndBecomeUploaded() async {
let uploader = SequencedPunchPointUploader()
let viewModel = PunchPointFormViewModel(currentScenicIdProvider: { 7 })
await viewModel.addLocalImages(
[PunchPointImageState(data: Data([1]), fileName: "retry.jpg")],
uploader: uploader
)
XCTAssertNotNil(viewModel.images.first?.errorMessage)
await viewModel.retryUpload(at: 0, uploader: uploader)
XCTAssertNil(viewModel.images.first?.errorMessage)
XCTAssertEqual(viewModel.images.first?.uploadedURL, "https://cdn/retry.jpg")
XCTAssertEqual(uploader.callCount, 2)
}
func testAutoLocationFillsCoordinateAndAddress() async {
let location = MockPunchPointLocationProvider(
snapshot: HomeLocationSnapshot(latitude: 31.2, longitude: 121.5, address: "当前位置地址")
)
let viewModel = PunchPointFormViewModel(currentScenicIdProvider: { 7 }, locationProvider: location)
var changedCoordinate: CLLocationCoordinate2D?
viewModel.onCoordinateChange = { changedCoordinate = $0 }
await viewModel.autoLocation()
XCTAssertEqual(viewModel.coordinate?.latitude, 31.2)
XCTAssertEqual(viewModel.coordinate?.longitude, 121.5)
XCTAssertEqual(viewModel.address, "当前位置地址")
XCTAssertEqual(changedCoordinate?.latitude, 31.2)
}
private func item(id: Int64, status: Int = 1, mpQrcode: String? = nil) -> PunchPointItem {
PunchPointItem(
id: id,
@ -233,6 +291,25 @@ private final class SlowPunchPointUploader: PunchPointImageUploading {
}
}
@MainActor
private final class SequencedPunchPointUploader: PunchPointImageUploading {
var callCount = 0
func uploadPunchPointImage(
data: Data,
fileName: String,
scenicId: Int,
onProgress: @escaping (Int) -> Void
) async throws -> String {
callCount += 1
if callCount == 1 {
throw URLError(.cannotConnectToHost)
}
onProgress(100)
return "https://cdn/retry.jpg"
}
}
private final class MockPunchPointLocationProvider: LocationProviding, @unchecked Sendable {
let snapshot: HomeLocationSnapshot
let reverseAddress: String

View File

@ -0,0 +1,66 @@
//
// RealNameAuthAPITests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
/// API
@MainActor
final class RealNameAuthAPITests: XCTestCase {
func testInfoUsesAndroidGETPath() async throws {
let response = Data(#"{"code":100000,"msg":"success","data":{"real_name_info":null}}"#.utf8)
let session = MockURLSession(responses: [response])
let api = ProfileAPI(client: APIClient(environment: .testing, session: session))
_ = try await api.realNameInfo()
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "GET")
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/real-name/info")
}
func testSMSCodeUsesPOSTWithoutBody() async throws {
let response = Data(#"{"code":100000,"msg":"success","data":{}}"#.utf8)
let session = MockURLSession(responses: [response])
let api = ProfileAPI(client: APIClient(environment: .testing, session: session))
try await api.realNameSmsVerifyCode()
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/real-name/sms-verify-code")
XCTAssertNil(request.httpBody)
}
func testSubmitUsesAndroidJSONFields() async throws {
let response = Data(#"{"code":100000,"msg":"success","data":{}}"#.utf8)
let session = MockURLSession(responses: [response])
let api = ProfileAPI(client: APIClient(environment: .testing, session: session))
let payload = RealNameAuthRequest(
realName: "张三",
idCardNo: "11010519491231002X",
smsVerifyCode: "123456",
startDate: "2026-07-13",
endDate: "",
isLongValid: 1,
frontUrl: "front",
backUrl: "back"
)
try await api.realNameSubmit(payload)
let request = try XCTUnwrap(session.requests.first)
let body = try XCTUnwrap(request.httpBody)
let object = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any])
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/real-name/submit")
XCTAssertEqual(object["real_name"] as? String, "张三")
XCTAssertEqual(object["id_card_no"] as? String, "11010519491231002X")
XCTAssertEqual(object["sms_verify_code"] as? String, "123456")
XCTAssertEqual(object["is_long_valid"] as? Int, 1)
XCTAssertEqual(object["front_url"] as? String, "front")
XCTAssertEqual(object["back_url"] as? String, "back")
}
}

View File

@ -3,17 +3,157 @@
// suixinkanTests
//
import UIKit
import XCTest
@testable import suixinkan
/// ViewModel
@MainActor
final class RealNameAuthViewModelTests: XCTestCase {
func testValidationRequiresSmsCodeWhenNotVerified() {
func testNewApplicationStartsAtIdentityStepAndRequiresName() {
let viewModel = RealNameAuthViewModel()
XCTAssertEqual(viewModel.mode, .newApplication)
XCTAssertEqual(viewModel.currentStep, .identity)
XCTAssertFalse(viewModel.isAgreed)
XCTAssertFalse(viewModel.canGoNextStep)
XCTAssertEqual(viewModel.validationMessage, "请输入真实姓名")
}
func testInputFilteringMatchesFormRules() {
let viewModel = RealNameAuthViewModel()
viewModel.updateIdCardNo("11010519491231002xABC")
viewModel.updateSmsCode("1a2b345678901234")
XCTAssertEqual(viewModel.idCardNo, "11010519491231002X")
XCTAssertEqual(viewModel.smsCode, "12345678901")
}
func testModesFollowAuditStatus() throws {
XCTAssertEqual(RealNameAuthViewModel(info: try makeInfo(status: 1)).mode, .pending)
XCTAssertEqual(RealNameAuthViewModel(info: try makeInfo(status: 2)).mode, .approved)
XCTAssertEqual(RealNameAuthViewModel(info: try makeInfo(status: 3)).mode, .rejected)
}
func testRejectedInfoIsEditableAndCanAdvance() throws {
let viewModel = RealNameAuthViewModel(info: try makeInfo(status: 3))
XCTAssertTrue(viewModel.isAgreed)
XCTAssertTrue(viewModel.canGoNextStep)
XCTAssertNil(viewModel.goToVerificationStep())
XCTAssertEqual(viewModel.currentStep, .verification)
XCTAssertFalse(viewModel.canSubmit)
}
func testReadOnlyInfoCanBrowseSecondStepButCannotSubmit() throws {
let viewModel = RealNameAuthViewModel(info: try makeInfo(status: 2))
XCTAssertNil(viewModel.goToVerificationStep())
XCTAssertEqual(viewModel.currentStep, .verification)
XCTAssertFalse(viewModel.canSubmit)
viewModel.updateRealName("不能修改")
XCTAssertEqual(viewModel.realName, "张三")
}
func testLongValidityClearsEndDate() throws {
let viewModel = RealNameAuthViewModel(info: try makeInfo(status: 3))
XCTAssertNotNil(viewModel.endDate)
viewModel.updateLongValid(true)
XCTAssertTrue(viewModel.isLongValid)
XCTAssertNil(viewModel.endDate)
XCTAssertTrue(viewModel.canGoNextStep)
}
func testEndDateCannotBeEarlierThanStartDate() throws {
let viewModel = RealNameAuthViewModel(info: try makeInfo(status: 3))
viewModel.updateEndDate(Self.date("2026-07-12"))
XCTAssertFalse(viewModel.canGoNextStep)
XCTAssertEqual(viewModel.validationMessage, "证件结束日期不能早于起始日期")
}
func testAgreementControlsStepTransition() throws {
let viewModel = RealNameAuthViewModel(info: try makeInfo(status: 3))
XCTAssertTrue(viewModel.canGoNextStep)
viewModel.toggleAgreement()
XCTAssertFalse(viewModel.canGoNextStep)
XCTAssertEqual(viewModel.goToVerificationStep(), "请阅读并同意用户协议与隐私政策")
XCTAssertEqual(viewModel.currentStep, .identity)
}
func testImmediateUploadsProduceURLsAndEnableNextStep() async throws {
let viewModel = RealNameAuthViewModel()
viewModel.updateRealName("张三")
viewModel.updateIdCardNo("11010519491231002X")
viewModel.updateStartDate(Self.date("2026-07-13"))
viewModel.updateEndDate(Self.date("2036-07-13"))
viewModel.toggleAgreement()
let imageData = try XCTUnwrap(Self.imageData())
let uploader = MockRealNameOSSUploadService()
XCTAssertEqual(viewModel.validationMessage, "请输入短信验证码")
try await viewModel.uploadIdentityImage(data: imageData, side: .back, uploader: uploader, scenicId: 9, timestamp: 1)
try await viewModel.uploadIdentityImage(data: imageData, side: .front, uploader: uploader, scenicId: 9, timestamp: 2)
XCTAssertEqual(viewModel.backUrl, "https://cdn.example.com/real_name_back_1.jpg")
XCTAssertEqual(viewModel.frontUrl, "https://cdn.example.com/real_name_front_2.jpg")
XCTAssertEqual(viewModel.backUploadProgress, 100)
XCTAssertTrue(viewModel.canGoNextStep)
viewModel.deleteIdentityImage(side: .front)
XCTAssertTrue(viewModel.frontUrl.isEmpty)
XCTAssertFalse(viewModel.canGoNextStep)
}
func testUploadFailureClearsPendingPreviewAndURL() async throws {
let viewModel = RealNameAuthViewModel()
let imageData = try XCTUnwrap(Self.imageData())
do {
try await viewModel.uploadIdentityImage(
data: imageData,
side: .front,
uploader: MockRealNameOSSUploadService(shouldFail: true),
scenicId: 9
)
XCTFail("Expected upload failure")
} catch {
XCTAssertTrue(viewModel.frontUrl.isEmpty)
XCTAssertNil(viewModel.frontPreviewImageData)
XCTAssertFalse(viewModel.isFrontUploading)
}
}
func testRequestUsesNormalizedFieldsAndLongTermDate() throws {
let viewModel = RealNameAuthViewModel(info: try makeInfo(status: 3))
viewModel.updateLongValid(true)
viewModel.updateSmsCode("12a3456")
_ = viewModel.goToVerificationStep()
let request = try viewModel.makeRequest()
XCTAssertEqual(request.idCardNo, "11010519491231002X")
XCTAssertEqual(request.smsVerifyCode, "123456")
XCTAssertEqual(request.isLongValid, 1)
XCTAssertEqual(request.endDate, "")
}
func testSendCodeStartsSixtySecondCountdown() async throws {
let viewModel = RealNameAuthViewModel(info: try makeInfo(status: 3))
_ = viewModel.goToVerificationStep()
let response = Data(#"{"code":100000,"msg":"success","data":{}}"#.utf8)
let api = ProfileAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [response])))
try await viewModel.sendCode(api: api)
XCTAssertEqual(viewModel.otpCountdown, 60)
XCTAssertFalse(viewModel.canRequestCode)
XCTAssertEqual(viewModel.statusMessage, "验证码发送成功")
}
func testIsValidMainlandIDCardNumberChecksChecksum() {
@ -27,4 +167,67 @@ final class RealNameAuthViewModelTests: XCTestCase {
"11010519491231002X"
)
}
private func makeInfo(status: Int) throws -> RealNameInfo {
let json = Data(
"""
{
"real_name":"",
"id_card_no":"11010519491231002X",
"audit_status":\(status),
"audit_status_text":"",
"start_date":"2026-07-13",
"end_date":"2036-07-13",
"is_long_valid":0,
"front_url":"https://cdn.example.com/front.jpg",
"back_url":"https://cdn.example.com/back.jpg",
"auditor":{"id":1,"name":"审核员"},
"audit_at":"2026-07-13 12:00:00",
"reject_reason":"照片模糊"
}
""".utf8
)
return try JSONDecoder().decode(RealNameInfo.self, from: json)
}
private static func date(_ value: String) -> Date {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd"
return formatter.date(from: value)!
}
private static func imageData() -> Data? {
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 40, height: 24))
let image = renderer.image { context in
UIColor.white.setFill()
context.fill(CGRect(x: 0, y: 0, width: 40, height: 24))
}
return image.jpegData(compressionQuality: 0.9)
}
}
/// 实名认证上传测试替身。
@MainActor
private struct MockRealNameOSSUploadService: OSSUploadServing {
let shouldFail: Bool
init(shouldFail: Bool = false) {
self.shouldFail = shouldFail
}
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
"https://cdn.example.com/avatar.jpg"
}
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
if shouldFail { throw URLError(.cannotWriteToFile) }
onProgress(100)
return "https://cdn.example.com/\(fileName)"
}
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
"https://cdn.example.com/bank.jpg"
}
}

View File

@ -106,4 +106,52 @@ final class TravelAlbumOTGCoreTests: XCTestCase {
XCTAssertEqual(key, "punch_point/20260709/u900/aaaaaaaabbbbccccddddeeeeeeeeeeee_guide_photo.jpg")
}
func testOSSRealNameObjectKeyUsesAndroidUserPath() {
let previousUserId = AppStore.shared.session.userId
AppStore.shared.session.userId = "u901"
defer { AppStore.shared.session.userId = previousUserId }
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
let date = calendar.date(from: DateComponents(year: 2026, month: 7, day: 13))!
let uuid = UUID(uuidString: "11111111-2222-3333-4444-555555555555")!
let key = OSSUploadPolicy.objectKey(
fileName: "id/front.jpg",
scenicId: 9,
moduleType: "real_name_auth",
date: date,
uuid: uuid,
timeZone: TimeZone(secondsFromGMT: 0)!
)
XCTAssertEqual(key, "real_name_auth/20260713/u901/11111111222233334444555555555555_id_front.jpg")
}
func testOSSRealNamePathDoesNotChangeAvatarOrBankCardModules() {
let date = Date(timeIntervalSince1970: 0)
let uuid = UUID(uuidString: "11111111-2222-3333-4444-555555555555")!
let timeZone = TimeZone(secondsFromGMT: 0)!
let avatar = OSSUploadPolicy.objectKey(
fileName: "avatar.jpg",
scenicId: 9,
moduleType: "user_avatar",
date: date,
uuid: uuid,
timeZone: timeZone
)
let bankCard = OSSUploadPolicy.objectKey(
fileName: "bank.jpg",
scenicId: 9,
moduleType: "bank_card",
date: date,
uuid: uuid,
timeZone: timeZone
)
XCTAssertTrue(avatar.hasPrefix("avatar/19700101/9/"))
XCTAssertTrue(bankCard.hasPrefix("bank_card/19700101/9/"))
}
}

View File

@ -4,6 +4,7 @@
//
import XCTest
import UIKit
@testable import suixinkan
/// API ViewModel
@ -71,6 +72,8 @@ final class WalletFeatureTests: XCTestCase {
XCTAssertEqual(Self.queryItems(from: session.requests[1])["page"], "1")
XCTAssertEqual(Self.queryItems(from: session.requests[2])["start_date"], "2026-07-01")
XCTAssertEqual(Self.queryItems(from: session.requests[6])["staff_id"], "9")
XCTAssertNil(session.requests[4].httpBody)
XCTAssertNil(session.requests[6].httpBody)
XCTAssertEqual(Self.bodyString(from: session.requests[5]).contains(#""sms_code":"123456""#), true)
XCTAssertEqual(Self.bodyString(from: session.requests[7]).contains(#""points":24"#), true)
XCTAssertEqual(Self.bodyString(from: session.requests[8]).contains(#""page_size":10"#), true)
@ -96,6 +99,46 @@ final class WalletFeatureTests: XCTestCase {
XCTAssertGreaterThanOrEqual(api.earningQueries.count, 2)
}
func testWalletFiltersUseAndroidDateOffsets() async {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
let now = calendar.date(from: DateComponents(year: 2026, month: 7, day: 13, hour: 12))!
let api = FakeWalletPageService()
let viewModel = WalletViewModel(
staffIdProvider: { 9 },
calendar: calendar,
nowProvider: { now }
)
await viewModel.refreshTransaction(api: api)
await viewModel.selectFilter(.last30Days, api: api)
await viewModel.selectFilter(.last180Days, api: api)
XCTAssertEqual(api.earningQueries.map(\.start), ["2026-07-06", "2026-06-13", "2026-01-14"])
XCTAssertEqual(api.earningQueries.map(\.end), ["2026-07-13", "2026-07-13", "2026-07-13"])
}
func testRefreshFailureKeepsWalletAndPointRecords() async {
let api = FakeWalletPageService()
let walletViewModel = WalletViewModel(staffIdProvider: { 9 })
let pointsViewModel = PointsRedemptionViewModel(staffIdProvider: { 9 })
await walletViewModel.refreshWithdraw(api: api)
await walletViewModel.refreshTransaction(api: api)
await pointsViewModel.refreshWithdrawList(api: api)
api.shouldFailWithdrawList = true
api.shouldFailEarningDetail = true
api.shouldFailPointWithdrawList = true
await walletViewModel.refreshWithdraw(api: api)
await walletViewModel.refreshTransaction(api: api)
await pointsViewModel.refreshWithdrawList(api: api)
XCTAssertEqual(walletViewModel.withdrawRecords.count, 1)
XCTAssertEqual(walletViewModel.transactionGroups.count, 1)
XCTAssertEqual(pointsViewModel.withdrawRecords.count, 1)
}
func testWalletWithdrawDestinationFlow() async throws {
let viewModel = WalletViewModel()
@ -107,6 +150,12 @@ final class WalletFeatureTests: XCTestCase {
destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile)
XCTAssertEqual(destination, .realNamePending)
profile = FakeWalletProfileService(realNameStatus: 3, bankCardStatus: nil)
destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile)
guard case .realNameAudit = destination else {
return XCTFail("实名认证驳回应进入审核页")
}
profile = FakeWalletProfileService(realNameStatus: 2, bankCardStatus: nil)
destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile)
XCTAssertEqual(destination, .withdrawalSettings)
@ -115,6 +164,12 @@ final class WalletFeatureTests: XCTestCase {
destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile)
XCTAssertEqual(destination, .bankCardPending)
profile = FakeWalletProfileService(realNameStatus: 2, bankCardStatus: 3)
destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile)
guard case .withdrawalAudit = destination else {
return XCTFail("银行卡驳回应进入审核页")
}
profile = FakeWalletProfileService(realNameStatus: 2, bankCardStatus: 2)
destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile)
XCTAssertEqual(destination, .withdraw)
@ -129,11 +184,21 @@ final class WalletFeatureTests: XCTestCase {
viewModel.updateVerificationCode("12a456")
XCTAssertEqual(viewModel.amount, "12.34")
XCTAssertEqual(viewModel.verificationCode, "12456")
XCTAssertEqual(viewModel.verificationCode, "12a456")
let success = await viewModel.submit(api: api)
XCTAssertTrue(success)
XCTAssertEqual(api.withdrawApplyRequests.first?.amount, "12.34")
viewModel.updateAmount("0")
XCTAssertNil(viewModel.validationMessage)
viewModel.updateAmount("99")
XCTAssertEqual(viewModel.validationMessage, "金额不能大于可提现金额")
await viewModel.requestVerificationCode(api: api)
XCTAssertEqual(viewModel.countdown, 60)
viewModel.decrementCountdown()
XCTAssertEqual(viewModel.countdown, 59)
}
func testPointsRedemptionViewModelClampsAndRefreshes() async {
@ -141,6 +206,8 @@ final class WalletFeatureTests: XCTestCase {
let viewModel = PointsRedemptionViewModel(staffIdProvider: { 9 })
await viewModel.loadInitial(api: api)
viewModel.updatePointsInput("0")
XCTAssertEqual(viewModel.pointsInput, "0")
viewModel.updatePointsInput("999")
XCTAssertEqual(viewModel.pointsInput, "60")
@ -151,6 +218,18 @@ final class WalletFeatureTests: XCTestCase {
XCTAssertEqual(viewModel.withdrawRecords.count, 1)
}
func testWalletTokensAndHomeIconMatchAndroid() {
XCTAssertEqual(WalletTokens.pageBackground.walletHexRGB, 0xF5F6F8)
XCTAssertEqual(WalletTokens.withdrawPageBackground.walletHexRGB, 0xF9FAFB)
XCTAssertEqual(WalletTokens.summaryBackground.walletHexRGB, 0x1677FF)
XCTAssertEqual(WalletTokens.primary.walletHexRGB, 0x0073FF)
XCTAssertEqual(WalletTokens.summaryRadius, 24)
XCTAssertEqual(WalletTokens.contentRadius, 16)
XCTAssertEqual(WalletTokens.cardRadius, 12)
XCTAssertEqual(HomeMenuCatalog.item(for: "wallet")?.iconName, "home_menu_wallet")
XCTAssertNotNil(UIImage(named: "home_menu_wallet"))
}
private static func envelope(_ dataJSON: String) -> Data {
Data(#"{"code":100000,"msg":"success","data":\#(dataJSON)}"#.utf8)
}
@ -175,6 +254,9 @@ private final class FakeWalletPageService: WalletPageServing {
private(set) var earningQueries: [(start: String, end: String, page: Int)] = []
private(set) var withdrawApplyRequests: [(amount: String, smsCode: String)] = []
private(set) var pointApplyRequests: [Int] = []
var shouldFailWithdrawList = false
var shouldFailEarningDetail = false
var shouldFailPointWithdrawList = false
func walletSummary(type: Int) async throws -> WalletSummaryResponse {
WalletSummaryResponse(amountTotal: "100.00", amountCurrentBalance: "80.00", amountWithdrawable: "50.00")
@ -191,6 +273,7 @@ private final class FakeWalletPageService: WalletPageServing {
}
func walletWithdrawList(page: Int, pageSize: Int) async throws -> WalletWithdrawListResponse {
if shouldFailWithdrawList { throw FakeWalletError.requestFailed }
WalletWithdrawListResponse(
total: 1,
item: [WalletWithdrawItem(id: page, amount: "10.00", createdAt: "2026-07-01", settlementStatus: 40, statusLabel: "打款中")]
@ -198,6 +281,7 @@ private final class FakeWalletPageService: WalletPageServing {
}
func earningDetail(startDate: String, endDate: String, page: Int, pageSize: Int) async throws -> EarningDetailResponse {
if shouldFailEarningDetail { throw FakeWalletError.requestFailed }
earningQueries.append((startDate, endDate, page))
return EarningDetailResponse(
totalAmount: "12.30",
@ -242,10 +326,18 @@ private final class FakeWalletPageService: WalletPageServing {
}
func pointWithdrawList(status: Int?, page: Int, pageSize: Int) async throws -> PointWithdrawListResponse {
if shouldFailPointWithdrawList { throw FakeWalletError.requestFailed }
PointWithdrawListResponse(total: 1, list: [PointWithdrawItem(id: page, points: 24, amount: 2, status: 1, createdAt: "2026-07-01")])
}
}
///
private enum FakeWalletError: LocalizedError {
case requestFailed
var errorDescription: String? { "请求失败" }
}
@MainActor
///
private struct FakeWalletProfileService: WalletProfileServing {
@ -270,3 +362,14 @@ private struct FakeWalletProfileService: WalletProfileServing {
return try JSONDecoder().decode(BankCardInfoResponse.self, from: data)
}
}
private extension UIColor {
var walletHexRGB: UInt {
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
getRed(&red, green: &green, blue: &blue, alpha: &alpha)
return UInt(Int(round(red * 255)) << 16 | Int(round(green * 255)) << 8 | Int(round(blue * 255)))
}
}