feat: 更新素材管理和举报风险地图

This commit is contained in:
2026-07-09 12:20:02 +08:00
parent 9b92d81902
commit 42aca73588
42 changed files with 6164 additions and 1369 deletions

View File

@ -8,36 +8,8 @@ import XCTest
@testable import suixinkan
@MainActor
/// Mock
///
final class WildPhotographerReportTests: XCTestCase {
func testSeedDataLoadsReportsAndRiskPoints() {
let viewModel = WildPhotographerReportHomeViewModel()
XCTAssertFalse(viewModel.reports.isEmpty)
XCTAssertFalse(viewModel.riskPoints.isEmpty)
XCTAssertEqual(viewModel.reports.count, WildPhotographerReportMockStore.seedReports.count)
}
func testSubmitReportGeneratesDynamicIDAndInsertsFirst() {
let viewModel = WildPhotographerReportHomeViewModel()
let beforeCount = viewModel.reports.count
let record = viewModel.submitReport(
reportType: WildReportType(value: 2, label: "疑似打野"),
description: "测试举报说明",
location: "测试景区 · 测试点位",
contact: "13800138000",
imageCount: 1,
videoCount: 0
)
XCTAssertEqual(viewModel.reports.count, beforeCount + 1)
XCTAssertEqual(viewModel.reports.first?.id, record.id)
XCTAssertTrue(record.id.hasPrefix("JB"))
XCTAssertEqual(record.status, .pending)
XCTAssertEqual(record.phoneNumber, "13800138000")
}
func testReportTypesAPILoadsRemoteOptions() async throws {
let json = Data("""
{
@ -68,6 +40,75 @@ final class WildPhotographerReportTests: XCTestCase {
])
}
func testReportCopyAPILoadsNoticeAndRuleGroups() async throws {
let json = Data("""
{
"code": 100000,
"msg": "success",
"data": {
"notice": ["", ""],
"rule_groups": [
{
"title": "",
"items": [""]
}
]
},
"time": "2026-07-09 10:15:17"
}
""".utf8)
let session = MockURLSession(responses: [json])
let api = WildPhotographerReportAPI(client: APIClient(environment: .testing, session: session))
let response = try await api.reportCopy()
XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/report/copy")
XCTAssertEqual(response.notice, ["需实名登录", "请尽量上传清晰证据"])
XCTAssertEqual(response.ruleGroups, [
WildReportRuleGroup(title: "可举报行为", items: ["疑似打野摄影师主动揽客"])
])
}
func testHomeViewModelLoadsReportCopyAndDisplayRules() async {
let viewModel = WildPhotographerReportHomeViewModel()
let api = MockWildPhotographerReportAPI()
api.reportCopyResponse = WildReportCopyResponse(
notice: ["需实名登录", "请尽量上传清晰证据"],
ruleGroups: [WildReportRuleGroup(title: "材料要求", items: ["尽量提供现场图片"])]
)
await viewModel.loadReportCopy(api: api)
XCTAssertEqual(viewModel.noticeItems, ["需实名登录", "请尽量上传清晰证据"])
XCTAssertEqual(viewModel.ruleGroups, [WildReportRuleGroup(title: "材料要求", items: ["尽量提供现场图片"])])
XCTAssertTrue(viewModel.shouldShowNoticeModule)
XCTAssertTrue(viewModel.shouldShowRuleButton)
}
func testHomeViewModelHidesNoticeModuleWhenCopyIsEmpty() async {
let viewModel = WildPhotographerReportHomeViewModel()
let api = MockWildPhotographerReportAPI()
api.reportCopyResponse = WildReportCopyResponse(notice: [" "], ruleGroups: [WildReportRuleGroup(title: " ", items: [])])
await viewModel.loadReportCopy(api: api)
XCTAssertTrue(viewModel.noticeItems.isEmpty)
XCTAssertTrue(viewModel.ruleGroups.isEmpty)
XCTAssertFalse(viewModel.shouldShowNoticeModule)
XCTAssertFalse(viewModel.shouldShowRuleButton)
}
func testHomeViewModelShowsNoticeWithoutRuleButtonWhenRulesAreEmpty() async {
let viewModel = WildPhotographerReportHomeViewModel()
let api = MockWildPhotographerReportAPI()
api.reportCopyResponse = WildReportCopyResponse(notice: ["需实名登录"], ruleGroups: [])
await viewModel.loadReportCopy(api: api)
XCTAssertTrue(viewModel.shouldShowNoticeModule)
XCTAssertFalse(viewModel.shouldShowRuleButton)
}
func testReportListAPILoadsRemoteReports() async throws {
let json = Data("""
{
@ -239,7 +280,7 @@ final class WildPhotographerReportTests: XCTestCase {
XCTAssertEqual(evidences.first?["file_name"] as? String, "supplement.jpg")
}
func testRiskMapAPISendsRequestBodyAndDecodesMarkers() async throws {
func testRiskMapAPISendsGETQueryAndDecodesMarkers() async throws {
let json = Data("""
{
"code": 100000,
@ -291,13 +332,13 @@ final class WildPhotographerReportTests: XCTestCase {
))
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(request.httpMethod, "GET")
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)
XCTAssertNil(request.httpBody)
let queryItems = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems ?? []
XCTAssertEqual(queryItems.first(where: { $0.name == "scenic_id" })?.value, "128")
XCTAssertEqual(queryItems.first(where: { $0.name == "latitude" })?.value, "43.3779")
XCTAssertEqual(queryItems.first(where: { $0.name == "longitude" })?.value, "84.0217")
XCTAssertEqual(response.scenicName, "伊犁那拉提景区-5A")
XCTAssertEqual(response.greenMarkerTip, "绿色为景区内摄影师")
XCTAssertEqual(response.markers.map(\.markerType), ["red", "yellow"])
@ -428,10 +469,10 @@ final class WildPhotographerReportTests: XCTestCase {
WildReportType(value: 2, label: ""),
WildReportType(value: 4, label: "")
])
XCTAssertEqual(viewModel.selectedReportType, .defaultSelected)
XCTAssertEqual(viewModel.selectedReportType, WildReportType(value: 1, label: ""))
}
func testSubmitViewModelFallsBackWhenReportTypesFail() async {
func testSubmitViewModelKeepsReportTypesEmptyWhenReportTypesFail() async {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
let api = MockWildPhotographerReportAPI()
@ -441,43 +482,61 @@ final class WildPhotographerReportTests: XCTestCase {
await viewModel.loadReportTypes(api: api)
XCTAssertEqual(viewModel.reportTypes, WildReportType.fallbackOptions)
XCTAssertEqual(viewModel.selectedReportType, .defaultSelected)
XCTAssertEqual(messages.last, "使")
XCTAssertTrue(viewModel.reportTypes.isEmpty)
XCTAssertNil(viewModel.selectedReportType)
XCTAssertEqual(messages.last, URLError(.badServerResponse).localizedDescription)
}
func testSubmitFailsWithoutEvidence() {
func testSubmitFailsWithoutEvidence() async {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
viewModel.selectReportType(WildReportType(value: 2, label: ""))
viewModel.updateDescription("")
viewModel.applyResolvedLocation("")
viewModel.applyResolvedLocation(HomeLocationSnapshot(latitude: 30.12, longitude: 120.34, address: ""))
viewModel.submitReport()
await viewModel.submitReport(
api: MockWildPhotographerReportAPI(),
uploader: MockWildReportAttachmentUploader(),
scenicId: 9,
scenicName: ""
)
XCTAssertEqual(viewModel.validationMessage, "")
XCTAssertNil(viewModel.submitResult)
}
func testSubmitFailsWithoutDescription() {
func testSubmitFailsWithoutDescription() async throws {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
viewModel.addImage()
viewModel.selectReportType(WildReportType(value: 2, label: ""))
viewModel.addImages([try makeAttachment(kind: .image, fileName: "scene.jpg")])
viewModel.updateDescription(" ")
viewModel.applyResolvedLocation("")
viewModel.applyResolvedLocation(HomeLocationSnapshot(latitude: 30.12, longitude: 120.34, address: ""))
viewModel.submitReport()
await viewModel.submitReport(
api: MockWildPhotographerReportAPI(),
uploader: MockWildReportAttachmentUploader(),
scenicId: 9,
scenicName: ""
)
XCTAssertEqual(viewModel.validationMessage, "")
XCTAssertNil(viewModel.submitResult)
}
func testSubmitFailsWithoutConfirmedLocation() {
func testSubmitFailsWithoutConfirmedLocation() async throws {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
viewModel.addImage()
viewModel.selectReportType(WildReportType(value: 2, label: ""))
viewModel.addImages([try makeAttachment(kind: .image, fileName: "scene.jpg")])
viewModel.updateDescription("")
viewModel.submitReport()
await viewModel.submitReport(
api: MockWildPhotographerReportAPI(),
uploader: MockWildReportAttachmentUploader(),
scenicId: 9,
scenicName: ""
)
XCTAssertEqual(viewModel.validationMessage, "")
XCTAssertNil(viewModel.submitResult)
@ -488,6 +547,7 @@ final class WildPhotographerReportTests: XCTestCase {
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
let api = MockWildPhotographerReportAPI()
let uploader = MockWildReportAttachmentUploader()
viewModel.selectReportType(WildReportType(value: 2, label: ""))
viewModel.addImages([try makeAttachment(kind: .image, fileName: "scene.jpg")])
viewModel.addPaymentImages([try makeAttachment(kind: .payment, fileName: "payment.jpg")])
viewModel.updateDescription("")
@ -497,7 +557,7 @@ final class WildPhotographerReportTests: XCTestCase {
XCTAssertNil(viewModel.validationMessage)
XCTAssertNotNil(viewModel.submitResult)
XCTAssertEqual(homeViewModel.reports.first?.id, viewModel.submitResult?.record.id)
XCTAssertEqual(viewModel.submitResult?.record.id, "")
XCTAssertEqual(uploader.uploadedFileNames, ["scene.jpg", "payment.jpg"])
XCTAssertEqual(api.submitRequests.first?.scenicId, 9)
XCTAssertEqual(api.submitRequests.first?.reportType, 2)
@ -512,9 +572,10 @@ final class WildPhotographerReportTests: XCTestCase {
func testPaymentScreenshotsDoNotReplaceRequiredEvidence() async throws {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
viewModel.selectReportType(WildReportType(value: 2, label: ""))
viewModel.addPaymentImages([try makeAttachment(kind: .payment, fileName: "payment.jpg")])
viewModel.updateDescription("")
viewModel.applyResolvedLocation(" · ")
viewModel.applyResolvedLocation(HomeLocationSnapshot(latitude: 30.12, longitude: 120.34, address: " · "))
await viewModel.submitReport(
api: MockWildPhotographerReportAPI(),
@ -546,9 +607,10 @@ final class WildPhotographerReportTests: XCTestCase {
let api = MockWildPhotographerReportAPI()
let uploader = MockWildReportAttachmentUploader()
uploader.error = OSSUploadError.emptyFile
viewModel.selectReportType(WildReportType(value: 2, label: ""))
viewModel.addImages([try makeAttachment(kind: .image, fileName: "scene.jpg")])
viewModel.updateDescription("")
viewModel.applyResolvedLocation(" · ")
viewModel.applyResolvedLocation(HomeLocationSnapshot(latitude: 30.12, longitude: 120.34, address: " · "))
await viewModel.submitReport(api: api, uploader: uploader, scenicId: 9, scenicName: "")
@ -556,23 +618,6 @@ final class WildPhotographerReportTests: XCTestCase {
XCTAssertEqual(viewModel.validationMessage, OSSUploadError.emptyFile.localizedDescription)
}
func testListFilterReturnsMatchingStatus() {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel)
viewModel.selectFilter(.pending)
XCTAssertTrue(viewModel.filteredReports.allSatisfy { $0.status == .pending })
viewModel.selectFilter(.processing)
XCTAssertTrue(viewModel.filteredReports.allSatisfy { $0.status == .processing })
viewModel.selectFilter(.processed)
XCTAssertTrue(viewModel.filteredReports.allSatisfy { $0.status == .processed })
viewModel.selectFilter(.all)
XCTAssertEqual(viewModel.filteredReports.count, homeViewModel.reports.count)
}
func testListInitialLoadUsesAllStatusAndFirstPage() async {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel)
@ -668,9 +713,6 @@ final class WildPhotographerReportTests: XCTestCase {
XCTAssertEqual(api.supplementRequests.first?.id, 32)
XCTAssertEqual(api.supplementRequests.first?.desc, "")
XCTAssertEqual(api.supplementRequests.first?.evidences?.map(\.fileType), [1, 2])
XCTAssertEqual(homeViewModel.supplementaryEvidences(for: record.id).first?.text, "")
XCTAssertEqual(homeViewModel.supplementaryEvidences(for: record.id).first?.imageCount, 1)
XCTAssertEqual(homeViewModel.supplementaryEvidences(for: record.id).first?.videoCount, 1)
}
func testSupplementEvidenceFailsWithoutServerID() async {
@ -757,10 +799,7 @@ final class WildPhotographerReportTests: XCTestCase {
}
func testRiskMapViewModelRejectsInvalidScenicID() async {
let viewModel = WildReportRiskMapViewModel(
record: makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord(),
riskPoints: []
)
let viewModel = WildReportRiskMapViewModel()
let api = MockWildPhotographerReportAPI()
var messages: [String] = []
viewModel.onShowMessage = { messages.append($0) }
@ -776,23 +815,23 @@ final class WildPhotographerReportTests: XCTestCase {
}
func testRiskMapViewModelLoadsWithLocationAndMapsMarkerTypes() async {
let viewModel = WildReportRiskMapViewModel(
record: makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord(),
riskPoints: []
)
let viewModel = WildReportRiskMapViewModel()
let api = MockWildPhotographerReportAPI()
api.riskMapResponses = [makeRiskMapResponse()]
let locationProvider = MockRiskMapLocationProvider(.success(latitude: 43.3779, longitude: 84.0217))
await viewModel.loadInitial(
api: api,
locationProvider: MockRiskMapLocationProvider(.success(latitude: 43.3779, longitude: 84.0217)),
locationProvider: locationProvider,
scenicId: 128,
scenicName: ""
)
XCTAssertEqual(api.riskMapRequests.count, 1)
XCTAssertEqual(api.riskMapRequests.first?.scenicId, 128)
XCTAssertEqual(api.riskMapRequests.first?.latitude, 43.3779)
XCTAssertEqual(api.riskMapRequests.first?.longitude, 84.0217)
XCTAssertEqual(locationProvider.requestedAccuracies.last, kCLLocationAccuracyHundredMeters)
XCTAssertEqual(viewModel.scenicAreaName, "-5A")
XCTAssertTrue(viewModel.markers.contains { $0.kind == .selfLocation })
XCTAssertTrue(viewModel.markers.contains { $0.kind == .wildPhotographer })
@ -807,10 +846,7 @@ final class WildPhotographerReportTests: XCTestCase {
}
func testRiskMapViewModelLoadsWithoutLocationWhenLocationFails() async {
let viewModel = WildReportRiskMapViewModel(
record: makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord(),
riskPoints: []
)
let viewModel = WildReportRiskMapViewModel()
let api = MockWildPhotographerReportAPI()
api.riskMapResponses = [makeRiskMapResponse()]
@ -827,10 +863,7 @@ final class WildPhotographerReportTests: XCTestCase {
}
func testRiskMapViewModelLoadsMarkerDetailAndUpdatesSelectedClue() async {
let viewModel = WildReportRiskMapViewModel(
record: makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord(),
riskPoints: []
)
let viewModel = WildReportRiskMapViewModel()
let api = MockWildPhotographerReportAPI()
api.riskMapResponses = [makeRiskMapResponse()]
api.markerDetailResponses = [
@ -867,10 +900,7 @@ final class WildPhotographerReportTests: XCTestCase {
}
func testRiskMapViewModelMarkerDetailFailureKeepsSummary() async {
let viewModel = WildReportRiskMapViewModel(
record: makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord(),
riskPoints: []
)
let viewModel = WildReportRiskMapViewModel()
let api = MockWildPhotographerReportAPI()
api.riskMapResponses = [makeRiskMapResponse()]
api.markerDetailError = URLError(.badServerResponse)
@ -946,6 +976,8 @@ final class WildPhotographerReportTests: XCTestCase {
@MainActor
private final class MockWildPhotographerReportAPI: WildPhotographerReportServing {
var reportTypeResponse = WildReportTypeListResponse(list: [])
var reportCopyResponse = WildReportCopyResponse()
var reportCopyError: Error?
var error: Error?
var submitRequests: [WildReportSubmitRequest] = []
var reportListResponses: [WildReportListResponse] = []
@ -968,6 +1000,11 @@ private final class MockWildPhotographerReportAPI: WildPhotographerReportServing
return reportTypeResponse
}
func reportCopy() async throws -> WildReportCopyResponse {
if let reportCopyError { throw reportCopyError }
return reportCopyResponse
}
func submitReport(_ request: WildReportSubmitRequest) async throws {
if let error { throw error }
submitRequests.append(request)
@ -1018,11 +1055,24 @@ private enum MockRiskMapLocationResult: Sendable {
case failure
}
private struct MockRiskMapLocationProvider: LocationProviding {
private final class MockRiskMapLocationProvider: LocationProviding, @unchecked Sendable {
private let result: MockRiskMapLocationResult
private let onCoordinateRequest: (@Sendable (CLLocationAccuracy) async -> Void)?
private let lock = NSLock()
private var recordedAccuracies: [CLLocationAccuracy] = []
init(_ result: MockRiskMapLocationResult) {
var requestedAccuracies: [CLLocationAccuracy] {
lock.lock()
defer { lock.unlock() }
return recordedAccuracies
}
init(
_ result: MockRiskMapLocationResult,
onCoordinateRequest: (@Sendable (CLLocationAccuracy) async -> Void)? = nil
) {
self.result = result
self.onCoordinateRequest = onCoordinateRequest
}
func requestSnapshot() async throws -> HomeLocationSnapshot {
@ -1034,7 +1084,11 @@ private struct MockRiskMapLocationProvider: LocationProviding {
}
}
func requestCoordinate() async throws -> CLLocationCoordinate2D {
func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D {
lock.lock()
recordedAccuracies.append(desiredAccuracy)
lock.unlock()
await onCoordinateRequest?(desiredAccuracy)
switch result {
case .success(let latitude, let longitude):
return CLLocationCoordinate2D(latitude: latitude, longitude: longitude)