feat: connect wild report risk map

This commit is contained in:
2026-07-08 17:52:29 +08:00
parent fd5dc2bbe6
commit 939295d74a
6 changed files with 1829 additions and 135 deletions

View File

@ -3,6 +3,7 @@
// suixinkanTests
//
import CoreLocation
import XCTest
@testable import suixinkan
@ -238,6 +239,121 @@ final class WildPhotographerReportTests: XCTestCase {
XCTAssertEqual(evidences.first?["file_name"] as? String, "supplement.jpg")
}
func testRiskMapAPISendsRequestBodyAndDecodesMarkers() async throws {
let json = Data("""
{
"code": 100000,
"msg": "success",
"data": {
"scenic_id": 128,
"scenic_name": "-5A",
"legend": [
{ "marker_type": "red", "title": "" }
],
"green_marker_tip": "绿",
"markers": [
{
"marker_type": "red",
"id": 32,
"latitude": "43.4068",
"longitude": "84.0582",
"location_name": "",
"title": "线 ID32",
"report_type_text": "",
"handle_status": 0,
"distance_m": 320,
"created_at": "2026-07-08 16:06:59"
},
{
"marker_type": "yellow",
"id": 33,
"latitude": "43.3652",
"longitude": "84.0385",
"location_name": "",
"title": "线 ID33",
"report_type_text": "",
"handle_status": "1",
"distance_m": 1280,
"created_at": "2026-07-08 16:08:59"
}
]
},
"time": "2026-07-08 18:31:50"
}
""".utf8)
let session = MockURLSession(responses: [json])
let api = WildPhotographerReportAPI(client: APIClient(environment: .testing, session: session))
let response = try await api.riskMap(WildReportRiskMapRequest(
scenicId: 128,
latitude: 43.3779,
longitude: 84.0217
))
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/report/risk-map")
let bodyData = try XCTUnwrap(request.httpBody)
let body = try XCTUnwrap(JSONSerialization.jsonObject(with: bodyData) as? [String: Any])
XCTAssertEqual(body["scenic_id"] as? Int, 128)
XCTAssertEqual(body["latitude"] as? Double, 43.3779)
XCTAssertEqual(body["longitude"] as? Double, 84.0217)
XCTAssertEqual(response.scenicName, "伊犁那拉提景区-5A")
XCTAssertEqual(response.greenMarkerTip, "绿色为景区内摄影师")
XCTAssertEqual(response.markers.map(\.markerType), ["red", "yellow"])
XCTAssertEqual(response.markers.last?.handleStatus?.displayText, WildReportStatus.processing.rawValue)
}
func testMarkerDetailAPISendsRequestBodyAndDecodesGreenDetail() async throws {
let json = Data("""
{
"code": 100000,
"msg": "success",
"data": {
"marker_type": "green",
"id": 901,
"title": "",
"tag": "",
"name": "",
"avatar": "https://example.com/avatar.jpg",
"store_name": "空中草原旅拍店",
"online": true,
"location_name": "空中草原",
"latitude": "43.4042",
"longitude": "84.0186",
"distance_m": 430,
"green_marker_tip": "绿色为景区内摄影师",
"activity_text": "当前在空中草原区域服务游客拍摄。"
},
"time": "2026-07-08 18:31:50"
}
""".utf8)
let session = MockURLSession(responses: [json])
let api = WildPhotographerReportAPI(client: APIClient(environment: .testing, session: session))
let response = try await api.markerDetail(WildReportMarkerDetailRequest(
scenicId: 128,
markerType: "green",
id: 901,
latitude: 43.3779,
longitude: 84.0217
))
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/report/marker-detail")
let bodyData = try XCTUnwrap(request.httpBody)
let body = try XCTUnwrap(JSONSerialization.jsonObject(with: bodyData) as? [String: Any])
XCTAssertEqual(body["scenic_id"] as? Int, 128)
XCTAssertEqual(body["marker_type"] as? String, "green")
XCTAssertEqual(body["id"] as? Int, 901)
XCTAssertEqual(body["latitude"] as? Double, 43.3779)
XCTAssertEqual(body["longitude"] as? Double, 84.0217)
XCTAssertEqual(response.name, "")
XCTAssertEqual(response.storeName, "")
XCTAssertTrue(response.online)
}
func testReportListItemMapsToRecord() {
let item = makeReportListItem(
complaintNo: "JB20260708917",
@ -640,6 +756,139 @@ final class WildPhotographerReportTests: XCTestCase {
XCTAssertEqual(viewModel.validationMessage, URLError(.badServerResponse).localizedDescription)
}
func testRiskMapViewModelRejectsInvalidScenicID() async {
let viewModel = WildReportRiskMapViewModel(
record: makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord(),
riskPoints: []
)
let api = MockWildPhotographerReportAPI()
var messages: [String] = []
viewModel.onShowMessage = { messages.append($0) }
await viewModel.loadInitial(
api: api,
locationProvider: MockRiskMapLocationProvider(.success(latitude: 43.3779, longitude: 84.0217)),
scenicId: 0
)
XCTAssertTrue(api.riskMapRequests.isEmpty)
XCTAssertEqual(messages.last, "")
}
func testRiskMapViewModelLoadsWithLocationAndMapsMarkerTypes() async {
let viewModel = WildReportRiskMapViewModel(
record: makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord(),
riskPoints: []
)
let api = MockWildPhotographerReportAPI()
api.riskMapResponses = [makeRiskMapResponse()]
await viewModel.loadInitial(
api: api,
locationProvider: MockRiskMapLocationProvider(.success(latitude: 43.3779, longitude: 84.0217)),
scenicId: 128,
scenicName: ""
)
XCTAssertEqual(api.riskMapRequests.first?.scenicId, 128)
XCTAssertEqual(api.riskMapRequests.first?.latitude, 43.3779)
XCTAssertEqual(api.riskMapRequests.first?.longitude, 84.0217)
XCTAssertEqual(viewModel.scenicAreaName, "-5A")
XCTAssertTrue(viewModel.markers.contains { $0.kind == .selfLocation })
XCTAssertTrue(viewModel.markers.contains { $0.kind == .wildPhotographer })
XCTAssertTrue(viewModel.markers.contains { $0.kind == .processingClue })
XCTAssertTrue(viewModel.markers.contains { $0.kind == .peerPhotographer })
XCTAssertEqual(viewModel.selectedMarker?.kind, .selfLocation)
let yellow = viewModel.markers.first { $0.kind == .processingClue }
XCTAssertEqual(yellow.flatMap { marker in
if case .activeClue(let clue) = marker.detail { return clue.distanceText }
return nil
}, "1.3")
}
func testRiskMapViewModelLoadsWithoutLocationWhenLocationFails() async {
let viewModel = WildReportRiskMapViewModel(
record: makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord(),
riskPoints: []
)
let api = MockWildPhotographerReportAPI()
api.riskMapResponses = [makeRiskMapResponse()]
await viewModel.loadInitial(
api: api,
locationProvider: MockRiskMapLocationProvider(.failure),
scenicId: 128
)
XCTAssertEqual(api.riskMapRequests.first?.scenicId, 128)
XCTAssertNil(api.riskMapRequests.first?.latitude)
XCTAssertNil(api.riskMapRequests.first?.longitude)
XCTAssertFalse(viewModel.markers.contains { $0.kind == .selfLocation })
}
func testRiskMapViewModelLoadsMarkerDetailAndUpdatesSelectedClue() async {
let viewModel = WildReportRiskMapViewModel(
record: makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord(),
riskPoints: []
)
let api = MockWildPhotographerReportAPI()
api.riskMapResponses = [makeRiskMapResponse()]
api.markerDetailResponses = [
WildReportMarkerDetailResponse(
markerType: "red",
id: 32,
title: "线 ID32",
locationName: "",
latitude: "43.4068",
longitude: "84.0582",
distanceM: 320,
reportTypeText: "",
handleStatus: WildReportDecodedHandleStatus(intValue: 0),
desc: "",
reporterName: "",
reporterPhone: "13333333333",
createdAt: "2026-07-08 16:06:59"
)
]
await viewModel.loadInitial(
api: api,
locationProvider: MockRiskMapLocationProvider(.success(latitude: 43.3779, longitude: 84.0217)),
scenicId: 128
)
let redMarker = try! XCTUnwrap(viewModel.markers.first { $0.kind == .wildPhotographer })
await viewModel.selectMarker(redMarker, api: api, scenicId: 128)
XCTAssertEqual(api.markerDetailRequests.first?.markerType, "red")
XCTAssertEqual(api.markerDetailRequests.first?.id, 32)
XCTAssertEqual(viewModel.selectedClue?.reporterName, "")
XCTAssertEqual(viewModel.selectedClue?.maskedReporterPhone, "133****3333")
XCTAssertEqual(viewModel.selectedClue?.detail, "")
}
func testRiskMapViewModelMarkerDetailFailureKeepsSummary() async {
let viewModel = WildReportRiskMapViewModel(
record: makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord(),
riskPoints: []
)
let api = MockWildPhotographerReportAPI()
api.riskMapResponses = [makeRiskMapResponse()]
api.markerDetailError = URLError(.badServerResponse)
var messages: [String] = []
viewModel.onShowMessage = { messages.append($0) }
await viewModel.loadInitial(
api: api,
locationProvider: MockRiskMapLocationProvider(.success(latitude: 43.3779, longitude: 84.0217)),
scenicId: 128
)
let redMarker = try! XCTUnwrap(viewModel.markers.first { $0.kind == .wildPhotographer })
await viewModel.selectMarker(redMarker, api: api, scenicId: 128)
XCTAssertEqual(viewModel.selectedClue?.summary, "")
XCTAssertEqual(messages.last, URLError(.badServerResponse).localizedDescription)
}
func testDetailViewModelLoadsRemoteDetailWhenServerIDExists() async {
let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord()
let viewModel = WildPhotographerReportDetailViewModel(record: record)
@ -707,6 +956,12 @@ private final class MockWildPhotographerReportAPI: WildPhotographerReportServing
var reportDetailError: Error?
var supplementRequests: [WildReportSupplementRequest] = []
var supplementError: Error?
var riskMapResponses: [WildReportRiskMapResponse] = []
var riskMapRequests: [WildReportRiskMapRequest] = []
var riskMapError: Error?
var markerDetailResponses: [WildReportMarkerDetailResponse] = []
var markerDetailRequests: [WildReportMarkerDetailRequest] = []
var markerDetailError: Error?
func reportTypes() async throws -> WildReportTypeListResponse {
if let error { throw error }
@ -723,6 +978,24 @@ private final class MockWildPhotographerReportAPI: WildPhotographerReportServing
supplementRequests.append(request)
}
func riskMap(_ request: WildReportRiskMapRequest) async throws -> WildReportRiskMapResponse {
if let riskMapError { throw riskMapError }
riskMapRequests.append(request)
guard !riskMapResponses.isEmpty else {
return WildReportRiskMapResponse(scenicId: request.scenicId, scenicName: "")
}
return riskMapResponses.removeFirst()
}
func markerDetail(_ request: WildReportMarkerDetailRequest) async throws -> WildReportMarkerDetailResponse {
if let markerDetailError { throw markerDetailError }
markerDetailRequests.append(request)
guard !markerDetailResponses.isEmpty else {
return WildReportMarkerDetailResponse(markerType: request.markerType, id: request.id)
}
return markerDetailResponses.removeFirst()
}
func reportList(_ request: WildReportListRequest) async throws -> WildReportListResponse {
if let reportListError { throw reportListError }
reportListRequests.append(request)
@ -740,6 +1013,41 @@ private final class MockWildPhotographerReportAPI: WildPhotographerReportServing
}
}
private enum MockRiskMapLocationResult: Sendable {
case success(latitude: Double, longitude: Double)
case failure
}
private struct MockRiskMapLocationProvider: LocationProviding {
private let result: MockRiskMapLocationResult
init(_ result: MockRiskMapLocationResult) {
self.result = result
}
func requestSnapshot() async throws -> HomeLocationSnapshot {
switch result {
case .success(let latitude, let longitude):
return HomeLocationSnapshot(latitude: latitude, longitude: longitude, address: "")
case .failure:
throw LocationProviderError.locationFailed
}
}
func requestCoordinate() async throws -> CLLocationCoordinate2D {
switch result {
case .success(let latitude, let longitude):
return CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
case .failure:
throw LocationProviderError.locationFailed
}
}
func reverseGeocode(latitude: Double, longitude: Double) async -> String {
""
}
}
@MainActor
private final class MockWildReportAttachmentUploader: WildReportAttachmentUploading {
var uploadedFileNames: [String] = []
@ -770,6 +1078,55 @@ private func makeAttachment(kind: WildReportAttachmentKind, fileName: String) th
)
}
private func makeRiskMapResponse() -> WildReportRiskMapResponse {
WildReportRiskMapResponse(
scenicId: 128,
scenicName: "伊犁那拉提景区-5A",
legend: [WildReportRiskLegendItem(markerType: "red", title: "疑似打野")],
greenMarkerTip: "绿色为景区内摄影师",
markers: [
WildReportRiskMapMarkerDTO(
markerType: "red",
id: 32,
latitude: "43.4068",
longitude: "84.0582",
locationName: "空中草原观景道",
title: "线索 ID32",
reportTypeText: "疑似打野",
handleStatus: WildReportDecodedHandleStatus(intValue: 0),
distanceM: 320,
createdAt: "2026-07-08 16:06:59"
),
WildReportRiskMapMarkerDTO(
markerType: "yellow",
id: 33,
latitude: "43.3652",
longitude: "84.0385",
locationName: "塔吾萨尼步道",
title: "线索 ID33",
reportTypeText: "处理中",
handleStatus: WildReportDecodedHandleStatus(intValue: 1),
distanceM: 1280,
createdAt: "2026-07-08 16:08:59"
),
WildReportRiskMapMarkerDTO(
markerType: "green",
id: 901,
latitude: "43.4042",
longitude: "84.0186",
locationName: "空中草原",
title: "景区内摄影师",
distanceM: 430,
createdAt: "2026-07-08 16:09:59",
name: "阿依达娜",
storeName: "空中草原旅拍店",
online: true,
activityText: "当前在空中草原区域服务游客拍摄。"
)
]
)
}
private func makeReportListItem(
id: Int = 32,
complaintNo: String,