Files
suixinkan_uikit/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift

1226 lines
52 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// WildPhotographerReportTests.swift
// suixinkanTests
//
import CoreLocation
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("""
{
"code": 100000,
"msg": "success",
"data": {
"list": [
{ "value": 1, "label": "" },
{ "value": 2, "label": "" },
{ "value": 3, "label": "" },
{ "value": 4, "label": "" }
]
},
"time": "2026-07-08 14:21:21"
}
""".utf8)
let session = MockURLSession(responses: [json])
let api = WildPhotographerReportAPI(client: APIClient(environment: .testing, session: session))
let response = try await api.reportTypes()
XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/report/types")
XCTAssertEqual(response.list, [
WildReportType(value: 1, label: "私下收款"),
WildReportType(value: 2, label: "疑似打野"),
WildReportType(value: 3, label: "未戴工牌"),
WildReportType(value: 4, label: "其他")
])
}
func testReportListAPILoadsRemoteReports() async throws {
let json = Data("""
{
"code": 100000,
"msg": "success",
"data": {
"list": [
{
"id": 32,
"complaint_no": "JB20260708917",
"title": "",
"report_type": 1,
"report_type_text": "",
"desc": "",
"scenic_id": 128,
"scenic_name": "-5A",
"location_name": "-5A",
"latitude": "32.4299428",
"longitude": "119.4403263",
"handle_status": 0,
"handle_status_text": "",
"created_at": "2026-07-08 16:06:59"
}
],
"total": 1,
"page": 1,
"page_size": 10
},
"time": "2026-07-08 16:07:30"
}
""".utf8)
let session = MockURLSession(responses: [json])
let api = WildPhotographerReportAPI(client: APIClient(environment: .testing, session: session))
let response = try await api.reportList(WildReportListRequest(scenicId: 128, handleStatus: 0, page: 1, pageSize: 10))
let components = URLComponents(url: try XCTUnwrap(session.requests.first?.url), resolvingAgainstBaseURL: false)
XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/report/list")
XCTAssertTrue(components?.queryItems?.contains(URLQueryItem(name: "scenic_id", value: "128")) == true)
XCTAssertTrue(components?.queryItems?.contains(URLQueryItem(name: "handle_status", value: "0")) == true)
XCTAssertTrue(components?.queryItems?.contains(URLQueryItem(name: "page", value: "1")) == true)
XCTAssertTrue(components?.queryItems?.contains(URLQueryItem(name: "page_size", value: "10")) == true)
XCTAssertEqual(response.total, 1)
XCTAssertEqual(response.page, 1)
XCTAssertEqual(response.pageSize, 10)
XCTAssertEqual(response.list.first?.complaintNo, "JB20260708917")
}
func testReportDetailAPILoadsRemoteDetail() async throws {
let json = Data("""
{
"code": 100000,
"msg": "success",
"data": {
"id": 32,
"complaint_no": "JB20260708917",
"title": "",
"report_type": 1,
"report_type_text": "",
"desc": "",
"supplements": [],
"report_contents": [
{
"type": "initial",
"id": 0,
"desc": "",
"time": "2026-07-08 16:06:59",
"time_label": "16:06:59",
"evidences": [
{
"file_type": 1,
"file_type_text": "",
"file_url": "https://example.com/report.jpg",
"file_name": "report.jpg"
}
]
}
],
"scenic_id": 128,
"scenic_name": "伊犁那拉提景区-5A",
"store_user_id": 0,
"contact_phone": "13800138000",
"location_name": "伊犁那拉提景区-5A",
"location_address": "江苏省扬州市邗江区瘦西湖路",
"latitude": "32.4299428",
"longitude": "119.4403263",
"handle_status": 0,
"handle_status_text": "待处理",
"handle_remark": "",
"evidence_summary": { "image_count": 1, "video_count": 1 },
"evidences": [
{
"file_type": 1,
"file_type_text": "图片",
"file_url": "https://example.com/report.jpg",
"file_name": "report.jpg"
},
{
"file_type": 2,
"file_type_text": "视频",
"file_url": "https://example.com/report.mov",
"file_name": "report.mov"
}
],
"payment_screenshots": [
{
"file_type": 5,
"file_type_text": "支付截图",
"file_url": "https://example.com/payment.jpg",
"file_name": "payment.jpg"
}
],
"created_at": "2026-07-08 16:06:59",
"timeline": [
{ "time": "2026-07-08 16:06:59", "content": "摄影师提交举报" }
]
},
"time": "2026-07-08 16:31:50"
}
""".utf8)
let session = MockURLSession(responses: [json])
let api = WildPhotographerReportAPI(client: APIClient(environment: .testing, session: session))
let response = try await api.reportDetail(id: 32)
let components = URLComponents(url: try XCTUnwrap(session.requests.first?.url), resolvingAgainstBaseURL: false)
XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/report/detail")
XCTAssertTrue(components?.queryItems?.contains(URLQueryItem(name: "id", value: "32")) == true)
XCTAssertEqual(response.complaintNo, "JB20260708917")
XCTAssertEqual(response.evidences.count, 2)
XCTAssertEqual(response.paymentScreenshots.first?.fileName, "payment.jpg")
XCTAssertEqual(response.timeline.first?.content, "")
}
func testSupplementReportAPISendsRequestBody() async throws {
let json = Data("""
{
"code": 100000,
"msg": "success",
"data": null,
"time": "2026-07-08 17:31:50"
}
""".utf8)
let session = MockURLSession(responses: [json])
let api = WildPhotographerReportAPI(client: APIClient(environment: .testing, session: session))
try await api.supplementReport(WildReportSupplementRequest(
id: 32,
desc: "",
evidences: [
WildReportEvidenceRequest(
fileURL: "https://example.com/supplement.jpg",
fileType: 1,
fileName: "supplement.jpg"
)
]
))
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/report/supplement")
let bodyData = try XCTUnwrap(request.httpBody)
let body = try XCTUnwrap(JSONSerialization.jsonObject(with: bodyData) as? [String: Any])
XCTAssertEqual(body["id"] as? Int, 32)
XCTAssertEqual(body["desc"] as? String, "补充说明")
let evidences = try XCTUnwrap(body["evidences"] as? [[String: Any]])
XCTAssertEqual(evidences.first?["file_url"] as? String, "https://example.com/supplement.jpg")
XCTAssertEqual(evidences.first?["file_type"] as? Int, 1)
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",
title: "",
reportType: 1,
reportTypeText: "",
handleStatus: 0,
handleStatusText: ""
)
let record = item.toRecord()
XCTAssertEqual(record.id, "JB20260708917")
XCTAssertEqual(record.title, "")
XCTAssertEqual(record.reportType, WildReportType(value: 1, label: ""))
XCTAssertEqual(record.status, .pending)
XCTAssertEqual(record.location, "-5A")
XCTAssertEqual(record.description, "")
XCTAssertEqual(record.submitTime, "2026-07-08 16:06:59")
XCTAssertEqual(record.imageCount, 0)
XCTAssertEqual(record.videoCount, 0)
XCTAssertEqual(record.serverID, 32)
}
func testReportDetailMapsToRecordAndSupplementData() {
let detail = makeReportDetailData(handleStatus: 3, handleStatusText: "")
let record = detail.toRecord()
XCTAssertEqual(record.serverID, 32)
XCTAssertEqual(record.id, "JB20260708917")
XCTAssertEqual(record.reportType, WildReportType(value: 1, label: ""))
XCTAssertEqual(record.status, .rejected)
XCTAssertEqual(record.location, "-5A · 西")
XCTAssertEqual(record.description, "")
XCTAssertEqual(record.phoneNumber, "13800138000")
XCTAssertEqual(record.imageCount, 1)
XCTAssertEqual(record.videoCount, 1)
XCTAssertEqual(detail.supplementRecords.first?.summaryText, " · 1")
}
func testReportStatusMapsRejected() {
XCTAssertEqual(WildReportStatus(handleStatus: 0), .pending)
XCTAssertEqual(WildReportStatus(handleStatus: 1), .processing)
XCTAssertEqual(WildReportStatus(handleStatus: 2), .processed)
XCTAssertEqual(WildReportStatus(handleStatus: 3), .rejected)
}
func testReportListAllFilterOmitsHandleStatus() {
let request = WildReportListRequest(scenicId: 128, handleStatus: nil, page: 1, pageSize: 10)
XCTAssertFalse(request.queryItems.contains { $0.name == "handle_status" })
XCTAssertTrue(request.queryItems.contains(URLQueryItem(name: "scenic_id", value: "128")))
XCTAssertTrue(request.queryItems.contains(URLQueryItem(name: "page", value: "1")))
XCTAssertTrue(request.queryItems.contains(URLQueryItem(name: "page_size", value: "10")))
}
func testSubmitViewModelLoadsReportTypesFromAPI() async {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
let api = MockWildPhotographerReportAPI()
api.reportTypeResponse = WildReportTypeListResponse(list: [
WildReportType(value: 1, label: ""),
WildReportType(value: 2, label: ""),
WildReportType(value: 4, label: ""),
])
await viewModel.loadReportTypes(api: api)
XCTAssertEqual(viewModel.reportTypes, [
WildReportType(value: 1, label: ""),
WildReportType(value: 2, label: ""),
WildReportType(value: 4, label: "")
])
XCTAssertEqual(viewModel.selectedReportType, .defaultSelected)
}
func testSubmitViewModelFallsBackWhenReportTypesFail() async {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
let api = MockWildPhotographerReportAPI()
api.error = URLError(.badServerResponse)
var messages: [String] = []
viewModel.onShowMessage = { messages.append($0) }
await viewModel.loadReportTypes(api: api)
XCTAssertEqual(viewModel.reportTypes, WildReportType.fallbackOptions)
XCTAssertEqual(viewModel.selectedReportType, .defaultSelected)
XCTAssertEqual(messages.last, "使")
}
func testSubmitFailsWithoutEvidence() {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
viewModel.updateDescription("")
viewModel.applyResolvedLocation("")
viewModel.submitReport()
XCTAssertEqual(viewModel.validationMessage, "")
XCTAssertNil(viewModel.submitResult)
}
func testSubmitFailsWithoutDescription() {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
viewModel.addImage()
viewModel.updateDescription(" ")
viewModel.applyResolvedLocation("")
viewModel.submitReport()
XCTAssertEqual(viewModel.validationMessage, "")
XCTAssertNil(viewModel.submitResult)
}
func testSubmitFailsWithoutConfirmedLocation() {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
viewModel.addImage()
viewModel.updateDescription("")
viewModel.submitReport()
XCTAssertEqual(viewModel.validationMessage, "")
XCTAssertNil(viewModel.submitResult)
}
func testSubmitSuccessCreatesRecord() async throws {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
let api = MockWildPhotographerReportAPI()
let uploader = MockWildReportAttachmentUploader()
viewModel.addImages([try makeAttachment(kind: .image, fileName: "scene.jpg")])
viewModel.addPaymentImages([try makeAttachment(kind: .payment, fileName: "payment.jpg")])
viewModel.updateDescription("")
viewModel.applyResolvedLocation(HomeLocationSnapshot(latitude: 30.12, longitude: 120.34, address: " · "))
await viewModel.submitReport(api: api, uploader: uploader, scenicId: 9, scenicName: "")
XCTAssertNil(viewModel.validationMessage)
XCTAssertNotNil(viewModel.submitResult)
XCTAssertEqual(homeViewModel.reports.first?.id, viewModel.submitResult?.record.id)
XCTAssertEqual(uploader.uploadedFileNames, ["scene.jpg", "payment.jpg"])
XCTAssertEqual(api.submitRequests.first?.scenicId, 9)
XCTAssertEqual(api.submitRequests.first?.reportType, 2)
XCTAssertEqual(api.submitRequests.first?.lat, 30.12)
XCTAssertEqual(api.submitRequests.first?.lng, 120.34)
XCTAssertEqual(api.submitRequests.first?.evidences.count, 1)
XCTAssertEqual(api.submitRequests.first?.evidences.first?.fileType, 1)
XCTAssertEqual(api.submitRequests.first?.paymentScreenshots.count, 1)
XCTAssertNil(api.submitRequests.first?.storeUserId)
}
func testPaymentScreenshotsDoNotReplaceRequiredEvidence() async throws {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
viewModel.addPaymentImages([try makeAttachment(kind: .payment, fileName: "payment.jpg")])
viewModel.updateDescription("")
viewModel.applyResolvedLocation(" · ")
await viewModel.submitReport(
api: MockWildPhotographerReportAPI(),
uploader: MockWildReportAttachmentUploader(),
scenicId: 9,
scenicName: ""
)
XCTAssertEqual(viewModel.validationMessage, "")
}
func testPaymentScreenshotsLimitedToThree() throws {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
viewModel.addPaymentImages([
try makeAttachment(kind: .payment, fileName: "p1.jpg"),
try makeAttachment(kind: .payment, fileName: "p2.jpg"),
try makeAttachment(kind: .payment, fileName: "p3.jpg"),
try makeAttachment(kind: .payment, fileName: "p4.jpg")
])
XCTAssertEqual(viewModel.paymentImages.count, 3)
}
func testUploadFailureSkipsSubmitAPI() async throws {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
let api = MockWildPhotographerReportAPI()
let uploader = MockWildReportAttachmentUploader()
uploader.error = OSSUploadError.emptyFile
viewModel.addImages([try makeAttachment(kind: .image, fileName: "scene.jpg")])
viewModel.updateDescription("")
viewModel.applyResolvedLocation(" · ")
await viewModel.submitReport(api: api, uploader: uploader, scenicId: 9, scenicName: "")
XCTAssertEqual(api.submitRequests.count, 0)
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)
let api = MockWildPhotographerReportAPI()
api.reportListResponses = [
WildReportListResponse(
list: [makeReportListItem(complaintNo: "JB001", handleStatus: 0)],
total: 1,
page: 1,
pageSize: 10
)
]
await viewModel.loadInitial(api: api, scenicId: 128)
XCTAssertEqual(api.reportListRequests.first, WildReportListRequest(scenicId: 128, handleStatus: nil, page: 1, pageSize: 10))
XCTAssertEqual(viewModel.filteredReports.map(\.id), ["JB001"])
XCTAssertFalse(viewModel.hasMore)
}
func testListFilterReloadsWithMappedStatus() async {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel)
let api = MockWildPhotographerReportAPI()
api.reportListResponses = [
WildReportListResponse(list: [makeReportListItem(complaintNo: "JB001", handleStatus: 0)], total: 1, page: 1, pageSize: 10),
WildReportListResponse(list: [makeReportListItem(complaintNo: "JB002", handleStatus: 1)], total: 1, page: 1, pageSize: 10),
WildReportListResponse(list: [makeReportListItem(complaintNo: "JB003", handleStatus: 2)], total: 1, page: 1, pageSize: 10),
]
await viewModel.selectFilter(.pending, api: api, scenicId: 128)
await viewModel.selectFilter(.processing, api: api, scenicId: 128)
await viewModel.selectFilter(.processed, api: api, scenicId: 128)
XCTAssertEqual(api.reportListRequests.map(\.handleStatus), [0, 1, 2] as [Int?])
XCTAssertEqual(api.reportListRequests.map(\.page), [1, 1, 1])
XCTAssertEqual(viewModel.filteredReports.map(\.id), ["JB003"])
}
func testListLoadMoreAppendsReportsAndUpdatesHasMore() async {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel)
let api = MockWildPhotographerReportAPI()
api.reportListResponses = [
WildReportListResponse(list: [makeReportListItem(complaintNo: "JB001", handleStatus: 0)], total: 2, page: 1, pageSize: 10),
WildReportListResponse(list: [makeReportListItem(complaintNo: "JB002", handleStatus: 0)], total: 2, page: 2, pageSize: 10),
]
await viewModel.loadInitial(api: api, scenicId: 128)
XCTAssertTrue(viewModel.hasMore)
await viewModel.loadMoreIfNeeded(api: api, scenicId: 128)
XCTAssertEqual(api.reportListRequests.map(\.page), [1, 2])
XCTAssertEqual(viewModel.filteredReports.map(\.id), ["JB001", "JB002"])
XCTAssertFalse(viewModel.hasMore)
}
func testListLoadFailureKeepsExistingReports() async {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel)
let api = MockWildPhotographerReportAPI()
api.reportListResponses = [
WildReportListResponse(list: [makeReportListItem(complaintNo: "JB001", handleStatus: 0)], total: 2, page: 1, pageSize: 10)
]
var messages: [String] = []
viewModel.onShowMessage = { messages.append($0) }
await viewModel.loadInitial(api: api, scenicId: 128)
api.reportListError = URLError(.badServerResponse)
await viewModel.loadMoreIfNeeded(api: api, scenicId: 128)
XCTAssertEqual(viewModel.filteredReports.map(\.id), ["JB001"])
XCTAssertEqual(messages.last, URLError(.badServerResponse).localizedDescription)
}
func testSupplementEvidenceValidationAndSuccess() async throws {
let homeViewModel = WildPhotographerReportHomeViewModel()
let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord()
let viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel)
let api = MockWildPhotographerReportAPI()
let uploader = MockWildReportAttachmentUploader()
await viewModel.submit(api: api, uploader: uploader, scenicId: 128)
XCTAssertEqual(viewModel.validationMessage, "")
viewModel.updateText("")
viewModel.addImages([try makeAttachment(kind: .image, fileName: "supplement.jpg")])
viewModel.addVideos([try makeAttachment(kind: .video, fileName: "supplement.mov")])
await viewModel.submit(api: api, uploader: uploader, scenicId: 128)
XCTAssertTrue(viewModel.didSubmit)
XCTAssertEqual(uploader.uploadedFileNames, ["supplement.jpg", "supplement.mov"])
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 {
let homeViewModel = WildPhotographerReportHomeViewModel()
let record = WildReportRecord(
serverID: nil,
id: "JBLOCAL",
title: "",
reportType: WildReportType(value: 2, label: ""),
status: .pending,
location: "",
submitTime: "2026-07-08 16:06:59",
description: "",
wechatID: "",
phoneNumber: "",
imageCount: 1,
videoCount: 0,
handlerInfo: nil
)
let viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel)
viewModel.updateText("")
await viewModel.submit(
api: MockWildPhotographerReportAPI(),
uploader: MockWildReportAttachmentUploader(),
scenicId: 128
)
XCTAssertEqual(viewModel.validationMessage, "")
XCTAssertFalse(viewModel.didSubmit)
}
func testSupplementEvidenceTextAndAttachmentLimits() throws {
let homeViewModel = WildPhotographerReportHomeViewModel()
let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord()
let viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel)
viewModel.updateText(String(repeating: "", count: 520))
XCTAssertEqual(viewModel.text.count, 500)
let images = try (0..<10).map { try makeAttachment(kind: .image, fileName: "image\($0).jpg") }
viewModel.addImages(images)
XCTAssertEqual(viewModel.images.count, 9)
viewModel.removeAttachment(viewModel.images[0])
let videos = try (0..<4).map { try makeAttachment(kind: .video, fileName: "video\($0).mov") }
viewModel.addVideos(videos)
XCTAssertEqual(viewModel.videos.count, 1)
}
func testSupplementEvidenceUploadFailureDoesNotSubmit() async throws {
let homeViewModel = WildPhotographerReportHomeViewModel()
let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord()
let viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel)
let api = MockWildPhotographerReportAPI()
let uploader = MockWildReportAttachmentUploader()
uploader.error = URLError(.cannotWriteToFile)
viewModel.addImages([try makeAttachment(kind: .image, fileName: "supplement.jpg")])
await viewModel.submit(api: api, uploader: uploader, scenicId: 128)
XCTAssertFalse(viewModel.didSubmit)
XCTAssertTrue(api.supplementRequests.isEmpty)
XCTAssertEqual(viewModel.validationMessage, URLError(.cannotWriteToFile).localizedDescription)
}
func testSupplementEvidenceAPIErrorDoesNotSucceed() async throws {
let homeViewModel = WildPhotographerReportHomeViewModel()
let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord()
let viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel)
let api = MockWildPhotographerReportAPI()
api.supplementError = URLError(.badServerResponse)
viewModel.addImages([try makeAttachment(kind: .image, fileName: "supplement.jpg")])
await viewModel.submit(
api: api,
uploader: MockWildReportAttachmentUploader(),
scenicId: 128
)
XCTAssertFalse(viewModel.didSubmit)
XCTAssertEqual(api.supplementRequests.count, 0)
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)
let api = MockWildPhotographerReportAPI()
api.reportDetailResponses = [makeReportDetailData()]
await viewModel.loadDetailIfNeeded(api: api)
XCTAssertEqual(api.reportDetailRequests, [32])
XCTAssertEqual(viewModel.record.description, "")
XCTAssertEqual(viewModel.reporterMediaItems.count, 2)
XCTAssertEqual(viewModel.paymentScreenshotItems.count, 1)
XCTAssertEqual(viewModel.progressSteps.first?.title, "")
}
func testDetailViewModelFailureKeepsInitialRecord() async {
let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord()
let viewModel = WildPhotographerReportDetailViewModel(record: record)
let api = MockWildPhotographerReportAPI()
api.reportDetailError = URLError(.badServerResponse)
var messages: [String] = []
viewModel.onShowMessage = { messages.append($0) }
await viewModel.loadDetailIfNeeded(api: api)
XCTAssertEqual(viewModel.record.description, "")
XCTAssertEqual(messages.last, URLError(.badServerResponse).localizedDescription)
}
func testDetailViewModelWithoutServerIDSkipsRequest() async {
let record = WildReportRecord(
id: "JBLOCAL",
title: "",
reportType: WildReportType(value: 2, label: ""),
status: .pending,
location: "",
submitTime: "2026-07-08 16:06:59",
description: "",
wechatID: "",
phoneNumber: "",
imageCount: 1,
videoCount: 0,
handlerInfo: nil
)
let viewModel = WildPhotographerReportDetailViewModel(record: record)
let api = MockWildPhotographerReportAPI()
await viewModel.loadDetailIfNeeded(api: api)
XCTAssertTrue(api.reportDetailRequests.isEmpty)
XCTAssertEqual(viewModel.record.description, "")
}
}
@MainActor
private final class MockWildPhotographerReportAPI: WildPhotographerReportServing {
var reportTypeResponse = WildReportTypeListResponse(list: [])
var error: Error?
var submitRequests: [WildReportSubmitRequest] = []
var reportListResponses: [WildReportListResponse] = []
var reportListRequests: [WildReportListRequest] = []
var reportListError: Error?
var reportDetailResponses: [WildReportDetailData] = []
var reportDetailRequests: [Int] = []
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 }
return reportTypeResponse
}
func submitReport(_ request: WildReportSubmitRequest) async throws {
if let error { throw error }
submitRequests.append(request)
}
func supplementReport(_ request: WildReportSupplementRequest) async throws {
if let supplementError { throw supplementError }
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)
guard !reportListResponses.isEmpty else {
return WildReportListResponse(list: [], total: 0, page: request.page, pageSize: request.pageSize)
}
return reportListResponses.removeFirst()
}
func reportDetail(id: Int) async throws -> WildReportDetailData {
if let reportDetailError { throw reportDetailError }
reportDetailRequests.append(id)
guard !reportDetailResponses.isEmpty else { return WildReportDetailData(id: id) }
return reportDetailResponses.removeFirst()
}
}
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] = []
var error: Error?
func uploadWildReportAttachment(
data: Data,
fileName: String,
fileType: Int,
scenicId: Int,
onProgress: @escaping (Int) -> Void
) async throws -> String {
if let error { throw error }
onProgress(100)
uploadedFileNames.append(fileName)
return "https://oss.example.com/\(fileName)"
}
}
private func makeAttachment(kind: WildReportAttachmentKind, fileName: String) throws -> WildReportAttachment {
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + "_" + fileName)
try Data("mock".utf8).write(to: url)
return WildReportAttachment(
kind: kind,
title: fileName,
localFileURL: url,
fileName: fileName
)
}
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,
title: String = "疑似打野摄影师举报",
reportType: Int = 2,
reportTypeText: String = "疑似打野",
handleStatus: Int,
handleStatusText: String = "待处理"
) -> WildReportListItem {
WildReportListItem(
id: id,
complaintNo: complaintNo,
title: title,
reportType: reportType,
reportTypeText: reportTypeText,
desc: "测试说明",
scenicId: 128,
scenicName: "伊犁那拉提景区-5A",
locationName: "伊犁那拉提景区-5A",
latitude: "32.4299428",
longitude: "119.4403263",
handleStatus: handleStatus,
handleStatusText: handleStatusText,
createdAt: "2026-07-08 16:06:59"
)
}
private func makeReportDetailData(
handleStatus: Int = 0,
handleStatusText: String = "待处理"
) -> WildReportDetailData {
WildReportDetailData(
id: 32,
complaintNo: "JB20260708917",
title: "私下收款举报",
reportType: 1,
reportTypeText: "私下收款",
desc: "正式详情说明",
supplements: [
WildReportDetailContent(
type: "supplement",
id: 7,
desc: "补充说明",
time: "2026-07-08 17:06:59",
timeLabel: "17:06:59",
evidences: [
WildReportDetailEvidence(
fileType: 1,
fileTypeText: "图片",
fileURL: "https://example.com/supplement.jpg",
fileName: "supplement.jpg"
)
]
)
],
reportContents: [],
scenicId: 128,
scenicName: "伊犁那拉提景区-5A",
storeUserId: 0,
contactPhone: "13800138000",
locationName: "伊犁那拉提景区-5A",
locationAddress: "江苏省扬州市邗江区瘦西湖路",
latitude: "32.4299428",
longitude: "119.4403263",
handleStatus: handleStatus,
handleStatusText: handleStatusText,
handleRemark: "处理备注",
evidenceSummary: WildReportEvidenceSummary(imageCount: 1, videoCount: 1),
evidences: [
WildReportDetailEvidence(
fileType: 1,
fileTypeText: "图片",
fileURL: "https://example.com/report.jpg",
fileName: "report.jpg"
),
WildReportDetailEvidence(
fileType: 2,
fileTypeText: "视频",
fileURL: "https://example.com/report.mov",
fileName: "report.mov"
)
],
paymentScreenshots: [
WildReportDetailEvidence(
fileType: 5,
fileTypeText: "支付截图",
fileURL: "https://example.com/payment.jpg",
fileName: "payment.jpg"
)
],
timeline: [
WildReportDetailTimelineItem(time: "2026-07-08 16:06:59", content: "摄影师提交举报")
],
createdAt: "2026-07-08 16:06:59"
)
}