// // 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": "线索 ID:32", "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": "线索 ID:33", "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: "线索 ID:32", 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: "线索 ID:32", 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: "线索 ID:33", 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" ) }