Files
suixinkan_uikit/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift
汉秋 5eef31b8da 完善景区排队设置页与全局按钮配置迁移。
重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 16:51:23 +08:00

1843 lines
79 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

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

//
// WildPhotographerReportTests.swift
// suixinkanTests
//
import CoreLocation
import 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": "线 ID32",
"report_type_text": "",
"handle_status": 0,
"distance_m": 320,
"created_at": "2026-07-08 16:06:59"
},
{
"marker_type": "yellow",
"id": 33,
"latitude": "43.3652",
"longitude": "84.0385",
"location_name": "",
"title": "线 ID33",
"report_type_text": "",
"handle_status": "1",
"distance_m": 1280,
"created_at": "2026-07-08 16:08:59"
}
]
},
"time": "2026-07-08 18:31:50"
}
""".utf8)
let session = MockURLSession(responses: [json])
let api = WildPhotographerReportAPI(client: APIClient(environment: .testing, session: session))
let response = try await api.riskMap(WildReportRiskMapRequest(
scenicId: 128,
latitude: 43.3779,
longitude: 84.0217
))
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "GET")
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/report/risk-map")
XCTAssertNil(request.httpBody)
let queryItems = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems ?? []
XCTAssertEqual(queryItems.first(where: { $0.name == "scenic_id" })?.value, "128")
XCTAssertEqual(queryItems.first(where: { $0.name == "latitude" })?.value, "43.3779")
XCTAssertEqual(queryItems.first(where: { $0.name == "longitude" })?.value, "84.0217")
XCTAssertEqual(response.scenicName, "伊犁那拉提景区-5A")
XCTAssertEqual(response.greenMarkerTip, "绿色为景区内摄影师")
XCTAssertEqual(response.markers.map(\.markerType), ["red", "yellow"])
XCTAssertEqual(response.markers.last?.handleStatus?.displayText, WildReportStatus.processing.rawValue)
}
func 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: "线索 ID32",
locationName: "空中草原观景道",
latitude: "43.4068",
longitude: "84.0582",
distanceM: 320,
reportTypeText: "疑似打野",
handleStatus: WildReportDecodedHandleStatus(intValue: 0),
desc: "多人结伴揽客",
reporterName: "张三",
reporterPhone: "13333333333",
createdAt: "2026-07-08 16:06:59"
)
]
await viewModel.loadInitial(
api: api,
locationProvider: MockRiskMapLocationProvider(.success(latitude: 43.3779, longitude: 84.0217)),
scenicId: 128
)
let redMarker = try! XCTUnwrap(viewModel.markers.first { $0.kind == .wildPhotographer })
await viewModel.selectMarker(redMarker, api: api, scenicId: 128)
XCTAssertEqual(api.markerDetailRequests.first?.markerType, "red")
XCTAssertEqual(api.markerDetailRequests.first?.id, 32)
XCTAssertEqual(viewModel.selectedClue?.reporterName, "张三")
XCTAssertEqual(viewModel.selectedClue?.maskedReporterPhone, "133****3333")
XCTAssertEqual(viewModel.selectedClue?.detail, "多人结伴揽客")
}
func 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.withLock { 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.withLock {
recordedAccuracies.append(desiredAccuracy)
}
await onCoordinateRequest?(desiredAccuracy)
switch result {
case .success(let latitude, let longitude):
return CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
case .failure:
throw LocationProviderError.locationFailed
}
}
func reverseGeocode(latitude: Double, longitude: Double) async -> String {
"测试地址"
}
}
@MainActor
private final class MockWildReportAttachmentUploader: WildReportAttachmentUploading {
var uploadedFileNames: [String] = []
var error: Error?
func uploadWildReportAttachment(
data: Data,
fileName: String,
fileType: Int,
scenicId: Int,
onProgress: @escaping (Int) -> Void
) async throws -> String {
if let error { throw error }
onProgress(100)
uploadedFileNames.append(fileName)
return "https://oss.example.com/\(fileName)"
}
}
private func makeAttachment(kind: WildReportAttachmentKind, fileName: String) throws -> WildReportAttachment {
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + "_" + fileName)
try Data("mock".utf8).write(to: url)
return WildReportAttachment(
kind: kind,
title: fileName,
localFileURL: url,
fileName: fileName
)
}
private func makeRiskMapResponse() -> WildReportRiskMapResponse {
WildReportRiskMapResponse(
scenicId: 128,
scenicName: "伊犁那拉提景区-5A",
legend: [WildReportRiskLegendItem(markerType: "red", title: "疑似打野")],
greenMarkerTip: "绿色为景区内摄影师",
markers: [
WildReportRiskMapMarkerDTO(
markerType: "red",
id: 32,
latitude: "43.4068",
longitude: "84.0582",
locationName: "空中草原观景道",
title: "线索 ID32",
reportTypeText: "疑似打野",
handleStatus: WildReportDecodedHandleStatus(intValue: 0),
distanceM: 320,
createdAt: "2026-07-08 16:06:59"
),
WildReportRiskMapMarkerDTO(
markerType: "yellow",
id: 33,
latitude: "43.3652",
longitude: "84.0385",
locationName: "塔吾萨尼步道",
title: "线索 ID33",
reportTypeText: "处理中",
handleStatus: WildReportDecodedHandleStatus(intValue: 1),
distanceM: 1280,
createdAt: "2026-07-08 16:08:59"
),
WildReportRiskMapMarkerDTO(
markerType: "green",
id: 901,
latitude: "43.4042",
longitude: "84.0186",
locationName: "空中草原",
title: "景区内摄影师",
distanceM: 430,
createdAt: "2026-07-08 16:09:59",
name: "阿依达娜",
storeName: "空中草原旅拍店",
online: true,
activityText: "当前在空中草原区域服务游客拍摄。"
)
]
)
}
private func makeReportListItem(
id: Int = 32,
complaintNo: String,
title: String = "疑似打野摄影师举报",
reportType: Int = 2,
reportTypeText: String = "疑似打野",
handleStatus: Int,
handleStatusText: String = "待处理"
) -> WildReportListItem {
WildReportListItem(
id: id,
complaintNo: complaintNo,
title: title,
reportType: reportType,
reportTypeText: reportTypeText,
desc: "测试说明",
scenicId: 128,
scenicName: "伊犁那拉提景区-5A",
locationName: "伊犁那拉提景区-5A",
latitude: "32.4299428",
longitude: "119.4403263",
handleStatus: handleStatus,
handleStatusText: handleStatusText,
createdAt: "2026-07-08 16:06:59"
)
}
private func makeReportDetailData(
handleStatus: Int = 0,
handleStatusText: String = "待处理",
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"
)
}