feat: 完成9.7线下收款登记与日清
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
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<T: Decodable>(_ json: String) throws -> T {
|
||||
try JSONDecoder().decode(T.self, from: Data(json.utf8))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user