完善实名认证、钱包与打卡点模块 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

@ -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)))
}
}