接入首页位置上报与在线倒计时,并优化上报交互体验。
持久化服务端 expired 过期时间戳以正确恢复倒计时,切换在线/离线与上报时展示定位 Loading,移除重复的成功 Alert,并将 Toast 调整为居中半透明样式。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -51,54 +51,88 @@ final class LocationReportAPITests: XCTestCase {
|
||||
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 测试,覆盖提交保护、类型和历史分页。
|
||||
/// 位置上报 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, api: api)
|
||||
let missingScenic = await viewModel.submit(type: .immediate, staffId: 77, scenicId: nil, api: api)
|
||||
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 testSubmitUsesExpectedTypes() async {
|
||||
/// 测试标记点上报使用正确坐标并更新倒计时。
|
||||
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: .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)
|
||||
_ = await viewModel.submit(type: .marked, staffId: 77, scenicId: 88, homeLocationViewModel: homeVM, toast: toast)
|
||||
|
||||
XCTAssertEqual(api.reportRequests.map(\.type), [.immediate, .marked, .onlineStatus])
|
||||
XCTAssertEqual(api.reportRequests[1].latitude, 31.1)
|
||||
XCTAssertEqual(viewModel.secondsUntilReport, 600)
|
||||
XCTAssertEqual(api.reportRequests.map(\.type), [.marked])
|
||||
XCTAssertEqual(api.reportRequests.first?.latitude, 31.1)
|
||||
XCTAssertEqual(homeVM.secondsUntilReport, 600)
|
||||
}
|
||||
|
||||
/// 测试上报失败保留当前状态并暴露错误。
|
||||
func testSubmitFailureKeepsState() async {
|
||||
/// 测试立即上报成功更新倒计时。
|
||||
func testSubmitImmediateUpdatesCountdown() 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, api: api)
|
||||
_ = 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(viewModel.secondsUntilReport, 0)
|
||||
XCTAssertEqual(homeVM.secondsUntilReport, 0)
|
||||
XCTAssertNotNil(viewModel.errorMessage)
|
||||
}
|
||||
|
||||
@ -120,6 +154,201 @@ final class LocationReportViewModelTests: XCTestCase {
|
||||
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。
|
||||
@ -142,8 +371,10 @@ private final class LocationRecordingSession: URLSessionProtocol {
|
||||
|
||||
@MainActor
|
||||
/// 位置上报服务测试替身。
|
||||
private final class MockLocationReportService: LocationReportServing {
|
||||
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?)] = []
|
||||
@ -151,13 +382,17 @@ private final class MockLocationReportService: LocationReportServing {
|
||||
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)
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user