接入首页位置上报与在线倒计时,并优化上报交互体验。

持久化服务端 expired 过期时间戳以正确恢复倒计时,切换在线/离线与上报时展示定位 Loading,移除重复的成功 Alert,并将 Toast 调整为居中半透明样式。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 15:59:59 +08:00
parent d9f897038d
commit 560b52fcc8
19 changed files with 1028 additions and 204 deletions

View File

@ -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 {