Files
suixinkan_ios_new/suixinkanTests/LocationReport/LocationReportTests.swift
汉秋 560b52fcc8 接入首页位置上报与在线倒计时,并优化上报交互体验。
持久化服务端 expired 过期时间戳以正确恢复倒计时,切换在线/离线与上报时展示定位 Loading,移除重复的成功 Alert,并将 Toast 调整为居中半透明样式。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 15:59:59 +08:00

415 lines
18 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)
}
/// detail
func testCheckLocationTimeoutShowsDialogWhenNearExpiry() async {
let api = MockLocationReportService()
let expireTime = Int(Date().timeIntervalSince1970) + 120
api.detailResponse = LocationDetailResponse(status: 1, needReport: true, expireTime: expireTime)
let viewModel = HomeLocationViewModel(api: api, store: makeIsolatedStore())
viewModel.updateReminderMinutes(5)
await viewModel.checkLocationTimeout(staffId: 77)
XCTAssertTrue(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) }
})
}