新增打卡点与位置上报模块,并接入首页路由

将打卡点管理与位置上报从占位页迁移为完整 MVVM 流程,包含前台定位支持与单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-24 11:08:41 +08:00
parent 403a3eefa6
commit abcac9bfdf
22 changed files with 2698 additions and 12 deletions

View File

@ -43,6 +43,9 @@ final class HomeMenuRouterTests: XCTestCase {
XCTAssertEqual(HomeMenuRouter.resolve(uri: "album_trailer", title: ""), .destination(.albumTrailer))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_management", title: ""), .destination(.sampleLibrary))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_upload", title: ""), .destination(.sampleUpload))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "checkin_points", title: ""), .destination(.punchPointList))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "location_report", title: ""), .destination(.locationReport))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "location_report_history", title: ""), .destination(.locationReportHistory))
}
///
@ -168,9 +171,13 @@ final class HomeMenuRouterTests: XCTestCase {
XCTAssertTrue(uris.contains("album_list"))
XCTAssertTrue(uris.contains("wallet"))
XCTAssertTrue(uris.contains("sample_management"))
XCTAssertTrue(uris.contains("checkin_points"))
XCTAssertTrue(uris.contains("location_report"))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "album_list", title: ""), .destination(.albumList))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_management", title: ""), .destination(.sampleLibrary))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_upload", title: ""), .destination(.sampleUpload))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "checkin_points", title: ""), .destination(.punchPointList))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "location_report", title: ""), .destination(.locationReport))
}
#endif

View File

@ -0,0 +1,179 @@
//
// 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")
}
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)
}
@MainActor
/// ViewModel
final class LocationReportViewModelTests: XCTestCase {
/// staffId scenicId
func testSubmitRequiresStaffAndScenic() async {
let api = MockLocationReportService()
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)
XCTAssertFalse(missingStaff)
XCTAssertFalse(missingScenic)
XCTAssertEqual(api.reportRequests.count, 0)
}
/// 线使
func testSubmitUsesExpectedTypes() async {
let api = MockLocationReportService()
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)
XCTAssertEqual(api.reportRequests.map(\.type), [.immediate, .marked, .onlineStatus])
XCTAssertEqual(api.reportRequests[1].latitude, 31.1)
XCTAssertEqual(viewModel.secondsUntilReport, 600)
}
///
func testSubmitFailureKeepsState() async {
let api = MockLocationReportService()
api.shouldFailReport = true
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)
XCTAssertFalse(success)
XCTAssertEqual(viewModel.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)
}
}
/// 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
///
private final class MockLocationReportService: LocationReportServing {
var shouldFailReport = false
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: 600, 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()
}
}
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) }
})
}

View File

@ -0,0 +1,232 @@
//
// PunchPointAPITests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/24.
//
import XCTest
@testable import suixinkan
@MainActor
/// API
final class PunchPointAPITests: XCTestCase {
/// 使 path query
func testPunchPointListUsesExpectedPathAndQuery() async throws {
let session = PunchPointRecordingSession(data: Self.listResponse)
let api = PunchPointAPI(client: APIClient(session: session))
let payload = try await api.punchPointList(scenicId: 88, status: -1, page: 0, pageSize: 0)
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/scenic-spot/list")
let query = queryItems(from: request)
XCTAssertEqual(query["scenic_area_id"], "88")
XCTAssertEqual(query["status"], "0")
XCTAssertEqual(query["page"], "1")
XCTAssertEqual(query["page_size"], "1")
XCTAssertEqual(payload.total, 1)
XCTAssertEqual(payload.list.first?.id, 7)
XCTAssertEqual(payload.list.first?.region?.lat, 30.1)
}
///
func testPunchPointMutationRequestsUseExpectedBodies() async throws {
let session = PunchPointRecordingSession(responses: [Self.infoResponse, Self.emptyResponse, Self.emptyResponse, Self.emptyResponse])
let api = PunchPointAPI(client: APIClient(session: session))
let region = PunchPointRegion(lat: 30.1, lot: 120.2, address: "入口", scenicSpotStr: "入口点")
_ = try await api.punchPointInfo(id: 7)
try await api.addPunchPoint(AddPunchPointRequest(scenicAreaId: "88", name: "入口", description: "描述", region: region, scenicSpotStr: "入口点", guideImages: ["https://cdn/a.jpg"]))
try await api.editPunchPoint(EditPunchPointRequest(id: 7, scenicAreaId: "88", name: "入口2", description: "描述2", region: region, scenicSpotStr: "入口点", guideImages: []))
try await api.deletePunchPoint(id: 7)
XCTAssertEqual(session.requests.map { $0.url?.path }, [
"/api/yf-handset-app/photog/scenic-spot/info",
"/api/yf-handset-app/photog/scenic-spot/add",
"/api/yf-handset-app/photog/scenic-spot/edit",
"/api/yf-handset-app/photog/scenic-spot/delete"
])
XCTAssertEqual(queryItems(from: session.requests[0])["id"], "7")
let addBody = try bodyObject(from: session.requests[1])
XCTAssertEqual(addBody["scenic_area_id"] as? String, "88")
XCTAssertEqual(addBody["name"] as? String, "入口")
XCTAssertEqual((addBody["guide_imgs"] as? [String])?.first, "https://cdn/a.jpg")
let deleteBody = try bodyObject(from: session.requests[3])
XCTAssertEqual(deleteBody["id"] as? Int, 7)
}
private static let emptyResponse = Data(#"{"code":100000,"msg":"ok","data":{}}"#.utf8)
private static let listResponse = Data(#"{"code":100000,"msg":"ok","data":{"total":"1","list":[{"id":"7","scenic_area_id":"88","name":100,"status":"1","status_label":"","description":"","region":{"lat":"30.1","lot":120.2,"address":"","scenic_spot_str":""},"scenic_spot_str":"","guide_imgs":["https://cdn/a.jpg"],"mp_qrcode":"https://cdn/qr.png","created_at":"2026-06-24"}]}}"#.utf8)
private static let infoResponse = Data(#"{"code":100000,"msg":"ok","data":{"id":7,"name":"","status":1,"region":{"lat":30.1,"lot":"120.2","address":""},"guide_imgs":[]}}"#.utf8)
}
@MainActor
/// ViewModel
final class PunchPointViewModelTests: XCTestCase {
///
func testListClearsWhenScenicMissing() async {
let api = MockPunchPointService()
let viewModel = PunchPointListViewModel()
viewModel.items = [PunchPointItem(id: 1, name: "旧数据")]
await viewModel.reload(scenicId: nil, api: api)
XCTAssertTrue(viewModel.items.isEmpty)
XCTAssertEqual(api.listRequests.count, 0)
}
///
func testListPaginationStopsAtLastPage() async {
let api = MockPunchPointService()
api.pages = [
ListPayload(total: 2, list: [PunchPointItem(id: 1, name: "A")]),
ListPayload(total: 2, list: [PunchPointItem(id: 2, name: "B")])
]
let viewModel = PunchPointListViewModel()
await viewModel.reload(scenicId: 88, api: api)
await viewModel.loadMore(scenicId: 88, api: api)
await viewModel.loadMore(scenicId: 88, api: api)
XCTAssertEqual(viewModel.items.map(\.id), [1, 2])
XCTAssertEqual(api.listRequests.map(\.page), [1, 2])
}
///
func testDeleteRefreshesList() async {
let api = MockPunchPointService()
let viewModel = PunchPointListViewModel()
let success = await viewModel.delete(PunchPointItem(id: 9, name: "A"), scenicId: 88, api: api)
XCTAssertTrue(success)
XCTAssertEqual(api.deletedIds, [9])
XCTAssertEqual(api.listRequests.count, 1)
}
/// OSS URL
func testEditorUploadsImagesBeforeSubmit() async {
let api = MockPunchPointService()
let uploader = MockPunchPointUploader()
let viewModel = PunchPointEditorViewModel()
viewModel.name = "入口"
viewModel.applyLocation(latitude: 30.1, longitude: 120.2, address: "入口")
viewModel.addLocalImages([PunchPointLocalImage(data: Data([1]), fileName: "a.jpg")])
let success = await viewModel.submit(scenicId: 88, api: api, uploadService: uploader)
XCTAssertTrue(success)
XCTAssertEqual(uploader.uploadedFileNames, ["a.jpg"])
XCTAssertEqual(api.addRequests.first?.guideImages.first, "https://cdn.example.com/a.jpg")
}
///
func testEditorUploadFailureDoesNotSubmit() async {
let api = MockPunchPointService()
let uploader = MockPunchPointUploader()
uploader.shouldFail = true
let viewModel = PunchPointEditorViewModel()
viewModel.name = "入口"
viewModel.applyLocation(latitude: 30.1, longitude: 120.2, address: "入口")
viewModel.addLocalImages([PunchPointLocalImage(data: Data([1]), fileName: "a.jpg")])
let success = await viewModel.submit(scenicId: 88, api: api, uploadService: uploader)
XCTAssertFalse(success)
XCTAssertEqual(api.addRequests.count, 0)
}
}
/// API URLSession
private final class PunchPointRecordingSession: URLSessionProtocol {
private var responses: [Data]
private(set) var requests: [URLRequest] = []
/// Session
init(data: Data) {
responses = [data]
}
/// Session
init(responses: [Data]) {
self.responses = responses
}
///
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
requests.append(request)
let data = responses.isEmpty ? Data(#"{"code":100000,"data":{}}"#.utf8) : responses.removeFirst()
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (data, response)
}
}
@MainActor
///
private final class MockPunchPointService: PunchPointServing {
var pages: [ListPayload<PunchPointItem>] = [ListPayload(total: 0, list: [])]
var listRequests: [(scenicId: Int, status: Int, page: Int, pageSize: Int)] = []
var addRequests: [AddPunchPointRequest] = []
var editRequests: [EditPunchPointRequest] = []
var deletedIds: [Int] = []
func punchPointList(scenicId: Int, status: Int, page: Int, pageSize: Int) async throws -> ListPayload<PunchPointItem> {
listRequests.append((scenicId, status, page, pageSize))
return pages.isEmpty ? ListPayload(total: 0, list: []) : pages.removeFirst()
}
func punchPointInfo(id: Int) async throws -> PunchPointItem {
PunchPointItem(id: id, name: "详情")
}
func addPunchPoint(_ request: AddPunchPointRequest) async throws {
addRequests.append(request)
}
func editPunchPoint(_ request: EditPunchPointRequest) async throws {
editRequests.append(request)
}
func deletePunchPoint(id: Int) async throws {
deletedIds.append(id)
}
}
@MainActor
/// OSS
private final class MockPunchPointUploader: OSSUploadServing {
var shouldFail = false
var uploadedFileNames: [String] = []
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
if shouldFail { throw NSError(domain: "upload", code: 1) }
uploadedFileNames.append(fileName)
onProgress(100)
return "https://cdn.example.com/\(fileName)"
}
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
}
/// query
private func queryItems(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) }
})
}
/// JSON
private func bodyObject(from request: URLRequest) throws -> [String: Any] {
let body = try XCTUnwrap(request.httpBody)
return try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any])
}