Files
suixinkan_ios_new/suixinkanTests/LocationReport/LocationReportTests.swift
汉秋 63fb0462d6 修复离线路径误弹位置上报超时窗,并优化提醒设置与定位 Loading 展示。
离线或倒计时未进入提醒窗口时不再弹出超时提醒;全局 Loading 支持按需展示定位文案,提前提醒选项抽成共用组件。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 16:22:55 +08:00

450 lines
19 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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<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: reportExpired, 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()
}
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) }
})
}