Files
suixinkan_uikit/suixinkanTests/OfflineCollectionFeatureTests.swift
T

264 lines
14 KiB
Swift

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")
XCTAssertEqual(OfflineCollectionMoney.displayAmount(200), "2")
XCTAssertEqual(OfflineCollectionMoney.displayAmount(210), "2.1")
XCTAssertEqual(OfflineCollectionMoney.displayAmount(222), "2.22")
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")
}
func testCalendarPageNavigationKeepsPositionAndUsesTodayAtFutureBoundary() throws {
let maximum = try XCTUnwrap(OfflineCollectionDate.date(from: "2026-08-26"))
let friday = try XCTUnwrap(OfflineCollectionDate.date(from: "2026-08-21"))
var week = OfflineCollectionCalendarState(selectedDate: friday, maximumDate: maximum)
XCTAssertTrue(week.canMovePage(1))
week.movePage(1)
XCTAssertEqual(OfflineCollectionDate.businessDate(for: week.selectedDate), "2026-08-26")
XCTAssertFalse(week.canMovePage(1))
let tuesday = try XCTUnwrap(OfflineCollectionDate.date(from: "2026-08-25"))
week = OfflineCollectionCalendarState(selectedDate: tuesday, maximumDate: maximum)
week.movePage(-1)
XCTAssertEqual(OfflineCollectionDate.businessDate(for: week.selectedDate), "2026-08-18")
let march = try XCTUnwrap(OfflineCollectionDate.date(from: "2025-03-31"))
var month = OfflineCollectionCalendarState(selectedDate: march, maximumDate: maximum, mode: .month)
month.movePage(-1)
XCTAssertEqual(OfflineCollectionDate.businessDate(for: month.selectedDate), "2025-02-28")
}
func testSelectedWeekRowIndexAndModeTogglePreserveDate() throws {
let selected = try XCTUnwrap(OfflineCollectionDate.date(from: "2026-08-12"))
var state = OfflineCollectionCalendarState(selectedDate: selected, maximumDate: selected)
XCTAssertEqual(state.selectedWeekRowIndex, 2)
state.toggleMode()
XCTAssertEqual(OfflineCollectionDate.businessDate(for: state.selectedDate), "2026-08-12")
XCTAssertEqual(state.selectedWeekRowIndex, 2)
}
}
/// 线下收款 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<T: Decodable>(_ json: String) throws -> T {
try JSONDecoder().decode(T.self, from: Data(json.utf8))
}
}