// // WildPhotographerReportTests.swift // suixinkanTests // import CoreLocation import MapKit 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 testSubmitReportAPIReturnsComplaintNumber() async throws { let json = Data(""" { "code": 100000, "data": { "complaint_no": "JB20260709886", "id": 40 }, "msg": "success", "time": "2026-07-09 14:09:41" } """.utf8) let session = MockURLSession(responses: [json]) let api = WildPhotographerReportAPI(client: APIClient(environment: .testing, session: session)) let response = try await api.submitReport(WildReportSubmitRequest( scenicId: 128, reportType: 2, desc: "测试说明", storeUserId: nil, contactPhone: "13800138000", locationName: "测试景区", locationAddress: "测试地址", lat: 30.12, lng: 120.34, evidences: [WildReportEvidenceRequest(fileURL: "https://example.com/scene.jpg", fileType: 1, fileName: "scene.jpg")], paymentScreenshots: [] )) XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/report/submit") XCTAssertEqual(response.id, 40) XCTAssertEqual(response.complaintNo, "JB20260709886") } 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": "", "handled_at": "2026-07-09 14:18:40", "handler": { "id": 43, "name": "管铜彬", "phone": "132****9413", "started_at": "2026-07-09 14:18:15", "finished_at": "2026-07-09 14:18:40", "remark": "我是处理意见" }, "handler_name": "管铜彬", "handler_phone": "132****9413", "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" } ], "handle_evidences": [ { "evidence_kind": 2, "evidence_kind_text": "处理凭证", "file_type": 1, "file_type_text": "图片", "file_url": "http://tmp/handler-one.jpg", "file_name": "handler-one.jpg" }, { "evidence_kind": 2, "evidence_kind_text": "处理凭证", "file_type": 1, "file_type_text": "图片", "file_url": "http://tmp/handler-two.png", "file_name": "handler-two.png" } ], "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, "摄影师提交举报") XCTAssertEqual(response.handler?.name, "管铜彬") XCTAssertEqual(response.handler?.phone, "132****9413") XCTAssertEqual(response.handler?.startedAt, "2026-07-09 14:18:15") XCTAssertEqual(response.handler?.finishedAt, "2026-07-09 14:18:40") XCTAssertEqual(response.handler?.remark, "我是处理意见") XCTAssertEqual(response.handledAt, "2026-07-09 14:18:40") XCTAssertEqual(response.handleEvidences.count, 2) XCTAssertEqual(response.handleEvidences.first?.evidenceKind, 2) XCTAssertEqual(response.handleEvidences.first?.evidenceKindText, "处理凭证") XCTAssertEqual(response.handleEvidences.first?.fileURL, "http://tmp/handler-one.jpg") XCTAssertEqual(response.handleEvidences.last?.fileName, "handler-two.png") } func testReportShareAPILoadsMiniProgramConfig() async throws { let json = Data(""" { "code": 100000, "msg": "success", "data": { "userName": "wx8c85189bf3bdda29", "path": "pages/scenic/radar/report-detail/index?id=32", "title": "私下收款举报", "imagePath": "/assets/share/report-share-cover.png", "webpageUrl": "https://www.youfuntour.com", "withShareTicket": true, "miniprogramType": 0, "scene": 0 }, "time": "2026-07-09 15:42:19" } """.utf8) let session = MockURLSession(responses: [json]) let api = WildPhotographerReportAPI(client: APIClient(environment: .testing, session: session)) let response = try await api.reportShare(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/share") XCTAssertTrue(components?.queryItems?.contains(URLQueryItem(name: "id", value: "32")) == true) XCTAssertEqual(response.userName, "wx8c85189bf3bdda29") XCTAssertEqual(response.path, "pages/scenic/radar/report-detail/index?id=32") XCTAssertEqual(response.title, "私下收款举报") XCTAssertEqual(response.imagePath, "/assets/share/report-share-cover.png") XCTAssertEqual(response.webpageURL, "https://www.youfuntour.com") XCTAssertTrue(response.withShareTicket) XCTAssertEqual(response.miniProgramType, .release) XCTAssertEqual(response.scene, .session) XCTAssertEqual(response.payload.userName, "wx8c85189bf3bdda29") XCTAssertEqual(response.payload.path, "pages/scenic/radar/report-detail/index?id=32") } 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": "线索 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, "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 testMarkerDetailAPISendsGETQueryAndDecodesGreenDetail() 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, "GET") XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/report/marker-detail") XCTAssertNil(request.httpBody) let components = try XCTUnwrap(URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)) let queryItems = Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value) }) XCTAssertEqual(queryItems["scenic_id"], "128") XCTAssertEqual(queryItems["marker_type"], "green") XCTAssertEqual(queryItems["id"], "901") XCTAssertEqual(queryItems["latitude"], "43.3779") XCTAssertEqual(queryItems["longitude"], "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张图片") XCTAssertEqual(detail.supplementRecords.first?.evidences.first?.fileURL, "https://example.com/supplement.jpg") } func testSupplementRecordsPreserveMixedMediaOrderAndSummary() throws { let evidences = [ WildReportDetailEvidence( fileType: 1, fileTypeText: "图片", fileURL: "https://example.com/one.jpg", fileName: "one.jpg" ), WildReportDetailEvidence( fileType: 2, fileTypeText: "视频", fileURL: "https://example.com/two.mp4", fileName: "two.mp4" ), WildReportDetailEvidence( fileType: 5, fileTypeText: "图片", fileURL: "https://example.com/three.png", fileName: "three.png" ) ] let detail = WildReportDetailData( supplements: [ WildReportDetailContent( type: "supplement", id: 7, desc: "补充现场材料", time: "2026-07-13 10:00:00", evidences: evidences ) ] ) let record = try XCTUnwrap(detail.supplementRecords.first) XCTAssertEqual(record.evidences.map(\.fileType), [1, 2, 5]) XCTAssertEqual(record.evidences.map(\.fileURL), [ "https://example.com/one.jpg", "https://example.com/two.mp4", "https://example.com/three.png" ]) XCTAssertEqual(record.imageCount, 2) XCTAssertEqual(record.videoCount, 1) XCTAssertTrue(record.hasMedia) XCTAssertEqual(record.summaryText, "补充现场材料 · 2张图片 · 1段视频") } func testSupplementRecordsFallBackToReportContentsAndKeepMedia() throws { let detail = WildReportDetailData( supplements: [], reportContents: [ WildReportDetailContent( type: "report", id: 1, desc: "初始举报", time: "2026-07-13 09:00:00" ), WildReportDetailContent( type: "supplement", id: 2, desc: "补充视频", time: "2026-07-13 10:00:00", evidences: [ WildReportDetailEvidence( fileType: 2, fileTypeText: "视频", fileURL: "https://example.com/fallback.mp4", fileName: "fallback.mp4" ) ] ) ] ) let record = try XCTUnwrap(detail.supplementRecords.first) XCTAssertEqual(detail.supplementRecords.count, 1) XCTAssertEqual(record.id, "2") XCTAssertEqual(record.evidences.map(\.fileURL), ["https://example.com/fallback.mp4"]) XCTAssertEqual(record.imageCount, 0) XCTAssertEqual(record.videoCount, 1) XCTAssertEqual(record.summaryText, "补充视频 · 1段视频") } func testSupplementRecordSequenceTitleStartsFromDisplayOrder() { let titles = (0..<3).map { WildReportSupplementDisplayFormatter.sequenceTitle(forDisplayIndex: $0) } XCTAssertEqual(titles, ["第1次补充", "第2次补充", "第3次补充"]) } func testReportDetailMapsHandlerInfoFromHandlerFields() throws { let detail = makeReportDetailData( handleStatus: 2, handleStatusText: "已处理", handleRemark: "顶层处理备注", handledAt: "2026-07-09 14:18:40", handler: WildReportDetailHandler( id: 43, name: "管铜彬", phone: "132****9413", startedAt: "2026-07-09 14:18:15", finishedAt: "2026-07-09 14:18:40", remark: "我是处理意见" ), handleEvidences: [ WildReportDetailEvidence( fileType: 1, fileTypeText: "图片", fileURL: "http://tmp/handler-one.jpg", fileName: "handler-one.jpg", evidenceKind: 2, evidenceKindText: "处理凭证" ), WildReportDetailEvidence( fileType: 1, fileTypeText: "图片", fileURL: "http://tmp/handler-two.png", fileName: "handler-two.png", evidenceKind: 2, evidenceKindText: "处理凭证" ) ] ) let info = try XCTUnwrap(detail.toRecord().handlerInfo) XCTAssertEqual(info.handlerName, "管铜彬") XCTAssertEqual(info.handlerPhone, "132****9413") XCTAssertEqual(info.processingAt, "2026-07-09 14:18:15") XCTAssertEqual(info.processedAt, "2026-07-09 14:18:40") XCTAssertEqual(info.processOpinion, "我是处理意见") XCTAssertEqual(info.completionMedia.count, 2) XCTAssertEqual(info.completionMedia.first?.coverURL, "http://tmp/handler-one.jpg") XCTAssertEqual(info.completionMediaSummary, "2张") } func testReportDetailMapsHandlerInfoFromFallbackFields() throws { let detail = makeReportDetailData( handleStatus: 2, handleStatusText: "已处理", handleRemark: "fallback 处理意见", handledAt: "2026-07-09 14:18:40", handlerName: "管铜彬", handlerPhone: "132****9413" ) let info = try XCTUnwrap(detail.toRecord().handlerInfo) XCTAssertEqual(info.handlerName, "管铜彬") XCTAssertEqual(info.handlerPhone, "132****9413") XCTAssertEqual(info.processingAt, "") XCTAssertEqual(info.processedAt, "2026-07-09 14:18:40") XCTAssertEqual(info.processOpinion, "fallback 处理意见") } func testReportShareDataDecodesSnakeCaseAndMapsPayload() throws { let json = Data(""" { "user_name": "gh_report_original_id", "path": "pages/report/detail?id=32", "title": "私下收款举报", "description": "查看举报处理进度", "image_path": "https://example.com/share.png", "webpage_url": "https://www.youfuntour.com/report/32", "with_share_ticket": true, "mini_program_type": 2, "scene": 0, "disable_forward": true } """.utf8) let data = try JSONDecoder().decode(WildReportMiniProgramShareData.self, from: json) XCTAssertEqual(data.userName, "gh_report_original_id") XCTAssertEqual(data.webpageURL, "https://www.youfuntour.com/report/32") XCTAssertEqual(data.miniProgramType, .preview) XCTAssertEqual(data.scene, .session) XCTAssertEqual(data.payload.description, "查看举报处理进度") XCTAssertTrue(data.payload.withShareTicket) XCTAssertTrue(data.payload.disableForward) } 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 testSubmitLocationOmitsProvinceCityDistrict() { let homeViewModel = WildPhotographerReportHomeViewModel() let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel) viewModel.applyResolvedLocation(HomeLocationSnapshot( latitude: 32.40, longitude: 119.41, address: "江苏省扬州市邗江区瘦西湖路" )) XCTAssertEqual(viewModel.locationTitleText, "当前定位:瘦西湖路") XCTAssertEqual(viewModel.reportLocation, "瘦西湖路") } func testSubmitTextInputUpdatesWithoutRequestingStructuralRefresh() { let viewModel = WildPhotographerReportSubmitViewModel( homeViewModel: WildPhotographerReportHomeViewModel() ) var stateChangeCount = 0 viewModel.onStateChange = { stateChangeCount += 1 } viewModel.updateDescription("现场说明") viewModel.updateContact("wx-test") XCTAssertEqual(viewModel.description, "现场说明") XCTAssertEqual(viewModel.contact, "wx-test") XCTAssertEqual(stateChangeCount, 0) } 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() api.submitResponse = WildReportSubmitResponse(id: 40, complaintNo: "JB20260709886") 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.serverID, 40) XCTAssertEqual(viewModel.submitResult?.record.id, "JB20260709886") 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?.locationName, "测试景区") XCTAssertEqual(api.submitRequests.first?.locationAddress, "瘦西湖路") 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) XCTAssertEqual(viewModel.submitResult?.record.location, "瘦西湖路") } 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 testListViewModelLoadsMiniProgramSharePayload() async throws { let homeViewModel = WildPhotographerReportHomeViewModel() let viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel) let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord() let api = MockWildPhotographerReportAPI() api.reportShareResponse = WildReportMiniProgramShareData( userName: "gh_report_original_id", path: "pages/report/detail?id=32", title: "私下收款举报", description: "查看举报处理进度", webpageURL: "https://www.youfuntour.com/report/32", withShareTicket: true, miniProgramTypeValue: WeChatMiniProgramType.preview.rawValue ) let maybePayload = await viewModel.shareReport(record, api: api) let payload = try XCTUnwrap(maybePayload) XCTAssertEqual(api.reportShareRequests, ["32"]) XCTAssertEqual(payload.title, "私下收款举报") XCTAssertEqual(payload.userName, "gh_report_original_id") XCTAssertEqual(payload.path, "pages/report/detail?id=32") XCTAssertEqual(payload.miniProgramType, .preview) XCTAssertTrue(payload.withShareTicket) } func testListViewModelShareRejectsLocalRecord() async { let homeViewModel = WildPhotographerReportHomeViewModel() let viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel) 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 api = MockWildPhotographerReportAPI() var messages: [String] = [] viewModel.onShowMessage = { messages.append($0) } let payload = await viewModel.shareReport(record, api: api) XCTAssertNil(payload) XCTAssertTrue(api.reportShareRequests.isEmpty) XCTAssertEqual(messages.last, "当前举报暂未同步到服务器,无法分享") } 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() await viewModel.loadInitial( api: api, locationProvider: MockRiskMapLocationProvider(.success(latitude: 43.3779, longitude: 84.0217)), scenicId: 0 ) XCTAssertTrue(api.riskMapRequests.isEmpty) XCTAssertEqual(viewModel.errorMessage, "请先选择景区后再查看风险地图") } 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 testRiskMapViewModelFocusCurrentLocationSelectsSelfMarker() async { let viewModel = WildReportRiskMapViewModel() let api = MockWildPhotographerReportAPI() api.riskMapResponses = [makeRiskMapResponse()] 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) let didFocus = viewModel.focusCurrentLocation() XCTAssertTrue(didFocus) XCTAssertEqual(viewModel.selectedMarker?.kind, .selfLocation) XCTAssertEqual(viewModel.region.center.latitude, 43.3779, accuracy: 0.000001) XCTAssertEqual(viewModel.region.center.longitude, 84.0217, accuracy: 0.000001) XCTAssertTrue(api.markerDetailRequests.isEmpty) } func testRiskMapViewModelLoadsMarkerDetailAndUpdatesSelectedClue() async { let viewModel = WildReportRiskMapViewModel() 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 testRiskMapViewModelMapsProcessingMarkerDetailForBottomCard() async { let viewModel = WildReportRiskMapViewModel() let api = MockWildPhotographerReportAPI() api.riskMapResponses = [makeRiskMapResponse()] api.markerDetailResponses = [ WildReportMarkerDetailResponse( markerType: "yellow", id: 33, clueId: "2026年7月9日-0039", title: "疑似打野摄影师举报", statusTag: "正在处理中", latitude: "32.3774100", longitude: "119.3979350", distanceM: 7068, reportTypeText: "疑似打野", desc: "色地方噶是的法规", reporterName: "游客", reporterPhone: "121****1212", createdAt: "2026-07-09 11:37:26", evidences: [ WildReportDetailEvidence(fileType: 1, fileTypeText: "图片", fileURL: "https://example.com/one.jpg"), WildReportDetailEvidence(fileType: 1, fileTypeText: "图片", fileURL: "https://example.com/two.jpg") ] ) ] await viewModel.loadInitial( api: api, locationProvider: MockRiskMapLocationProvider(.success(latitude: 32.429943033854165, longitude: 119.44032552083333)), scenicId: 128 ) let yellowMarker = try! XCTUnwrap(viewModel.markers.first { $0.kind == .processingClue }) await viewModel.selectMarker(yellowMarker, api: api, scenicId: 128) XCTAssertEqual(api.markerDetailRequests.first?.markerType, "yellow") XCTAssertEqual(viewModel.selectedClue?.id, "2026年7月9日-0039") XCTAssertEqual(viewModel.selectedClue?.statusText, "正在处理中") XCTAssertEqual(viewModel.selectedClue?.reporterName, "游客") XCTAssertEqual(viewModel.selectedClue?.reporterPhone, "121****1212") XCTAssertEqual(viewModel.selectedClue?.reportContents.first?.content, "色地方噶是的法规") XCTAssertEqual(viewModel.selectedClue?.images, ["https://example.com/one.jpg", "https://example.com/two.jpg"]) XCTAssertEqual(viewModel.selectedClue?.processingStarted, true) } func testRiskMapViewModelMarkerDetailFailureKeepsSummary() async { let viewModel = WildReportRiskMapViewModel() let api = MockWildPhotographerReportAPI() api.riskMapResponses = [makeRiskMapResponse()] api.markerDetailError = URLError(.badServerResponse) 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(viewModel.errorMessage, URLError(.badServerResponse).localizedDescription) } func testRiskMapViewModelSharesSelectedReportMarker() async { let viewModel = WildReportRiskMapViewModel() let api = MockWildPhotographerReportAPI() api.riskMapResponses = [makeRiskMapResponse()] api.reportShareResponse = WildReportMiniProgramShareData( userName: "gh_test", path: "/pages/report/detail?id=32", title: "举报线索", description: "请协助处理", webpageURL: "https://example.com/report/32", miniProgramTypeValue: WeChatMiniProgramType.preview.rawValue ) 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) let payload = await viewModel.shareSelectedClue(api: api) XCTAssertEqual(api.reportShareRequests, ["32"]) XCTAssertEqual(payload?.title, "举报线索") XCTAssertEqual(payload?.path, "/pages/report/detail?id=32") XCTAssertEqual(payload?.miniProgramType, .preview) } func testRiskMapViewModelDoesNotShareCurrentLocationMarker() async { let viewModel = WildReportRiskMapViewModel() let api = MockWildPhotographerReportAPI() api.riskMapResponses = [makeRiskMapResponse()] await viewModel.loadInitial( api: api, locationProvider: MockRiskMapLocationProvider(.success(latitude: 43.3779, longitude: 84.0217)), scenicId: 128 ) let payload = await viewModel.shareSelectedClue(api: api) XCTAssertNil(payload) XCTAssertTrue(api.reportShareRequests.isEmpty) XCTAssertEqual(viewModel.errorMessage, "请选择可分享的举报线索") } func testDetailViewModelLoadsRemoteDetailWhenServerIDExists() async { let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord() let viewModel = WildPhotographerReportDetailViewModel(record: record) let api = MockWildPhotographerReportAPI() api.reportDetailResponses = [ makeReportDetailData( handleStatus: 2, handleStatusText: "已处理", handler: WildReportDetailHandler( id: 43, name: "管铜彬", phone: "132****9413", startedAt: "2026-07-09 14:18:15", finishedAt: "2026-07-09 14:18:40", remark: "我是处理意见" ), handleEvidences: [ WildReportDetailEvidence( fileType: 1, fileTypeText: "图片", fileURL: "http://tmp/handler-one.jpg", fileName: "handler-one.jpg" ) ] ) ] 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, "摄影师提交举报") XCTAssertTrue(viewModel.showHandlerInfo) XCTAssertEqual(viewModel.handlerInfo?.handlerName, "管铜彬") XCTAssertEqual(viewModel.handlerInfo?.processingAt, "2026-07-09 14:18:15") XCTAssertEqual(viewModel.handlerInfo?.completionMedia.first?.coverURL, "http://tmp/handler-one.jpg") } 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 testDetailViewModelLoadsMiniProgramSharePayload() async throws { let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord() let viewModel = WildPhotographerReportDetailViewModel(record: record) let api = MockWildPhotographerReportAPI() api.reportShareResponse = WildReportMiniProgramShareData( userName: "gh_report_original_id", path: "pages/report/detail?id=32", title: "私下收款举报", webpageURL: "https://www.youfuntour.com/report/32", miniProgramTypeValue: WeChatMiniProgramType.test.rawValue ) let maybePayload = await viewModel.shareReport(api: api) let payload = try XCTUnwrap(maybePayload) XCTAssertEqual(api.reportShareRequests, ["32"]) XCTAssertEqual(payload.webpageURL, "https://www.youfuntour.com/report/32") XCTAssertEqual(payload.userName, "gh_report_original_id") XCTAssertEqual(payload.miniProgramType, .test) } 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 submitResponse = WildReportSubmitResponse() var submitRequests: [WildReportSubmitRequest] = [] var reportListResponses: [WildReportListResponse] = [] var reportListRequests: [WildReportListRequest] = [] var reportListError: Error? var reportDetailResponses: [WildReportDetailData] = [] var reportDetailRequests: [Int] = [] var reportDetailError: Error? var reportShareResponse = WildReportMiniProgramShareData() var reportShareRequests: [String] = [] var reportShareError: 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 -> WildReportSubmitResponse { if let error { throw error } submitRequests.append(request) return submitResponse } 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() } func reportShare(id: String) async throws -> WildReportMiniProgramShareData { if let reportShareError { throw reportShareError } reportShareRequests.append(id) return reportShareResponse } } 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(desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyBest) 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: "线索 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 = "待处理", handleRemark: String = "处理备注", handledAt: String = "", handler: WildReportDetailHandler? = nil, handlerName: String = "", handlerPhone: String = "", handleEvidences: [WildReportDetailEvidence] = [] ) -> 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: handleRemark, handledAt: handledAt, handler: handler, handlerName: handlerName, handlerPhone: handlerPhone, 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" ) ], handleEvidences: handleEvidences, 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" ) }