新增 CooperationOrder 模块(双 Tab 列表、获客员绑定、主 Tab 扫码)、订单来源/带客单绑定及配套 API 测试;同步引入 roleCode 权限匹配与相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
239 lines
9.9 KiB
Swift
239 lines
9.9 KiB
Swift
//
|
||
// StatisticsViewModelTests.swift
|
||
// suixinkanTests
|
||
//
|
||
// Created by Codex on 2026/6/22.
|
||
//
|
||
|
||
import XCTest
|
||
@testable import suixinkan
|
||
|
||
@MainActor
|
||
/// 数据统计 ViewModel 测试,覆盖汇总、日数据、分页和角色接口选择。
|
||
final class StatisticsViewModelTests: XCTestCase {
|
||
/// 测试首屏会加载汇总和第一页日数据。
|
||
func testReloadLoadsSummaryAndDailyItems() async throws {
|
||
let api = MockStatisticsService()
|
||
api.summaryResponses = [try TestFixture.payload(StatisticsSummaryResponse.self, named: "statistics_summary_success")]
|
||
api.dailyResponses = [try TestFixture.payload(DataListPayload<StatisticsDailyItem>.self, named: "statistics_daily_page1_total3")]
|
||
let viewModel = StatisticsViewModel()
|
||
|
||
try await viewModel.reload(api: api, scenicId: 88, appRole: nil)
|
||
|
||
XCTAssertEqual(viewModel.summary.orderCount, 18)
|
||
XCTAssertEqual(viewModel.dailyItems.map(\.date), ["2026-05-23"])
|
||
XCTAssertEqual(viewModel.totalDailyCount, 3)
|
||
XCTAssertTrue(viewModel.hasMore)
|
||
XCTAssertFalse(viewModel.loading)
|
||
XCTAssertNotNil(viewModel.lastRefreshAt)
|
||
XCTAssertEqual(api.summaryCalls.map(\.range), ["1"])
|
||
XCTAssertEqual(api.dailyCalls.map(\.page), [1])
|
||
}
|
||
|
||
/// 测试切换时间段会重置分页并使用对应 range。
|
||
func testSelectPeriodResetsPaginationAndUsesExpectedRange() async throws {
|
||
let api = MockStatisticsService()
|
||
api.summaryResponses = [
|
||
try TestFixture.payload(StatisticsSummaryResponse.self, named: "statistics_summary_success"),
|
||
try TestFixture.payload(StatisticsSummaryResponse.self, named: "statistics_summary_success")
|
||
]
|
||
api.dailyResponses = [
|
||
try TestFixture.payload(DataListPayload<StatisticsDailyItem>.self, named: "statistics_daily_page1_total3"),
|
||
try TestFixture.payload(DataListPayload<StatisticsDailyItem>.self, named: "statistics_daily_page2_total3"),
|
||
try TestFixture.payload(DataListPayload<StatisticsDailyItem>.self, named: "statistics_daily_page1_total3")
|
||
]
|
||
let viewModel = StatisticsViewModel()
|
||
|
||
try await viewModel.reload(api: api, scenicId: 88, appRole: nil)
|
||
try await viewModel.loadMore(api: api, scenicId: 88, appRole: nil)
|
||
XCTAssertEqual(viewModel.dailyItems.count, 3)
|
||
|
||
try await viewModel.selectPeriod(.sevenDays, api: api, scenicId: 88, appRole: nil)
|
||
|
||
XCTAssertEqual(viewModel.selectedPeriod, .sevenDays)
|
||
XCTAssertEqual(viewModel.dailyItems.map(\.date), ["2026-05-23"])
|
||
XCTAssertEqual(viewModel.totalDailyCount, 3)
|
||
XCTAssertTrue(viewModel.hasMore)
|
||
XCTAssertEqual(api.summaryCalls.last?.range, "3")
|
||
XCTAssertEqual(api.dailyCalls.last?.page, 1)
|
||
XCTAssertFalse(api.dailyCalls.last?.startTime.isEmpty ?? true)
|
||
XCTAssertFalse(api.dailyCalls.last?.endTime.isEmpty ?? true)
|
||
}
|
||
|
||
/// 测试加载更多会追加日数据。
|
||
func testLoadMoreAppendsDailyItems() async throws {
|
||
let api = MockStatisticsService()
|
||
api.summaryResponses = [try TestFixture.payload(StatisticsSummaryResponse.self, named: "statistics_summary_success")]
|
||
api.dailyResponses = [
|
||
try TestFixture.payload(DataListPayload<StatisticsDailyItem>.self, named: "statistics_daily_page1_total3"),
|
||
try TestFixture.payload(DataListPayload<StatisticsDailyItem>.self, named: "statistics_daily_page2_total3")
|
||
]
|
||
let viewModel = StatisticsViewModel()
|
||
|
||
try await viewModel.reload(api: api, scenicId: 88, appRole: nil)
|
||
try await viewModel.loadMore(api: api, scenicId: 88, appRole: nil)
|
||
|
||
XCTAssertEqual(viewModel.dailyItems.map(\.date), ["2026-05-23", "2026-05-22", "2026-05-21"])
|
||
XCTAssertFalse(viewModel.hasMore)
|
||
XCTAssertFalse(viewModel.loadingMore)
|
||
XCTAssertEqual(api.dailyCalls.map(\.page), [1, 2])
|
||
}
|
||
|
||
/// 测试加载更多失败时保留已有数据和下一页能力。
|
||
func testLoadMoreFailureKeepsCurrentItems() async throws {
|
||
let api = MockStatisticsService()
|
||
api.summaryResponses = [try TestFixture.payload(StatisticsSummaryResponse.self, named: "statistics_summary_success")]
|
||
api.dailyResponses = [try TestFixture.payload(DataListPayload<StatisticsDailyItem>.self, named: "statistics_daily_page1_total3")]
|
||
let viewModel = StatisticsViewModel()
|
||
|
||
try await viewModel.reload(api: api, scenicId: 88, appRole: nil)
|
||
api.nextDailyError = APIError.httpStatus(500, "server error")
|
||
|
||
do {
|
||
try await viewModel.loadMore(api: api, scenicId: 88, appRole: nil)
|
||
XCTFail("Expected load more to fail")
|
||
} catch APIError.httpStatus(let statusCode, _) {
|
||
XCTAssertEqual(statusCode, 500)
|
||
} catch {
|
||
XCTFail("Unexpected error: \(error)")
|
||
}
|
||
|
||
XCTAssertEqual(viewModel.dailyItems.map(\.date), ["2026-05-23"])
|
||
XCTAssertTrue(viewModel.hasMore)
|
||
XCTAssertFalse(viewModel.loadingMore)
|
||
}
|
||
|
||
/// 测试无景区时清空旧统计状态。
|
||
func testReloadWithoutScenicClearsStaleStatisticsState() async throws {
|
||
let api = MockStatisticsService()
|
||
api.summaryResponses = [try TestFixture.payload(StatisticsSummaryResponse.self, named: "statistics_summary_success")]
|
||
api.dailyResponses = [try TestFixture.payload(DataListPayload<StatisticsDailyItem>.self, named: "statistics_daily_page1_total3")]
|
||
let viewModel = StatisticsViewModel()
|
||
|
||
try await viewModel.reload(api: api, scenicId: 88, appRole: nil)
|
||
XCTAssertEqual(viewModel.summary.orderCount, 18)
|
||
|
||
try await viewModel.reload(api: api, scenicId: nil, appRole: nil)
|
||
|
||
XCTAssertEqual(api.summaryCalls.count, 1)
|
||
XCTAssertEqual(viewModel.summary.orderCount, 0)
|
||
XCTAssertTrue(viewModel.dailyItems.isEmpty)
|
||
XCTAssertEqual(viewModel.totalDailyCount, 0)
|
||
XCTAssertFalse(viewModel.hasMore)
|
||
XCTAssertNil(viewModel.lastRefreshAt)
|
||
}
|
||
|
||
/// 测试景区管理员角色使用管理员统计接口。
|
||
func testStatisticsAPIUsesAdminEndpointForScenicAdminRole() async throws {
|
||
let session = SequencedURLSession(responses: [
|
||
try TestFixture.data(named: "statistics_summary_success"),
|
||
try TestFixture.data(named: "statistics_daily_page1_total3")
|
||
])
|
||
let api = StatisticsAPI(client: APIClient(session: session))
|
||
|
||
_ = try await api.summary(scenicId: 88, range: "1", isScenicAdmin: true)
|
||
_ = try await api.dailyList(
|
||
scenicId: 88,
|
||
startTime: "2026-05-01",
|
||
endTime: "2026-05-31",
|
||
page: 2,
|
||
pageSize: 15,
|
||
isScenicAdmin: true
|
||
)
|
||
|
||
XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/app/scenic-admin/analyse", "/api/app/scenic-admin/analyse/daily"])
|
||
let dailyQuery = queryItems(from: try XCTUnwrap(session.requests.last))
|
||
XCTAssertEqual(dailyQuery["scenic_id"], "88")
|
||
XCTAssertEqual(dailyQuery["page"], "2")
|
||
XCTAssertEqual(dailyQuery["start_time"], "2026-05-01")
|
||
XCTAssertEqual(dailyQuery["end_time"], "2026-05-31")
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
/// 数据统计服务测试替身,记录请求参数并返回预设响应。
|
||
private final class MockStatisticsService: StatisticsServing {
|
||
struct SummaryCall: Equatable {
|
||
let scenicId: Int
|
||
let range: String
|
||
let isScenicAdmin: Bool
|
||
}
|
||
|
||
struct DailyCall: Equatable {
|
||
let scenicId: Int
|
||
let startTime: String
|
||
let endTime: String
|
||
let page: Int
|
||
let pageSize: Int
|
||
let isScenicAdmin: Bool
|
||
}
|
||
|
||
var summaryResponses: [StatisticsSummaryResponse] = []
|
||
var dailyResponses: [DataListPayload<StatisticsDailyItem>] = []
|
||
var nextDailyError: Error?
|
||
private(set) var summaryCalls: [SummaryCall] = []
|
||
private(set) var dailyCalls: [DailyCall] = []
|
||
|
||
/// 返回测试汇总数据。
|
||
func summary(scenicId: Int, range: String, isScenicAdmin: Bool) async throws -> StatisticsSummaryResponse {
|
||
summaryCalls.append(SummaryCall(scenicId: scenicId, range: range, isScenicAdmin: isScenicAdmin))
|
||
return summaryResponses.isEmpty ? StatisticsSummaryResponse() : summaryResponses.removeFirst()
|
||
}
|
||
|
||
/// 返回测试每日明细数据。
|
||
func dailyList(
|
||
scenicId: Int,
|
||
startTime: String,
|
||
endTime: String,
|
||
page: Int,
|
||
pageSize: Int,
|
||
isScenicAdmin: Bool
|
||
) async throws -> DataListPayload<StatisticsDailyItem> {
|
||
dailyCalls.append(
|
||
DailyCall(
|
||
scenicId: scenicId,
|
||
startTime: startTime,
|
||
endTime: endTime,
|
||
page: page,
|
||
pageSize: pageSize,
|
||
isScenicAdmin: isScenicAdmin
|
||
)
|
||
)
|
||
if let nextDailyError {
|
||
self.nextDailyError = nil
|
||
throw nextDailyError
|
||
}
|
||
return dailyResponses.isEmpty ? DataListPayload(total: 0, data: []) : dailyResponses.removeFirst()
|
||
}
|
||
}
|
||
|
||
/// URLSession 顺序响应测试替身。
|
||
private final class SequencedURLSession: URLSessionProtocol {
|
||
private var responses: [Data]
|
||
private(set) var requests: [URLRequest] = []
|
||
|
||
init(responses: [Data]) {
|
||
self.responses = responses
|
||
}
|
||
|
||
/// 依次返回预设响应。
|
||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||
requests.append(request)
|
||
let data = responses.isEmpty ? Data() : responses.removeFirst()
|
||
return (
|
||
data,
|
||
HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||
)
|
||
}
|
||
}
|
||
|
||
private func queryItems(from request: URLRequest) -> [String: String] {
|
||
guard let url = request.url,
|
||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||
return [:]
|
||
}
|
||
return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in
|
||
item.value.map { (item.name, $0) }
|
||
})
|
||
}
|