import XCTest import UIKit @testable import suixinkan /// 线下收款接口契约与精确金额测试。 @MainActor final class OfflineCollectionAPITests: XCTestCase { func testStatisticsAndDetailsUseRequiredQueriesWithoutPagination() async throws { let statistics = Data(#"{"code":100000,"msg":"success","data":{"today":{"date":"2026-08-25","total_amount":"350.00","paid_amount":"100.00","unpaid_amount":"250.00","collect_count":3},"pending":{"amount":"250.00","collect_count":2,"date_count":1,"dates":[{"date":"2026-08-25","unpaid_amount":"250.00","unpaid_count":2}]}}}"#.utf8) let details = Data(#"{"code":100000,"msg":"success","data":{"date":"2026-08-24","total_amount":"0.00","collect_count":0,"paid_amount":"0.00","paid_count":0,"unpaid_amount":"0.00","unpaid_count":0,"status":1,"status_text":"本日已结清","collects":[]}}"#.utf8) let session = MockURLSession(responses: [statistics, details]) let api = OfflineCollectionAPI(client: APIClient(environment: .testing, session: session)) let response = try await api.statistics(scenicId: 100) let empty = try await api.details(scenicId: 100, date: "2026-08-24") XCTAssertEqual(response.today.date, "2026-08-25") XCTAssertEqual(response.todaySummary.pendingAmountFen, 25_000) XCTAssertEqual(empty.collects.count, 0) let statisticsQuery = URLComponents(url: try XCTUnwrap(session.requests[0].url), resolvingAgainstBaseURL: false)?.queryItems XCTAssertEqual(statisticsQuery, [URLQueryItem(name: "scenic_id", value: "100")]) let detailQuery = try XCTUnwrap(URLComponents(url: try XCTUnwrap(session.requests[1].url), resolvingAgainstBaseURL: false)?.queryItems) XCTAssertTrue(detailQuery.contains(URLQueryItem(name: "scenic_id", value: "100"))) XCTAssertTrue(detailQuery.contains(URLQueryItem(name: "date", value: "2026-08-24"))) XCTAssertFalse(detailQuery.contains { $0.name == "page" || $0.name == "page_size" }) } func testRegisterAndSupplementBodiesMatchBackendContract() async throws { let registered = Data(#"{"code":100000,"msg":"success","data":{"collect_no":"OC1","amount":"12.30","pay_method":2,"pay_method_text":"微信","status":0,"status_text":"未补缴","id":101,"created_at":"2026-08-25 09:30:00"}}"#.utf8) let supplemented = Data(#"{"code":100000,"msg":"success","data":{"date":"2026-08-25","updated_count":1}}"#.utf8) let session = MockURLSession(responses: [registered, supplemented]) let api = OfflineCollectionAPI(client: APIClient(environment: .testing, session: session)) _ = try await api.register(.init(scenicId: 100, amount: "12.30", payMethod: 2)) _ = try await api.supplement(.init(scenicId: 100, businessDate: "2026-08-25", amountFen: 1_230, pendingCount: 1)) let registerBody = try jsonBody(session.requests[0]) XCTAssertNil(registerBody["request_id"]) XCTAssertEqual(registerBody["scenic_id"] as? Int, 100) XCTAssertEqual(registerBody["amount"] as? String, "12.30") XCTAssertNil(registerBody["status"]) let supplementBody = try jsonBody(session.requests[1]) XCTAssertEqual(supplementBody["date"] as? String, "2026-08-25") XCTAssertEqual(supplementBody["scenic_id"] as? Int, 100) XCTAssertNil(supplementBody["request_id"]) XCTAssertNil(supplementBody["expected_unpaid_amount"]) XCTAssertNil(supplementBody["expected_unpaid_count"]) } func testMoneyNeverUsesFloatingPoint() { XCTAssertEqual(OfflineCollectionMoney.parseFen("0.01"), 1) XCTAssertEqual(OfflineCollectionMoney.parseFen("12.3"), 1_230) XCTAssertEqual(OfflineCollectionMoney.apiAmount(9_999_999), "99999.99") XCTAssertNil(OfflineCollectionMoney.parseFen("0")) XCTAssertNil(OfflineCollectionMoney.parseFen("100000.00")) XCTAssertNil(OfflineCollectionMoney.parseFen("1.001")) } private func jsonBody(_ request: URLRequest) throws -> [String: Any] { try XCTUnwrap(JSONSerialization.jsonObject(with: try XCTUnwrap(request.httpBody)) as? [String: Any]) } } /// 线下收款周/月日历边界测试。 final class OfflineCollectionCalendarTests: XCTestCase { func testMondayFirstWeekAndSixRowMonth() throws { let selected = try XCTUnwrap(OfflineCollectionDate.date(from: "2026-08-25")) var state = OfflineCollectionCalendarState(selectedDate: selected, maximumDate: selected) XCTAssertEqual(state.weekDates.map { OfflineCollectionDate.businessDate(for: $0) }, [ "2026-08-24", "2026-08-25", "2026-08-26", "2026-08-27", "2026-08-28", "2026-08-29", "2026-08-30", ]) state.toggleMode() XCTAssertEqual(state.monthDates.count, 42) XCTAssertEqual(OfflineCollectionDate.businessDate(for: state.monthDates[0]), "2026-07-27") } func testMonthSwipeClampsDayAndFutureDate() throws { let maximum = try XCTUnwrap(OfflineCollectionDate.date(from: "2026-08-25")) let january = try XCTUnwrap(OfflineCollectionDate.date(from: "2024-01-31")) var state = OfflineCollectionCalendarState(selectedDate: january, maximumDate: maximum, mode: .month) state.movePage(1) XCTAssertEqual(OfflineCollectionDate.businessDate(for: state.selectedDate), "2024-02-29") XCTAssertFalse(state.select(try XCTUnwrap(OfflineCollectionDate.date(from: "2026-08-26")))) state.movePage(40) XCTAssertEqual(OfflineCollectionDate.businessDate(for: state.selectedDate), "2026-08-25") } } /// 线下收款 ViewModel 的并发与幂等行为测试。 @MainActor final class OfflineCollectionViewModelTests: XCTestCase { func testRegistrationViewMatchesReferenceStructure() throws { let controller = OfflineCollectionRegistrationViewController( context: .init(collectorName: "张三", storeName: "那拉提旅拍一店", scenicId: 100, scenicName: "那拉提景区"), api: OfflineCollectionFakeAPI() ) controller.loadViewIfNeeded() controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) controller.view.layoutIfNeeded() let subviews = allSubviews(in: controller.view) let labels = subviews.compactMap { ($0 as? UILabel)?.text } let amountField = try XCTUnwrap(subviews.compactMap { $0 as? UITextField }.first { $0.accessibilityLabel == "收款金额" }) let methodButtons = subviews.compactMap { $0 as? OfflinePaymentMethodButton } let submitButton = try XCTUnwrap(subviews.compactMap { $0 as? UIButton }.first { $0.accessibilityIdentifier == "offlineCollection.submit" }) XCTAssertEqual(controller.title, "线下收款登记") XCTAssertTrue(labels.contains("收款金额")) XCTAssertTrue(labels.contains("收款方式")) XCTAssertTrue(labels.contains("登记后将计入今日待补缴,不生成订单。")) XCTAssertTrue(labels.contains("当前:张三 · 那拉提旅拍一店 · 那拉提景区")) XCTAssertEqual(amountField.font?.pointSize, 38) XCTAssertEqual(amountField.textAlignment, .right) XCTAssertEqual(methodButtons.count, 3) XCTAssertEqual(submitButton.configuration?.title, "确认登记") XCTAssertFalse(submitButton.configuration?.showsActivityIndicator ?? false) } func testDailyViewControllerLoadsWithoutConstraintCrash() { let controller = OfflineCollectionDailyViewController( businessDate: "2026-08-25", context: .init(collectorName: "张三", storeName: "门店", scenicId: 100, scenicName: "景区"), api: OfflineCollectionFakeAPI() ) controller.loadViewIfNeeded() XCTAssertNotNil(controller.view) } func testOfflineCollectionHomeCardsUseTheirEntireSurfaceForNavigation() throws { let homeView = OfflineCollectionHomeView(frame: CGRect(x: 0, y: 0, width: 390, height: 300)) var openedRegistration = false var openedToday = false homeView.onRegister = { openedRegistration = true } homeView.onOpenToday = { openedToday = true } homeView.apply( today: .init(businessDate: "2026-08-25", totalCount: 1, totalAmountFen: 1_230, settledCount: 0, settledAmountFen: 0, pendingCount: 1, pendingAmountFen: 1_230), overdueDayCount: 0, overdueRecordCount: 0, overdueAmountFen: 0, errorMessage: nil ) homeView.setNeedsLayout() homeView.layoutIfNeeded() let controls = allSubviews(in: homeView).compactMap { $0 as? UIControl } let registrationCard = try XCTUnwrap(controls.first { $0.accessibilityLabel?.hasPrefix("线下收款登记") == true }) let todayCard = try XCTUnwrap(controls.first { $0.accessibilityLabel?.hasPrefix("今日待补缴") == true }) XCTAssertTrue(registrationCard.hitTest(CGPoint(x: registrationCard.bounds.midX, y: registrationCard.bounds.midY), with: nil) === registrationCard) XCTAssertTrue(todayCard.hitTest(CGPoint(x: todayCard.bounds.midX, y: todayCard.bounds.midY), with: nil) === todayCard) registrationCard.sendActions(for: .touchUpInside) todayCard.sendActions(for: .touchUpInside) XCTAssertTrue(openedRegistration) XCTAssertTrue(openedToday) } func testRapidDateChangesDiscardOlderResponse() async throws { let api = OfflineCollectionFakeAPI() api.detailDelays["2026-08-24"] = 150_000_000 let viewModel = OfflineCollectionDailyViewModel( businessDate: "2026-08-25", context: .init(collectorName: "张三", storeName: "门店", scenicId: 100, scenicName: "景区"), api: api ) let older = Task { await viewModel.selectBusinessDate("2026-08-24") } try await Task.sleep(nanoseconds: 20_000_000) await viewModel.selectBusinessDate("2026-08-25") await older.value XCTAssertEqual(viewModel.businessDate, "2026-08-25") XCTAssertEqual(viewModel.summary.businessDate, "2026-08-25") } func testRegistrationRetryUsesExistingBackendRequestShape() async { let api = OfflineCollectionFakeAPI() api.registerFailuresRemaining = 1 let viewModel = OfflineCollectionRegistrationViewModel( context: .init(collectorName: "张三", storeName: "门店", scenicId: 100, scenicName: "景区"), api: api ) viewModel.updateAmount("12.30") await viewModel.submit() await viewModel.submit() XCTAssertEqual(api.registerRequests.count, 2) XCTAssertEqual(api.registerRequests[0].amount, api.registerRequests[1].amount) } private func allSubviews(in view: UIView) -> [UIView] { view.subviews.flatMap { [$0] + allSubviews(in: $0) } } } /// 为 ViewModel 测试提供可控延时和失败次数的线下收款接口替身。 @MainActor private final class OfflineCollectionFakeAPI: OfflineCollectionServing { var detailDelays: [String: UInt64] = [:] var registerFailuresRemaining = 0 private(set) var registerRequests: [OfflineCollectionRegisterRequest] = [] func statistics(scenicId: Int) async throws -> OfflineCollectionStatisticsResponse { try decode(#"{"today":{"date":"2026-08-25","total_amount":"12.30","paid_amount":"0.00","unpaid_amount":"12.30","collect_count":1},"pending":{"amount":"12.30","collect_count":1,"date_count":1,"dates":[{"date":"2026-08-25","unpaid_amount":"12.30","unpaid_count":1}]}}"#) } func details(scenicId: Int, date: String) async throws -> OfflineCollectionDetailsResponse { if let delay = detailDelays[date] { try? await Task.sleep(nanoseconds: delay) } return try decode(#"{"date":"\#(date)","total_amount":"0.00","collect_count":0,"paid_amount":"0.00","paid_count":0,"unpaid_amount":"0.00","unpaid_count":0,"status":1,"status_text":"本日已结清","collects":[]}"#) } func register(_ request: OfflineCollectionRegisterRequest) async throws -> OfflineCollectionRegisterResponse { registerRequests.append(request) if registerFailuresRemaining > 0 { registerFailuresRemaining -= 1 throw URLError(.networkConnectionLost) } return try decode(#"{"collect_no":"OC1","amount":"12.30","pay_method":2,"pay_method_text":"微信","status":0,"status_text":"未补缴","id":101,"created_at":"2026-08-25 09:30:00"}"#) } func supplement(_ request: OfflineSettlementRequest) async throws -> OfflineSettlementResult { try decode(#"{"date":"2026-08-25","updated_count":1}"#) } private func decode(_ json: String) throws -> T { try JSONDecoder().decode(T.self, from: Data(json.utf8)) } }