将打卡点管理与位置上报从占位页迁移为完整 MVVM 流程,包含前台定位支持与单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
180 lines
8.4 KiB
Swift
180 lines
8.4 KiB
Swift
//
|
||
// 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<LocationReportHistoryItem>] = [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<LocationReportHistoryItem> {
|
||
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) }
|
||
})
|
||
}
|