Sync migrated iOS modules

This commit is contained in:
2026-06-22 15:35:12 +08:00
parent ace9c94359
commit 484566d27e
48 changed files with 4455 additions and 15 deletions

View File

@ -0,0 +1,238 @@
//
// 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, 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<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, 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<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, 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<StatisticsDailyItem>.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<StatisticsDailyItem>.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<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) }
})
}