// // PaymentViewModelTests.swift // suixinkanTests // // Created by Codex on 2026/6/22. // import XCTest @testable import suixinkan @MainActor /// 收款模块测试,覆盖收款码、动态二维码、轮询和记录分组。 final class PaymentViewModelTests: XCTestCase { /// 测试无景区时清空二维码且不请求接口。 func testLoadPayCodeWithoutScenicClearsState() async { let api = PaymentMockAPI() let viewModel = makeViewModel() viewModel.staticPayUrl = "old" await viewModel.loadPayCode(api: api, scenicId: nil) XCTAssertTrue(viewModel.staticPayUrl.isEmpty) XCTAssertEqual(api.payCodeScenicIds, []) XCTAssertEqual(viewModel.errorMessage, "请先选择景区") } /// 测试收款码接口加载后生成当前二维码 URL。 func testLoadPayCodeUsesScenicIdAndAppliesStaticURL() async { let api = PaymentMockAPI() api.payCodeResponse = PayCodeResponse(staticPayUrl: "https://pay.example.com/static", dynamicPayUrl: "https://pay.example.com/dynamic") let viewModel = makeViewModel() await viewModel.loadPayCode(api: api, scenicId: 9) XCTAssertEqual(api.payCodeScenicIds, [9]) XCTAssertEqual(viewModel.currentPayUrl, "https://pay.example.com/static") XCTAssertTrue(viewModel.hasStaticPayCode) } /// 测试设置金额会校验金额并生成动态收款 URL。 func testApplyDynamicAmountBuildsDynamicURL() async { let viewModel = makeViewModel() viewModel.dynamicPayUrl = "https://pay.example.com/dynamic?scenic_id=9" viewModel.amountText = "12.345" viewModel.remarkText = "茶水" XCTAssertTrue(viewModel.applyDynamicAmount()) XCTAssertTrue(viewModel.currentPayUrl.contains("amount=12.35")) XCTAssertTrue(viewModel.currentPayUrl.contains("remark=")) XCTAssertEqual(viewModel.status, .waiting) } /// 测试确认金额会校验格式并更新二维码。 func testConfirmAmountUpdatesQRCode() { let viewModel = makeViewModel() viewModel.staticPayUrl = "https://pay.example.com/static" viewModel.dynamicPayUrl = "https://pay.example.com/dynamic?scenic_id=9" viewModel.amountText = "18.50" viewModel.remarkText = "门票" XCTAssertTrue(viewModel.confirmAmount()) XCTAssertTrue(viewModel.currentPayUrl.contains("amount=18.5")) XCTAssertEqual(viewModel.status, .waiting) XCTAssertNil(viewModel.scanSuccessTime) XCTAssertNil(viewModel.paymentSuccessData) } /// 测试扫码成功推送会写入扫码时间并清空付款成功态。 func testHandlePaymentPushScanSuccess() { let viewModel = makeViewModel() viewModel.paymentSuccessData = PaymentSuccessDisplayData( orderAmount: "10", orderNumber: "A", payTime: "2026-06-22 10:00", remark: "" ) viewModel.handlePaymentPush(type: 6, dataJSON: nil) XCTAssertNotNil(viewModel.scanSuccessTime) XCTAssertNil(viewModel.paymentSuccessData) } /// 测试付款成功推送会解析详情并清空扫码态。 func testHandlePaymentPushPaymentSuccess() { let viewModel = makeViewModel() viewModel.scanSuccessTime = "2026-06-22 10:00" let payload = """ {"order_amount":"88.00","order_number":"ORDER-1","pay_time":"2026-06-22 10:01","remark":"测试"} """ viewModel.handlePaymentPush(type: 1, dataJSON: payload) XCTAssertNil(viewModel.scanSuccessTime) XCTAssertEqual(viewModel.paymentSuccessData?.orderAmount, "88.00") XCTAssertEqual(viewModel.paymentSuccessData?.orderNumber, "ORDER-1") XCTAssertEqual(viewModel.paymentSuccessData?.payTime, "2026-06-22 10:01") XCTAssertEqual(viewModel.paymentSuccessData?.remark, "测试") } /// 测试语音开关会写入偏好存储。 func testToggleVoiceBroadcastPersistsPreference() { let defaults = UserDefaults(suiteName: "PaymentViewModelTests.voice")! defaults.removePersistentDomain(forName: "PaymentViewModelTests.voice") let preferences = AppPreferencesStore(defaults: defaults) let viewModel = makeViewModel(preferencesStore: preferences) XCTAssertFalse(viewModel.isVoiceBroadcast) viewModel.toggleVoiceBroadcast() XCTAssertTrue(viewModel.isVoiceBroadcast) XCTAssertTrue(preferences.loadPaymentReceiveVoiceEnabled()) let reloaded = makeViewModel(preferencesStore: preferences) XCTAssertTrue(reloaded.isVoiceBroadcast) } /// 测试轮询命中新收款记录后进入成功态并填充详情。 func testPollingFindsNewPaymentRecord() async { let api = PaymentMockAPI() api.recordResponses = [ PaymentCollectionRecordResponse(list: [ PaymentCollectionRecordItem(orderNumber: "A", userPhone: "138", orderAmount: "8.00", createDate: "2026-06-22", createTime: "10:00") ]), PaymentCollectionRecordResponse(list: [ PaymentCollectionRecordItem(orderNumber: "A", userPhone: "138", orderAmount: "8.00", createDate: "2026-06-22", createTime: "10:00"), PaymentCollectionRecordItem(orderNumber: "B", userPhone: "139", orderAmount: "12.35", createDate: "2026-06-22", createTime: "10:01") ]) ] let viewModel = makeViewModel() viewModel.amountText = "12.35" viewModel.remarkText = "备注" await viewModel.primePaymentRecords(api: api, scenicId: 9) await viewModel.pollUntilPaymentDetected(api: api, scenicId: 9, maxAttempts: 1, intervalNanoseconds: 0) XCTAssertEqual(viewModel.status, .success(PaymentCollectionRecordItem(orderNumber: "B", userPhone: "139", orderAmount: "12.35", createDate: "2026-06-22", createTime: "10:01"))) XCTAssertEqual(viewModel.paymentSuccessData?.orderAmount, "12.35") XCTAssertEqual(viewModel.paymentSuccessData?.orderNumber, "B") XCTAssertEqual(viewModel.paymentSuccessData?.payTime, "2026-06-22 10:01") XCTAssertEqual(viewModel.paymentSuccessData?.remark, "备注") } /// 测试后端缺少日汇总时能从列表兜底生成分组。 func testRecordGroupsFallbackFromList() { let response = PaymentCollectionRecordResponse(list: [ PaymentCollectionRecordItem(orderNumber: "A", userPhone: "138", orderAmount: "8.00", createDate: "2026-06-21", createTime: "10:00"), PaymentCollectionRecordItem(orderNumber: "B", userPhone: "139", orderAmount: "12.00", createDate: "2026-06-21", createTime: "10:01") ]) let groups = PaymentCollectionRecordViewModel.makeGroups(from: response) XCTAssertEqual(groups.count, 1) XCTAssertEqual(groups[0].analyse.orderCount, 2) XCTAssertEqual(groups[0].analyse.orderAmountSum, "20") } /// 创建测试用 ViewModel,并允许注入独立偏好存储。 private func makeViewModel(preferencesStore: AppPreferencesStore = AppPreferencesStore(defaults: UserDefaults(suiteName: "PaymentViewModelTests.default")!)) -> PaymentCollectionViewModel { PaymentCollectionViewModel(preferencesStore: preferencesStore) } } @MainActor /// 收款 API 测试替身。 private final class PaymentMockAPI: PaymentServing { var payCodeResponse = PayCodeResponse() var recordResponses: [PaymentCollectionRecordResponse] = [] private(set) var payCodeScenicIds: [Int] = [] private(set) var recordScenicIds: [Int] = [] /// 记录景区 ID 并返回收款码响应。 func payCode(scenicId: Int) async throws -> PayCodeResponse { payCodeScenicIds.append(scenicId) return payCodeResponse } /// 记录景区 ID 并返回下一份收款记录响应。 func paymentCollectionRecords(scenicId: Int) async throws -> PaymentCollectionRecordResponse { recordScenicIds.append(scenicId) return recordResponses.isEmpty ? PaymentCollectionRecordResponse() : recordResponses.removeFirst() } }