// // 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") } /// 测试详情接口路径与字段解码。 func testDetailUsesExpectedQueryAndDecodesFields() async throws { let session = LocationRecordingSession(data: Self.detailResponse) let api = LocationReportAPI(client: APIClient(session: session)) let detail = try await api.locationDetail(staffId: 77) let request = try XCTUnwrap(session.requests.first) XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/loacation/detail") XCTAssertEqual(locationQueryItems(from: request)["staff_id"], "77") XCTAssertEqual(detail.status, 1) XCTAssertTrue(detail.needReport) XCTAssertEqual(detail.expireTime, 1_718_000_000) } 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) private static let detailResponse = Data(#"{"code":100000,"msg":"ok","data":{"status":"1","need_report":"1","expire_time":"1718000000"}}"#.utf8) } @MainActor /// 位置上报 ViewModel 测试,覆盖提交保护和类型选择。 final class LocationReportViewModelTests: XCTestCase { /// 测试缺 staffId 或 scenicId 时禁止上报。 func testSubmitRequiresStaffAndScenic() async { let api = MockLocationReportService() let homeVM = HomeLocationViewModel(api: api, store: makeIsolatedStore()) let toast = ToastCenter() let viewModel = LocationReportViewModel() viewModel.applyCurrentLocation(latitude: 30.1, longitude: 120.2, address: "入口") let missingStaff = await viewModel.submit(type: .immediate, staffId: nil, scenicId: 88, homeLocationViewModel: homeVM, toast: toast) let missingScenic = await viewModel.submit(type: .immediate, staffId: 77, scenicId: nil, homeLocationViewModel: homeVM, toast: toast) XCTAssertFalse(missingStaff) XCTAssertFalse(missingScenic) XCTAssertEqual(api.reportRequests.count, 0) } /// 测试标记点上报使用正确坐标并更新倒计时。 func testSubmitUsesExpectedCoordinates() async { let api = MockLocationReportService() let homeVM = HomeLocationViewModel(api: api, store: makeIsolatedStore()) let toast = ToastCenter() 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: .marked, staffId: 77, scenicId: 88, homeLocationViewModel: homeVM, toast: toast) XCTAssertEqual(api.reportRequests.map(\.type), [.marked]) XCTAssertEqual(api.reportRequests.first?.latitude, 31.1) XCTAssertEqual(homeVM.secondsUntilReport, 600) } /// 测试立即上报成功更新倒计时。 func testSubmitImmediateUpdatesCountdown() async { let api = MockLocationReportService() let homeVM = HomeLocationViewModel(api: api, store: makeIsolatedStore()) let toast = ToastCenter() let viewModel = LocationReportViewModel() viewModel.applyCurrentLocation(latitude: 30.1, longitude: 120.2, address: "入口") _ = await viewModel.submit(type: .immediate, staffId: 77, scenicId: 88, homeLocationViewModel: homeVM, toast: toast) XCTAssertEqual(api.reportRequests.map(\.type), [.immediate]) XCTAssertEqual(homeVM.secondsUntilReport, 600) } /// 测试上报失败保留当前倒计时。 func testSubmitFailureKeepsCountdown() async { let api = MockLocationReportService() api.shouldFailReport = true let homeVM = HomeLocationViewModel(api: api, store: makeIsolatedStore()) let toast = ToastCenter() let viewModel = LocationReportViewModel() viewModel.applyCurrentLocation(latitude: 30.1, longitude: 120.2, address: "入口") let success = await viewModel.submit(type: .immediate, staffId: 77, scenicId: 88, homeLocationViewModel: homeVM, toast: toast) XCTAssertFalse(success) XCTAssertEqual(homeVM.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) } private func makeIsolatedStore() -> LocationStateStore { let suiteName = "LocationReportViewModelTests.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suiteName)! return LocationStateStore(defaults: defaults) } } @MainActor /// 位置状态存储测试。 final class LocationStateStoreTests: XCTestCase { /// 测试在线状态、过期时间、上报时间和提醒分钟持久化。 func testPersistsOnlineReminderReportAndExpireTime() { let store = makeIsolatedStore() store.saveOnlineStatus(true) store.saveReminderMinutes(15) store.saveLastReportTime(1_000) store.saveExpireTime(1_782_726_128) XCTAssertTrue(store.loadOnlineStatus()) XCTAssertEqual(store.loadReminderMinutes(), 15) XCTAssertEqual(store.loadLastReportTime(), 1_000) XCTAssertEqual(store.loadExpireTime(), 1_782_726_128) } /// 测试将 Unix 时间戳和剩余秒数规范化为过期时间戳。 func testNormalizedExpireTimestamp() { let store = makeIsolatedStore() let now = Date(timeIntervalSince1970: 1_782_718_928) XCTAssertEqual(store.normalizedExpireTimestamp(from: 1_782_726_128, now: now), 1_782_726_128) XCTAssertEqual(store.normalizedExpireTimestamp(from: 7_200, now: now), 1_782_726_128) XCTAssertEqual(store.normalizedExpireTimestamp(from: 0, now: now), 1_782_726_128) } /// 测试根据过期时间戳计算剩余倒计时。 func testRemainingSecondsUntilExpireTime() { let store = makeIsolatedStore() let now = Date(timeIntervalSince1970: 1_782_719_528) XCTAssertEqual(store.remainingSeconds(untilExpireTime: 1_782_726_128, now: now), 6_600) XCTAssertEqual(store.remainingSeconds(untilExpireTime: 1_782_719_528, now: now), 0) } /// 测试剩余倒计时与过期判断(兼容旧版仅保存上报时间)。 func testRemainingSecondsAndWindow() { let store = makeIsolatedStore() let anchor = Date(timeIntervalSince1970: 1_000).timeIntervalSince1970 * 1000 let beforeExpiry = Date(timeIntervalSince1970: 1_000 + 3_600) let afterExpiry = Date(timeIntervalSince1970: 1_000 + 8_000) XCTAssertTrue(store.isWithinOnlineWindow(since: anchor, now: beforeExpiry)) XCTAssertFalse(store.isWithinOnlineWindow(since: anchor, now: afterExpiry)) XCTAssertEqual(store.remainingSeconds(since: anchor, now: beforeExpiry), 3_600) XCTAssertEqual(store.remainingSeconds(since: anchor, now: afterExpiry), 0) } private func makeIsolatedStore() -> LocationStateStore { let suiteName = "LocationStateStoreTests.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suiteName)! return LocationStateStore(defaults: defaults) } } @MainActor /// 首页位置上报 ViewModel 测试。 final class HomeLocationViewModelTests: XCTestCase { /// 测试上报成功后启动倒计时并持久化在线状态。 func testReportCoordinateStartsCountdown() async { let api = MockLocationReportService() let store = makeIsolatedStore() let viewModel = HomeLocationViewModel(api: api, store: store) let toast = ToastCenter() let success = await viewModel.reportCoordinate( latitude: 30.1, longitude: 120.2, address: "入口", type: .immediate, staffId: 77, scenicId: 88, toast: toast ) XCTAssertTrue(success) XCTAssertTrue(viewModel.isOnline) XCTAssertEqual(viewModel.secondsUntilReport, 600) XCTAssertTrue(store.loadOnlineStatus()) XCTAssertGreaterThan(store.loadLastReportTime(), 0) XCTAssertGreaterThan(store.loadExpireTime(), Int(Date().timeIntervalSince1970)) } /// 测试上报成功后持久化服务端过期时间戳。 func testReportCoordinatePersistsExpireTimestamp() async { let api = MockLocationReportService() let expireTime = Int(Date().timeIntervalSince1970) + 7_200 api.reportExpired = expireTime let store = makeIsolatedStore() let viewModel = HomeLocationViewModel(api: api, store: store) let toast = ToastCenter() _ = await viewModel.reportCoordinate( latitude: 30.1, longitude: 120.2, address: "入口", type: .immediate, staffId: 77, scenicId: 88, toast: toast ) XCTAssertEqual(store.loadExpireTime(), expireTime) XCTAssertEqual(viewModel.secondsUntilReport, 7_200) } /// 测试 30 秒操作节流。 func testOperationIntervalBlocksFrequentReports() async { let api = MockLocationReportService() let viewModel = HomeLocationViewModel(api: api, store: makeIsolatedStore()) let toast = ToastCenter() _ = await viewModel.reportCoordinate( latitude: 30.1, longitude: 120.2, address: "入口", type: .immediate, staffId: 77, scenicId: 88, toast: toast ) let second = await viewModel.reportCoordinate( latitude: 30.2, longitude: 120.3, address: "入口2", type: .immediate, staffId: 77, scenicId: 88, toast: toast ) XCTAssertFalse(second) XCTAssertEqual(api.reportRequests.count, 1) } /// 测试恢复在线倒计时(兼容旧版仅保存上报时间)。 func testRestoreStateRecoversCountdown() { let store = makeIsolatedStore() let now = Date().timeIntervalSince1970 * 1000 store.saveOnlineStatus(true) store.saveLastReportTime(now - 1_000 * 1000) store.saveReminderMinutes(10) let viewModel = HomeLocationViewModel(api: MockLocationReportService(), store: store) viewModel.restoreState() XCTAssertTrue(viewModel.isOnline) XCTAssertGreaterThan(viewModel.secondsUntilReport, 0) XCTAssertEqual(viewModel.reminderMinutes, 10) } /// 测试下次启动优先使用本地过期时间戳恢复倒计时。 func testRestoreStateUsesPersistedExpireTime() { let store = makeIsolatedStore() let expireTime = Int(Date().timeIntervalSince1970) + 3_600 store.saveOnlineStatus(true) store.saveExpireTime(expireTime) store.saveLastReportTime(Date().timeIntervalSince1970 * 1000) store.saveReminderMinutes(10) let viewModel = HomeLocationViewModel(api: MockLocationReportService(), store: store) viewModel.restoreState() XCTAssertTrue(viewModel.isOnline) XCTAssertEqual(viewModel.secondsUntilReport, 3_600) XCTAssertEqual(viewModel.reminderMinutes, 10) } /// 测试在线且倒计时进入提醒窗口时弹出超时对话框。 func testCheckLocationTimeoutShowsDialogWhenOnlineAndWithinReminderWindow() async { let store = makeIsolatedStore() let expireTime = Int(Date().timeIntervalSince1970) + 120 store.saveOnlineStatus(true) store.saveExpireTime(expireTime) store.saveReminderMinutes(5) let viewModel = HomeLocationViewModel(api: MockLocationReportService(), store: store) viewModel.restoreState() await viewModel.checkLocationTimeout(staffId: 77) XCTAssertTrue(viewModel.isOnline) XCTAssertTrue(viewModel.showTimeoutDialog) } /// 测试离线状态不弹出超时对话框。 func testCheckLocationTimeoutDoesNotShowDialogWhenOffline() async { let store = makeIsolatedStore() store.saveOnlineStatus(false) store.saveReminderMinutes(5) let viewModel = HomeLocationViewModel(api: MockLocationReportService(), store: store) viewModel.restoreState() await viewModel.checkLocationTimeout(staffId: 77) XCTAssertFalse(viewModel.showTimeoutDialog) } /// 测试在线但倒计时未进入提醒窗口时不弹窗。 func testCheckLocationTimeoutDoesNotShowDialogWhenOutsideReminderWindow() async { let store = makeIsolatedStore() let expireTime = Int(Date().timeIntervalSince1970) + 3_600 store.saveOnlineStatus(true) store.saveExpireTime(expireTime) store.saveReminderMinutes(5) let viewModel = HomeLocationViewModel(api: MockLocationReportService(), store: store) viewModel.restoreState() await viewModel.checkLocationTimeout(staffId: 77) XCTAssertTrue(viewModel.isOnline) XCTAssertFalse(viewModel.showTimeoutDialog) } private func makeIsolatedStore() -> LocationStateStore { let suiteName = "HomeLocationViewModelTests.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suiteName)! return LocationStateStore(defaults: defaults) } } /// 位置上报 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 /// 位置上报服务测试替身。 final class MockLocationReportService: LocationReportServing { var shouldFailReport = false var reportExpired = 600 var detailResponse = LocationDetailResponse(status: 0, needReport: false, expireTime: 0) 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: reportExpired, 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() } func locationDetail(staffId: Int) async throws -> LocationDetailResponse { detailResponse } } 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) } }) }