Files
suixinkan_uikit/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift

1280 lines
55 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
///
final class WildPhotographerReportTests: XCTestCase {
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 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("""
{
"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 testRiskMapAPISendsGETQueryAndDecodesMarkers() 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, "GET")
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/report/risk-map")
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"])
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, WildReportType(value: 1, label: ""))
}
func testSubmitViewModelKeepsReportTypesEmptyWhenReportTypesFail() 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)
XCTAssertTrue(viewModel.reportTypes.isEmpty)
XCTAssertNil(viewModel.selectedReportType)
XCTAssertEqual(messages.last, URLError(.badServerResponse).localizedDescription)
}
func testSubmitFailsWithoutEvidence() async {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
viewModel.selectReportType(WildReportType(value: 2, label: ""))
viewModel.updateDescription("")
viewModel.applyResolvedLocation(HomeLocationSnapshot(latitude: 30.12, longitude: 120.34, address: ""))
await viewModel.submitReport(
api: MockWildPhotographerReportAPI(),
uploader: MockWildReportAttachmentUploader(),
scenicId: 9,
scenicName: ""
)
XCTAssertEqual(viewModel.validationMessage, "")
XCTAssertNil(viewModel.submitResult)
}
func testSubmitFailsWithoutDescription() async throws {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
viewModel.selectReportType(WildReportType(value: 2, label: ""))
viewModel.addImages([try makeAttachment(kind: .image, fileName: "scene.jpg")])
viewModel.updateDescription(" ")
viewModel.applyResolvedLocation(HomeLocationSnapshot(latitude: 30.12, longitude: 120.34, address: ""))
await viewModel.submitReport(
api: MockWildPhotographerReportAPI(),
uploader: MockWildReportAttachmentUploader(),
scenicId: 9,
scenicName: ""
)
XCTAssertEqual(viewModel.validationMessage, "")
XCTAssertNil(viewModel.submitResult)
}
func testSubmitFailsWithoutConfirmedLocation() async throws {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
viewModel.selectReportType(WildReportType(value: 2, label: ""))
viewModel.addImages([try makeAttachment(kind: .image, fileName: "scene.jpg")])
viewModel.updateDescription("")
await viewModel.submitReport(
api: MockWildPhotographerReportAPI(),
uploader: MockWildReportAttachmentUploader(),
scenicId: 9,
scenicName: ""
)
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.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("")
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(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.selectReportType(WildReportType(value: 2, label: ""))
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: 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.selectReportType(WildReportType(value: 2, label: ""))
viewModel.addImages([try makeAttachment(kind: .image, fileName: "scene.jpg")])
viewModel.updateDescription("")
viewModel.applyResolvedLocation(HomeLocationSnapshot(latitude: 30.12, longitude: 120.34, address: " · "))
await viewModel.submitReport(api: api, uploader: uploader, scenicId: 9, scenicName: "")
XCTAssertEqual(api.submitRequests.count, 0)
XCTAssertEqual(viewModel.validationMessage, OSSUploadError.emptyFile.localizedDescription)
}
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])
}
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()
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()
let api = MockWildPhotographerReportAPI()
api.riskMapResponses = [makeRiskMapResponse()]
let locationProvider = MockRiskMapLocationProvider(.success(latitude: 43.3779, longitude: 84.0217))
await viewModel.loadInitial(
api: api,
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 })
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()
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()
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()
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 reportCopyResponse = WildReportCopyResponse()
var reportCopyError: Error?
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 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)
}
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 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] = []
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 {
switch result {
case .success(let latitude, let longitude):
return HomeLocationSnapshot(latitude: latitude, longitude: longitude, address: "")
case .failure:
throw LocationProviderError.locationFailed
}
}
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)
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"
)
}