feat: update app workflows and permissions

This commit is contained in:
2026-07-09 17:34:00 +08:00
parent 43e6133c21
commit 8e356973bd
44 changed files with 2944 additions and 307 deletions

View File

@ -46,6 +46,39 @@ final class CooperationAcquirerViewModelTests: XCTestCase {
XCTAssertEqual(message, "请输入验证码")
}
func testCommissionRateDraftFiltersDigitsAndLimitsLength() {
let viewModel = CooperationAcquirerViewModel(appStore: appStore)
viewModel.updateCommissionRateDraft("a12b345")
XCTAssertEqual(viewModel.commissionRateDraft, "123")
}
func testCommissionSmsCodeDraftFiltersDigitsAndLimitsLength() {
let viewModel = CooperationAcquirerViewModel(appStore: appStore)
viewModel.updateCommissionSmsCodeDraft("1a2b345678")
XCTAssertEqual(viewModel.commissionSmsCodeDraft, "123456")
}
func testRequestCommissionSmsCodeShowsCountdownMessageWhenTimerActive() async {
let json = """
{"code":100000,"msg":"success","data":{}}
""".data(using: .utf8)!
let viewModel = CooperationAcquirerViewModel(appStore: appStore)
var message: String?
viewModel.onShowMessage = { message = $0 }
viewModel.startEditCommissionRate(CooperativeSalerEntity(saleUserId: 1, salerPhone: "13800138000"))
await viewModel.requestCommissionSmsCode(api: makeAPI(responses: [json]))
await Task.yield()
await viewModel.requestCommissionSmsCode(api: makeAPI(responses: []))
XCTAssertEqual(message, "请在倒计时结束后重新获取")
viewModel.dismissCommissionRateDialog()
}
func testProcessScanResultNavigatesWithValidQR() {
let viewModel = CooperationAcquirerViewModel(appStore: appStore)
var navigatedId: Int?

View File

@ -63,6 +63,14 @@ final class HomeCommonMenuStoreTests: XCTestCase {
let item = HomeMenuCatalog.item(for: "report_photographer")
XCTAssertEqual(item?.title, "举报摄影师")
XCTAssertEqual(item?.iconName, "exclamationmark.shield")
XCTAssertEqual(item?.iconName, "shield.fill")
}
func testHomeMenuCatalogUsesSyncedSymbols() {
XCTAssertEqual(HomeMenuCatalog.item(for: "space_settings")?.iconName, "person.crop.square.fill")
XCTAssertEqual(HomeMenuCatalog.item(for: "wallet")?.iconName, "creditcard.fill")
XCTAssertEqual(HomeMenuCatalog.item(for: "sample_management")?.iconName, "point.3.connected.trianglepath.dotted")
XCTAssertEqual(HomeMenuCatalog.item(for: "/scenic-queue")?.iconName, "person.3.fill")
XCTAssertEqual(HomeMenuItem.moreFunctions.iconName, "ellipsis")
}
}

View File

@ -3,6 +3,7 @@
// suixinkanTests
//
import UIKit
import XCTest
@testable import suixinkan
@ -48,6 +49,33 @@ final class HomeViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.locationInfoText, "您已进入打卡范围")
}
func testQuickActionsOnlineEntrySyncsOfflineStyle() throws {
let view = HomeQuickActionsView()
view.apply(isOnline: false)
let statusContainer = try statusActionContainer(in: view)
XCTAssertEqual(statusContainer.backgroundColor?.homeTestHexRGB, AppColor.cardBackground.homeTestHexRGB)
XCTAssertEqual(statusContainer.layer.borderWidth, 1)
XCTAssertEqual(statusLabel(in: statusContainer)?.text, "离线")
XCTAssertEqual(statusLabel(in: statusContainer)?.textColor.homeTestHexRGB, AppColor.textTabInactive.homeTestHexRGB)
XCTAssertEqual(statusIcon(in: statusContainer)?.accessibilityIdentifier, "wifi.slash")
XCTAssertEqual(statusIcon(in: statusContainer)?.tintColor.homeTestHexRGB, AppColor.textTabInactive.homeTestHexRGB)
}
func testQuickActionsOnlineEntrySyncsOnlineStyle() throws {
let view = HomeQuickActionsView()
view.apply(isOnline: true)
let statusContainer = try statusActionContainer(in: view)
XCTAssertEqual(statusContainer.backgroundColor?.homeTestHexRGB, AppColor.online.homeTestHexRGB)
XCTAssertEqual(statusLabel(in: statusContainer)?.text, "在线")
XCTAssertEqual(statusLabel(in: statusContainer)?.textColor.homeTestHexRGB, AppColor.successBackground.homeTestHexRGB)
XCTAssertEqual(statusIcon(in: statusContainer)?.accessibilityIdentifier, "wifi")
XCTAssertEqual(statusIcon(in: statusContainer)?.tintColor.homeTestHexRGB, AppColor.successBackground.homeTestHexRGB)
}
func testRebuildCommonMenusFiltersUnknownURIs() {
appStore.savePermissionItems([
HomePermissionItem(id: "1", name: "钱包", uri: "wallet"),
@ -178,4 +206,49 @@ final class HomeViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.currentScenicName, "")
XCTAssertEqual(appStore.currentScenicName, "")
}
private func statusActionContainer(in view: HomeQuickActionsView) throws -> UIView {
let stackView = try XCTUnwrap(view.subviews.compactMap { $0 as? UIStackView }.first)
XCTAssertEqual(stackView.arrangedSubviews.count, 3)
return stackView.arrangedSubviews[2]
}
private func statusLabel(in view: UIView) -> UILabel? {
if let label = view as? UILabel {
return label
}
for subview in view.subviews {
if let label = statusLabel(in: subview) {
return label
}
}
return nil
}
private func statusIcon(in view: UIView) -> UIImageView? {
if let imageView = view as? UIImageView {
return imageView
}
for subview in view.subviews {
if let imageView = statusIcon(in: subview) {
return imageView
}
}
return nil
}
}
private extension UIColor {
var homeTestHexRGB: UInt {
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
getRed(&red, green: &green, blue: &blue, alpha: &alpha)
let r = Int(round(red * 255))
let g = Int(round(green * 255))
let b = Int(round(blue * 255))
return UInt(r << 16 | g << 8 | b)
}
}

View File

@ -0,0 +1,42 @@
//
// MainTabItemTests.swift
// suixinkanTests
//
import UIKit
import XCTest
@testable import suixinkan
/// TabBar 访
@MainActor
final class MainTabItemTests: XCTestCase {
func testAppTabsUseRestoredAssetIconsAndBuildImages() {
XCTAssertEqual(AppTab.home.unselectedImageName, "tab_home_unselected")
XCTAssertEqual(AppTab.home.selectedImageName, "tab_home_selected")
XCTAssertEqual(AppTab.orders.unselectedImageName, "tab_order_unselected")
XCTAssertEqual(AppTab.statistics.selectedImageName, "tab_data_selected")
XCTAssertEqual(AppTab.profile.unselectedImageName, "tab_profile_unselected")
for tab in AppTab.allCases {
let item = tab.makeTabBarItem()
XCTAssertEqual(item.title, tab.title)
XCTAssertEqual(item.accessibilityIdentifier, "main.tab.\(tab.rawValue)")
XCTAssertNotNil(item.image)
XCTAssertNotNil(item.selectedImage)
XCTAssertEqual(item.image?.renderingMode, .alwaysOriginal)
XCTAssertEqual(item.selectedImage?.renderingMode, .alwaysOriginal)
}
}
func testScanTabItemBuildsGeneratedImageAndKeepsAccessibility() {
let item = MainScanTabItem.makeTabBarItem()
XCTAssertNil(item.title)
XCTAssertEqual(item.accessibilityLabel, "扫码核销")
XCTAssertEqual(item.accessibilityIdentifier, "main.scan")
XCTAssertNotNil(item.image)
XCTAssertNotNil(item.selectedImage)
XCTAssertEqual(item.image?.renderingMode, .alwaysOriginal)
}
}

View File

@ -118,6 +118,18 @@ final class MaterialManagementViewModelTests: XCTestCase {
XCTAssertNil(api.lastUploadRequest?.id)
}
func testUploadDialogStaysHiddenAfterCompletionProgressCallback() async {
let viewModel = MaterialFormViewModel(mode: .create, currentScenicIdProvider: { 3 })
let uploader = MockMaterialUploader(urls: ["https://cdn/a.jpg"])
await viewModel.addLocalMedia([makeUploadItem(fileName: "a.jpg")], uploader: uploader)
await Task.yield()
XCTAssertNil(viewModel.uploadDialogState)
XCTAssertFalse(viewModel.uploadedMediaList.first?.isUploading ?? true)
XCTAssertEqual(viewModel.uploadedMediaList.first?.uploadProgress, 100)
}
func testEditLoadsDetailAndBuildsEditRequest() async {
let api = MockMaterialAPI()
api.spotResponse = ScenicSpotListResponse(list: [ScenicSpotItem(id: 9, name: "打卡点")], total: 1)

View File

@ -47,6 +47,16 @@ final class OrderListUITokensTests: XCTestCase {
badge.applyOrderStatus(50, text: "已退款")
XCTAssertEqual(badge.backgroundColor?.hexRGB, AppColor.dangerBackground.hexRGB)
}
func testOrderTypeChipIntrinsicSizeWrapsTextWithPadding() {
let chip = OrderTypeChipView()
chip.apply(text: "订单类型")
let size = chip.intrinsicContentSize
XCTAssertGreaterThan(size.width, "订单类型".size(withAttributes: [.font: UIFont.app(.caption)]).width)
XCTAssertGreaterThan(size.height, UIFont.app(.caption).lineHeight)
XCTAssertFalse(chip.isHidden)
}
}
private extension UIColor {

View File

@ -0,0 +1,130 @@
//
// OrderRefundDialogValidationTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
final class OrderRefundDialogValidationTests: XCTestCase {
func testPhotographerOrderRequiresRefundReason() {
let result = OrderRefundDialogValidation.validate(
kind: .photographerOrder,
mode: .full,
amountText: "",
reason: "",
fullRefundAmount: "88.00",
maxRefundAmount: "88.00"
)
XCTAssertEqual(failureMessage(from: result), "请输入退款原因")
}
func testPhotographerPartialRefundRequiresAmount() {
let result = OrderRefundDialogValidation.validate(
kind: .photographerOrder,
mode: .partial,
amountText: "",
reason: "用户申请退款",
fullRefundAmount: "88.00",
maxRefundAmount: "88.00"
)
XCTAssertEqual(failureMessage(from: result), "请输入退款金额")
}
func testStoreOrderRefundReasonLengthValidation() {
let shortResult = OrderRefundDialogValidation.validate(
kind: .storeOrder,
mode: .full,
amountText: "",
reason: "太短",
fullRefundAmount: "0",
maxRefundAmount: "99.00"
)
XCTAssertEqual(failureMessage(from: shortResult), "退款原因至少5个字符")
let longResult = OrderRefundDialogValidation.validate(
kind: .storeOrder,
mode: .full,
amountText: "",
reason: String(repeating: "a", count: 256),
fullRefundAmount: "0",
maxRefundAmount: "99.00"
)
XCTAssertEqual(failureMessage(from: longResult), "退款原因最多255个字符")
}
func testStoreOrderPartialRefundAmountValidation() {
let emptyResult = OrderRefundDialogValidation.validate(
kind: .storeOrder,
mode: .partial,
amountText: "",
reason: "用户申请退款",
fullRefundAmount: "0",
maxRefundAmount: "99.00"
)
XCTAssertEqual(failureMessage(from: emptyResult), "请输入退款金额")
let invalidResult = OrderRefundDialogValidation.validate(
kind: .storeOrder,
mode: .partial,
amountText: "0",
reason: "用户申请退款",
fullRefundAmount: "0",
maxRefundAmount: "99.00"
)
XCTAssertEqual(failureMessage(from: invalidResult), "请输入有效的退款金额")
let overflowResult = OrderRefundDialogValidation.validate(
kind: .storeOrder,
mode: .partial,
amountText: "100.00",
reason: "用户申请退款",
fullRefundAmount: "0",
maxRefundAmount: "99.00"
)
XCTAssertEqual(failureMessage(from: overflowResult), "退款金额不能大于实际支付金额")
}
func testStoreOrderFullRefundUsesZeroAmount() throws {
let result = OrderRefundDialogValidation.validate(
kind: .storeOrder,
mode: .full,
amountText: "99.00",
reason: "用户申请退款",
fullRefundAmount: "0",
maxRefundAmount: "99.00"
)
let payload = try successPayload(from: result)
XCTAssertEqual(payload.refundType, OrderRefundMode.full.rawValue)
XCTAssertEqual(payload.refundAmount, "0")
XCTAssertEqual(payload.refundReason, "用户申请退款")
}
func testMoneyInputKeepsSingleDotAndTwoDecimalPlaces() {
XCTAssertEqual(OrderRefundDialogValidation.filterMoneyInput("12..345a6", maxLength: 10), "12.34")
XCTAssertEqual(OrderRefundDialogValidation.filterMoneyInput("12345678901", maxLength: 10), "1234567890")
}
private func failureMessage(from result: Result<OrderRefundDialogPayload, OrderRefundDialogValidationError>) -> String? {
if case .failure(let error) = result {
return error.message
}
return nil
}
private func successPayload(
from result: Result<OrderRefundDialogPayload, OrderRefundDialogValidationError>
) throws -> OrderRefundDialogPayload {
switch result {
case .success(let payload):
return payload
case .failure(let error):
XCTFail("Expected success, got failure: \(error.message)")
throw NSError(domain: "OrderRefundDialogValidationTests", code: 1)
}
}
}

View File

@ -4,6 +4,7 @@
//
import UIKit
import WechatOpenSDK
import XCTest
@testable import suixinkan
@ -24,6 +25,24 @@ final class WeChatShareServiceTests: XCTestCase {
XCTAssertEqual(WeChatShareScene.favorite.wxSceneValue, 2)
}
/// SDK
func testMiniProgramTypeMapsToWXValues() {
XCTAssertEqual(WeChatMiniProgramType.release.rawValue, 0)
XCTAssertEqual(WeChatMiniProgramType.test.rawValue, 1)
XCTAssertEqual(WeChatMiniProgramType.preview.rawValue, 2)
}
/// Debug 使Release
func testMiniProgramTypeResolvesForCurrentBuild() {
#if DEBUG
XCTAssertEqual(WeChatMiniProgramType.release.resolvedForCurrentBuild, .preview)
XCTAssertEqual(WeChatMiniProgramType.test.resolvedForCurrentBuild, .preview)
#else
XCTAssertEqual(WeChatMiniProgramType.release.resolvedForCurrentBuild, .release)
XCTAssertEqual(WeChatMiniProgramType.test.resolvedForCurrentBuild, .test)
#endif
}
///
func testShareResultFromWXErrorCode() {
XCTAssertEqual(WeChatShareResult.fromWXErrorCode(0), .success)
@ -45,6 +64,23 @@ final class WeChatShareServiceTests: XCTestCase {
XCTAssertLessThanOrEqual(thumbData.count, WeChatThumbImageProcessor.maxThumbBytes)
}
///
func testMiniProgramHDImageProcessorKeepsDataUnderLimit() throws {
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 1200, height: 800))
let image = renderer.image { context in
UIColor.systemGreen.setFill()
context.fill(CGRect(x: 0, y: 0, width: 1200, height: 800))
UIColor.white.setFill()
context.fill(CGRect(x: 160, y: 120, width: 880, height: 560))
UIColor.systemBlue.setFill()
context.fill(CGRect(x: 260, y: 220, width: 680, height: 360))
}
let imageData = try XCTUnwrap(WeChatThumbImageProcessor.makeMiniProgramHDImageData(from: image))
XCTAssertLessThanOrEqual(imageData.count, WeChatThumbImageProcessor.maxMiniProgramHDImageBytes)
XCTAssertNil(WeChatThumbImageProcessor.makeMiniProgramHDImageData(from: image, maxBytes: 0))
}
///
func testShareWebPageRejectsInvalidPayloadBeforeSending() {
var results: [WeChatShareResult] = []
@ -66,4 +102,90 @@ final class WeChatShareServiceTests: XCTestCase {
.invalidPayload("分享链接不能为空"),
])
}
///
func testMakeMiniProgramRequestFillsWXObject() throws {
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 600, height: 400))
let image = renderer.image { context in
UIColor.systemOrange.setFill()
context.fill(CGRect(x: 0, y: 0, width: 600, height: 400))
}
let payload = WeChatMiniProgramSharePayload(
title: " 举报详情 ",
description: "查看处理进度",
webpageURL: " https://www.zhifly.cn/report/40 ",
userName: " gh_xxxxxxxx ",
path: " /pages/report/detail?id=40 ",
miniProgramType: .preview,
withShareTicket: true,
disableForward: true
)
let request = try XCTUnwrap(try WeChatShareService.makeMiniProgramRequest(payload, scene: .session, previewImage: image).get())
let message = try XCTUnwrap(request.message)
let miniProgramObject = try XCTUnwrap(message.mediaObject as? WXMiniProgramObject)
XCTAssertFalse(request.bText)
XCTAssertEqual(request.scene, WeChatShareScene.session.wxSceneValue)
XCTAssertEqual(message.title, "举报详情")
XCTAssertEqual(message.description, "查看处理进度")
XCTAssertEqual(miniProgramObject.webpageUrl, "https://www.zhifly.cn/report/40")
XCTAssertEqual(miniProgramObject.userName, "gh_xxxxxxxx")
XCTAssertEqual(miniProgramObject.path, "/pages/report/detail?id=40")
XCTAssertEqual(miniProgramObject.miniProgramType.rawValue, UInt(WeChatMiniProgramType.preview.resolvedForCurrentBuild.rawValue))
XCTAssertEqual(miniProgramObject.withShareTicket, true)
XCTAssertEqual(miniProgramObject.disableForward, true)
XCTAssertLessThanOrEqual(miniProgramObject.hdImageData?.count ?? 0, WeChatThumbImageProcessor.maxMiniProgramHDImageBytes)
}
///
func testMakeMiniProgramRequestRejectsInvalidPayload() {
let valid = WeChatMiniProgramSharePayload(
title: "举报详情",
description: "查看处理进度",
webpageURL: "https://www.zhifly.cn/report/40",
userName: "gh_xxxxxxxx"
)
let longURL = "https://www.zhifly.cn/" + String(repeating: "a", count: 1025)
XCTAssertEqual(
WeChatShareService.makeMiniProgramRequest(valid, scene: .timeline).failure,
.invalidPayload("小程序仅支持分享到微信好友")
)
XCTAssertEqual(
WeChatShareService.makeMiniProgramRequest(
WeChatMiniProgramSharePayload(title: " ", description: "", webpageURL: valid.webpageURL, userName: valid.userName),
scene: .session
).failure,
.invalidPayload("分享标题不能为空")
)
XCTAssertEqual(
WeChatShareService.makeMiniProgramRequest(
WeChatMiniProgramSharePayload(title: valid.title, description: "", webpageURL: " ", userName: valid.userName),
scene: .session
).failure,
.invalidPayload("小程序兼容网页链接不能为空")
)
XCTAssertEqual(
WeChatShareService.makeMiniProgramRequest(
WeChatMiniProgramSharePayload(title: valid.title, description: "", webpageURL: longURL, userName: valid.userName),
scene: .session
).failure,
.invalidPayload("小程序兼容网页链接不能超过1024字节")
)
XCTAssertEqual(
WeChatShareService.makeMiniProgramRequest(
WeChatMiniProgramSharePayload(title: valid.title, description: "", webpageURL: valid.webpageURL, userName: " "),
scene: .session
).failure,
.invalidPayload("小程序原始 ID 不能为空")
)
}
}
private extension Result where Success == SendMessageToWXReq, Failure == WeChatShareResult {
var failure: WeChatShareResult? {
guard case .failure(let result) = self else { return nil }
return result
}
}

View File

@ -233,6 +233,17 @@ final class WildPhotographerReportTests: XCTestCase {
"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": [
{
@ -248,6 +259,24 @@ final class WildPhotographerReportTests: XCTestCase {
"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,
@ -276,6 +305,55 @@ final class WildPhotographerReportTests: XCTestCase {
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 {
@ -472,6 +550,106 @@ final class WildPhotographerReportTests: XCTestCase {
XCTAssertEqual(detail.supplementRecords.first?.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)
@ -731,6 +909,61 @@ final class WildPhotographerReportTests: XCTestCase {
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()
@ -1023,7 +1256,28 @@ final class WildPhotographerReportTests: XCTestCase {
let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord()
let viewModel = WildPhotographerReportDetailViewModel(record: record)
let api = MockWildPhotographerReportAPI()
api.reportDetailResponses = [makeReportDetailData()]
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)
@ -1032,6 +1286,10 @@ final class WildPhotographerReportTests: XCTestCase {
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 {
@ -1048,6 +1306,27 @@ final class WildPhotographerReportTests: XCTestCase {
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",
@ -1087,6 +1366,9 @@ private final class MockWildPhotographerReportAPI: WildPhotographerReportServing
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] = []
@ -1150,6 +1432,12 @@ private final class MockWildPhotographerReportAPI: WildPhotographerReportServing
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 {
@ -1312,7 +1600,13 @@ private func makeReportListItem(
private func makeReportDetailData(
handleStatus: Int = 0,
handleStatusText: String = "待处理"
handleStatusText: String = "待处理",
handleRemark: String = "处理备注",
handledAt: String = "",
handler: WildReportDetailHandler? = nil,
handlerName: String = "",
handlerPhone: String = "",
handleEvidences: [WildReportDetailEvidence] = []
) -> WildReportDetailData {
WildReportDetailData(
id: 32,
@ -1349,7 +1643,11 @@ private func makeReportDetailData(
longitude: "119.4403263",
handleStatus: handleStatus,
handleStatusText: handleStatusText,
handleRemark: "处理备注",
handleRemark: handleRemark,
handledAt: handledAt,
handler: handler,
handlerName: handlerName,
handlerPhone: handlerPhone,
evidenceSummary: WildReportEvidenceSummary(imageCount: 1, videoCount: 1),
evidences: [
WildReportDetailEvidence(
@ -1365,6 +1663,7 @@ private func makeReportDetailData(
fileName: "report.mov"
)
],
handleEvidences: handleEvidences,
paymentScreenshots: [
WildReportDetailEvidence(
fileType: 5,