235 lines
13 KiB
Swift
235 lines
13 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")
|
|
}
|
|
}
|
|
|
|
/// 线下收款 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))
|
|
}
|
|
}
|