// // 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.self, named: "statistics_daily_page1_total3")] let viewModel = StatisticsViewModel() try await viewModel.reload(api: api, scenicId: 88, roleId: 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.self, named: "statistics_daily_page1_total3"), try TestFixture.payload(DataListPayload.self, named: "statistics_daily_page2_total3"), try TestFixture.payload(DataListPayload.self, named: "statistics_daily_page1_total3") ] let viewModel = StatisticsViewModel() try await viewModel.reload(api: api, scenicId: 88, roleId: nil) try await viewModel.loadMore(api: api, scenicId: 88, roleId: nil) XCTAssertEqual(viewModel.dailyItems.count, 3) try await viewModel.selectPeriod(.sevenDays, api: api, scenicId: 88, roleId: 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.self, named: "statistics_daily_page1_total3"), try TestFixture.payload(DataListPayload.self, named: "statistics_daily_page2_total3") ] let viewModel = StatisticsViewModel() try await viewModel.reload(api: api, scenicId: 88, roleId: nil) try await viewModel.loadMore(api: api, scenicId: 88, roleId: 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.self, named: "statistics_daily_page1_total3")] let viewModel = StatisticsViewModel() try await viewModel.reload(api: api, scenicId: 88, roleId: nil) api.nextDailyError = APIError.httpStatus(500, "server error") do { try await viewModel.loadMore(api: api, scenicId: 88, roleId: 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.self, named: "statistics_daily_page1_total3")] let viewModel = StatisticsViewModel() try await viewModel.reload(api: api, scenicId: 88, roleId: nil) XCTAssertEqual(viewModel.summary.orderCount, 18) try await viewModel.reload(api: api, scenicId: nil, roleId: 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] = [] 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 { 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) } }) }