// // LocationReportTests.swift // suixinkanTests // // Created by Codex on 2026/6/24. // import XCTest @testable import suixinkan @MainActor /// 位置上报 API 测试,覆盖旧工程接口路径、参数和宽松解码。 final class LocationReportAPITests: XCTestCase { /// 测试上报接口携带旧工程需要的 query 参数。 func testReportLocationUsesExpectedQuery() async throws { let session = LocationRecordingSession(data: Self.submitResponse) let api = LocationReportAPI(client: APIClient(session: session)) let response = try await api.reportLocation(staffId: 77, latitude: 30.1, longitude: 120.2, address: "入口", type: .immediate, scenicId: 88) let request = try XCTUnwrap(session.requests.first) XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/loacation/report") let query = locationQueryItems(from: request) XCTAssertEqual(query["staff_id"], "77") XCTAssertEqual(query["latitude"], "30.1") XCTAssertEqual(query["longitude"], "120.2") XCTAssertEqual(query["address"], "入口") XCTAssertEqual(query["type"], "1") XCTAssertEqual(query["scenic_id"], "88") XCTAssertEqual(response.expired, 7200) } /// 测试历史接口支持类型、日期和分页参数。 func testHistoryUsesExpectedQueryAndDecodesLossyFields() async throws { let session = LocationRecordingSession(data: Self.historyResponse) let api = LocationReportAPI(client: APIClient(session: session)) let payload = try await api.locationReportList(staffId: 77, page: 0, pageSize: 0, type: .marked, startDate: "2026-06-01", endDate: "2026-06-24") let request = try XCTUnwrap(session.requests.first) XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/loacation/list") let query = locationQueryItems(from: request) XCTAssertEqual(query["staff_id"], "77") XCTAssertEqual(query["page"], "1") XCTAssertEqual(query["page_size"], "1") XCTAssertEqual(query["type"], "2") XCTAssertEqual(query["start_date"], "2026-06-01") XCTAssertEqual(query["end_date"], "2026-06-24") XCTAssertEqual(payload.total, 1) XCTAssertEqual(payload.list.first?.staffId, 77) XCTAssertEqual(payload.list.first?.latitude, "30.1") } private static let submitResponse = Data(#"{"code":100000,"msg":"ok","data":{"staff_id":77,"expired":"7200","status":"1"}}"#.utf8) private static let historyResponse = Data(#"{"code":100000,"msg":"ok","data":{"total":"1","list":[{"id":"1","staff_id":"77","type":"2","latitude":30.1,"longitude":"120.2","address":"入口","ip":"127.0.0.1","remark":"ok","created_at":"2026-06-24 10:00:00"}]}}"#.utf8) } @MainActor /// 位置上报 ViewModel 测试,覆盖提交保护、类型和历史分页。 final class LocationReportViewModelTests: XCTestCase { /// 测试缺 staffId 或 scenicId 时禁止上报。 func testSubmitRequiresStaffAndScenic() async { let api = MockLocationReportService() let viewModel = LocationReportViewModel() viewModel.applyCurrentLocation(latitude: 30.1, longitude: 120.2, address: "入口") let missingStaff = await viewModel.submit(type: .immediate, staffId: nil, scenicId: 88, api: api) let missingScenic = await viewModel.submit(type: .immediate, staffId: 77, scenicId: nil, api: api) XCTAssertFalse(missingStaff) XCTAssertFalse(missingScenic) XCTAssertEqual(api.reportRequests.count, 0) } /// 测试立即上报、标记点上报和在线状态使用正确类型。 func testSubmitUsesExpectedTypes() async { let api = MockLocationReportService() let viewModel = LocationReportViewModel() viewModel.applyCurrentLocation(latitude: 30.1, longitude: 120.2, address: "入口") viewModel.applyMarkedLocation(latitude: 31.1, longitude: 121.2, address: "标记点") _ = await viewModel.submit(type: .immediate, staffId: 77, scenicId: 88, api: api) _ = await viewModel.submit(type: .marked, staffId: 77, scenicId: 88, api: api) _ = await viewModel.setOnline(true, staffId: 77, scenicId: 88, api: api) XCTAssertEqual(api.reportRequests.map(\.type), [.immediate, .marked, .onlineStatus]) XCTAssertEqual(api.reportRequests[1].latitude, 31.1) XCTAssertEqual(viewModel.secondsUntilReport, 600) } /// 测试上报失败保留当前状态并暴露错误。 func testSubmitFailureKeepsState() async { let api = MockLocationReportService() api.shouldFailReport = true let viewModel = LocationReportViewModel() viewModel.applyCurrentLocation(latitude: 30.1, longitude: 120.2, address: "入口") let success = await viewModel.submit(type: .immediate, staffId: 77, scenicId: 88, api: api) XCTAssertFalse(success) XCTAssertEqual(viewModel.secondsUntilReport, 0) XCTAssertNotNil(viewModel.errorMessage) } /// 测试历史筛选和分页请求正确。 func testHistoryFilterAndPagination() async { let api = MockLocationReportService() api.historyPages = [ ListPayload(total: 2, list: [LocationReportHistoryItem.fixture(id: 1)]), ListPayload(total: 2, list: [LocationReportHistoryItem.fixture(id: 2)]) ] let viewModel = LocationReportHistoryViewModel() viewModel.selectedType = .marked await viewModel.reload(staffId: 77, api: api) await viewModel.loadMore(staffId: 77, api: api) await viewModel.loadMore(staffId: 77, api: api) XCTAssertEqual(viewModel.items.map(\.id), [1, 2]) XCTAssertEqual(api.historyRequests.map(\.page), [1, 2]) XCTAssertEqual(api.historyRequests.first?.type, .marked) } } /// 位置上报 API 测试用 URLSession。 private final class LocationRecordingSession: URLSessionProtocol { let data: Data private(set) var requests: [URLRequest] = [] /// 初始化测试 Session。 init(data: Data) { self.data = data } /// 记录请求并返回成功响应。 func data(for request: URLRequest) async throws -> (Data, URLResponse) { requests.append(request) let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! return (data, response) } } @MainActor /// 位置上报服务测试替身。 private final class MockLocationReportService: LocationReportServing { var shouldFailReport = false var reportRequests: [(staffId: Int, latitude: Double, longitude: Double, address: String, type: LocationReportType, scenicId: Int)] = [] var historyPages: [ListPayload] = [ListPayload(total: 0, list: [])] var historyRequests: [(staffId: Int, page: Int, pageSize: Int, type: LocationReportType, startDate: String?, endDate: String?)] = [] func reportLocation(staffId: Int, latitude: Double, longitude: Double, address: String, type: LocationReportType, scenicId: Int) async throws -> LocationReportSubmitResponse { if shouldFailReport { throw NSError(domain: "location", code: 1) } reportRequests.append((staffId, latitude, longitude, address, type, scenicId)) return LocationReportSubmitResponse(staffId: "\(staffId)", expired: 600, status: 1) } func locationReportList(staffId: Int, page: Int, pageSize: Int, type: LocationReportType, startDate: String?, endDate: String?) async throws -> ListPayload { historyRequests.append((staffId, page, pageSize, type, startDate, endDate)) return historyPages.isEmpty ? ListPayload(total: 0, list: []) : historyPages.removeFirst() } } private extension LocationReportHistoryItem { /// 创建历史记录测试实体。 static func fixture(id: Int) -> LocationReportHistoryItem { let data = Data(#"{"id":\#(id),"staff_id":77,"type":1,"latitude":"30.1","longitude":"120.2","address":"入口","ip":"","remark":"","created_at":"2026-06-24"}"#.utf8) return try! JSONDecoder().decode(LocationReportHistoryItem.self, from: data) } } /// 从请求中提取 query 字典。 private func locationQueryItems(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) } }) }