// // WalletFeatureTests.swift // suixinkanTests // import XCTest import UIKit @testable import suixinkan /// 钱包功能模型、API 和 ViewModel 测试。 @MainActor final class WalletFeatureTests: XCTestCase { func testWalletModelsDecodeLossyFields() throws { let withdrawJSON = Data(#"{"total":"2","item":[{"id":"10","amount":88.5,"created_at":"2026-07-01","settlement_status":"40","withdraw_status":1,"expected_at":"T+1","status_label":"打款中"}]}"#.utf8) let withdraw = try JSONDecoder().decode(WalletWithdrawListResponse.self, from: withdrawJSON) XCTAssertEqual(withdraw.total, 2) XCTAssertEqual(withdraw.item[0].id, 10) XCTAssertEqual(withdraw.item[0].amount, "88.5") XCTAssertEqual(withdraw.item[0].settlementStatus, 40) let earningJSON = Data(#"{"total_amount":12.3,"total_points":"7","total":"1","list":[{"date":"2026-07-01","day_amount":"12.30","day_points":"7","items":[{"id":"1","amount":"12.30","points":"7","type_label":"订单收益","order_number_suffix":"001","created_at":"10:00","withdraw_label":"可提现","source":"wallet"}]}]}"#.utf8) let earning = try JSONDecoder().decode(EarningDetailResponse.self, from: earningJSON) XCTAssertEqual(earning.totalAmount, "12.3") XCTAssertEqual(earning.totalPoints, 7) XCTAssertEqual(earning.list[0].items[0].points, 7) let pointJSON = Data(#"{"total":"1","list":[{"id":"2","points":"24","amount":"2.0","status":"2","payment_time":"2026-07-02","created_at":"2026-07-01"}]}"#.utf8) let points = try JSONDecoder().decode(PointWithdrawListResponse.self, from: pointJSON) XCTAssertEqual(points.total, 1) XCTAssertEqual(points.list[0].points, 24) XCTAssertEqual(points.list[0].amount, 2.0) } func testWalletAPIUsesExpectedRequests() async throws { let session = MockURLSession(responses: [ Self.envelope(#"{"amount_total":"100.00","amount_current_balance":"80.00","amount_withdrawable":"50.00"}"#), Self.envelope(#"{"total":0,"item":[]}"#), Self.envelope(#"{"total_amount":"0.00","total_points":0,"total":0,"list":[]}"#), Self.envelope(#"{"amount_withdrawable":"50.00","min_withdraw_amount":"1","max_single_withdraw_amount":"100","max_daily_withdraw_amount":"200","user_phone":"13800138000","bank_card":{"real_name":"张三","bank_name":"招商银行","card_number":"622200001234"},"settlement":"T+1","withdraw_info":["说明"]}"#), Self.envelope(#"{}"#), Self.envelope(#"{}"#), Self.envelope(#"{"total_points":100,"available_points":80,"withdrawn_points":60,"pending_points":20,"time":"2026"}"#), Self.envelope(#"{}"#), Self.envelope(#"{"total":0,"list":[]}"#), ]) let api = WalletAPI(client: APIClient(environment: .testing, session: session)) _ = try await api.walletSummary(type: 0) _ = try await api.walletWithdrawList(page: 0, pageSize: 0) _ = try await api.earningDetail(startDate: "2026-07-01", endDate: "2026-07-09", page: 2, pageSize: 10) _ = try await api.withdrawInfo() try await api.withdrawSendSMS() try await api.withdrawApply(amount: "12.30", smsCode: "123456") _ = try await api.pointOverview(staffId: 9) try await api.pointWithdrawApply(points: 24, remark: "积分提现申请") _ = try await api.pointWithdrawList(status: nil, page: 1, pageSize: 10) XCTAssertEqual(session.requests.map { $0.url?.path }, [ "/api/yf-handset-app/photog/wallet/summary", "/api/yf-handset-app/photog/wallet/withdraw-list", "/api/yf-handset-app/photog/wallet/earning-detail", "/api/yf-handset-app/photog/wallet/withdraw-info", "/api/yf-handset-app/photog/wallet/withdraw-send-sms", "/api/yf-handset-app/photog/wallet/withdraw-apply", "/api/yf-handset-app/photog/point/overview", "/api/yf-handset-app/photog/point/withdraw/apply", "/api/yf-handset-app/photog/point/withdraw/list", ]) 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) } func testWalletViewModelLoadsAndPaginates() async { let api = FakeWalletPageService() let viewModel = WalletViewModel(staffIdProvider: { 9 }) await viewModel.loadInitial(api: api) XCTAssertEqual(viewModel.summary?.amountWithdrawable, "50.00") XCTAssertEqual(viewModel.availablePoints, 80) XCTAssertEqual(viewModel.withdrawRecords.count, 1) XCTAssertEqual(viewModel.selectedTab, .withdraw) await viewModel.selectTab(.transaction, api: api) XCTAssertEqual(viewModel.transactionEntries.count, 2) XCTAssertEqual(viewModel.totalIncomeLabel, "¥ 12.30") await viewModel.selectFilter(.last30Days, api: api) XCTAssertEqual(viewModel.filter, .last30Days) 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() var profile = FakeWalletProfileService(realNameStatus: nil, bankCardStatus: nil) var destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile) XCTAssertEqual(destination, .realNameAuth) profile = FakeWalletProfileService(realNameStatus: 1, bankCardStatus: nil) 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) profile = FakeWalletProfileService(realNameStatus: 2, bankCardStatus: 1) 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) } func testWithdrawViewModelValidatesAndSubmits() async { let api = FakeWalletPageService() let viewModel = WithdrawViewModel() await viewModel.load(api: api) viewModel.updateAmount("0012.345") viewModel.updateVerificationCode("12a456") XCTAssertEqual(viewModel.amount, "12.34") 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 { let api = FakeWalletPageService() let viewModel = PointsRedemptionViewModel(staffIdProvider: { 9 }) await viewModel.loadInitial(api: api) viewModel.updatePointsInput("0") XCTAssertEqual(viewModel.pointsInput, "0") viewModel.updatePointsInput("999") XCTAssertEqual(viewModel.pointsInput, "60") await viewModel.applyWithdraw(api: api) XCTAssertEqual(api.pointApplyRequests, [60]) XCTAssertEqual(viewModel.pointsInput, "") 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) } private static func queryItems(from request: URLRequest) -> [String: String] { URLComponents(url: request.url!, resolvingAgainstBaseURL: false)? .queryItems? .reduce(into: [String: String]()) { result, item in result[item.name] = item.value } ?? [:] } private static func bodyString(from request: URLRequest) -> String { String(data: request.httpBody ?? Data(), encoding: .utf8) ?? "" } } @MainActor /// 钱包页面服务测试替身。 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") } func walletTransactionList( startDate: String, endDate: String, page: Int, pageSize: Int, type: Int ) async throws -> WalletTransactionListResponse { WalletTransactionListResponse() } 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: "打款中")] ) } 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", totalPoints: 7, total: 1, list: [ EarningDetailGroup( date: "2026-07-01", dayAmount: "12.30", dayPoints: 7, items: [EarningDetailItem(id: page, amount: "12.30", points: 7, typeLabel: "订单收益", createdAt: "10:00", withdrawLabel: "可提现")] ), ] ) } func withdrawInfo() async throws -> WithdrawInfoResponse { WithdrawInfoResponse( amountWithdrawable: "50.00", minWithdrawAmount: "1", maxSingleWithdrawAmount: "100", maxDailyWithdrawAmount: "200", userPhone: "13800138000", bankCard: WithdrawBankCardInfo(realName: "张三", bankName: "招商银行", cardNumber: "622200001234"), settlement: "T+1", withdrawInfo: ["说明"] ) } func withdrawSendSMS() async throws {} func withdrawApply(amount: String, smsCode: String) async throws { withdrawApplyRequests.append((amount, smsCode)) } func pointOverview(staffId: Int) async throws -> PointOverviewResponse { PointOverviewResponse(totalPoints: 100, availablePoints: 80, withdrawnPoints: 60, pendingPoints: 20) } func pointWithdrawApply(points: Int, remark: String) async throws { pointApplyRequests.append(points) } 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 { let realNameStatus: Int? let bankCardStatus: Int? func realNameInfo() async throws -> RealNameInfoResponse { let data: Data = if let realNameStatus { Data(#"{"real_name_info":{"real_name":"张三","id_card_no":"110101199001010010","audit_status":\#(realNameStatus),"is_long_valid":1}}"#.utf8) } else { Data(#"{"real_name_info":null}"#.utf8) } return try JSONDecoder().decode(RealNameInfoResponse.self, from: data) } func bankCardInfo() async throws -> BankCardInfoResponse { let data: Data = if let bankCardStatus { Data(#"{"bank_card":{"real_name":"张三","bank_name":"招商银行","branch_name":"支行","card_number":"622200001234","audit_status":\#(bankCardStatus),"audit_status_label":"审核中"}}"#.utf8) } else { Data(#"{"bank_card":null}"#.utf8) } 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))) } }