新增订单长尾流程,并迁移排队、消息、结算与审核模块
将定金订单、历史拍摄与多行程上传接入 Orders,并以真实页面替换首页排队管理、消息中心、景区结算与提现审核占位入口。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
27
suixinkanTests/Fixtures/deposit_order_list_success.json
Normal file
27
suixinkanTests/Fixtures/deposit_order_list_success.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"total": "2",
|
||||
"list": [
|
||||
{
|
||||
"id": "4101",
|
||||
"order_number": "DEP001",
|
||||
"user_phone": "13800000000",
|
||||
"amount": 99.5,
|
||||
"status": "20",
|
||||
"status_name": "待核销",
|
||||
"created_at": "2026-05-23 09:00:00"
|
||||
},
|
||||
{
|
||||
"id": 4102,
|
||||
"order_number": "DEP002",
|
||||
"user_phone": 13900000000,
|
||||
"amount": "199.00",
|
||||
"status": 50,
|
||||
"status_name": "已退款",
|
||||
"created_at": "2026-05-22 18:30:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
30
suixinkanTests/Fixtures/message_list_page2_success.json
Normal file
30
suixinkanTests/Fixtures/message_list_page2_success.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"has_more": 0,
|
||||
"last_id": 99,
|
||||
"items": [
|
||||
{
|
||||
"id": 100,
|
||||
"type": 2,
|
||||
"type_name": "核销",
|
||||
"title": "",
|
||||
"content": "订单已核销",
|
||||
"push_at": "2026-05-23 02:50",
|
||||
"created_at": "2026-05-23 02:50",
|
||||
"is_read": "1"
|
||||
},
|
||||
{
|
||||
"id": 99,
|
||||
"type": 0,
|
||||
"type_name": "",
|
||||
"title": "",
|
||||
"content": "系统维护通知",
|
||||
"push_at": "2026-05-23 02:40",
|
||||
"created_at": "2026-05-23 02:40",
|
||||
"is_read": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
30
suixinkanTests/Fixtures/message_list_success.json
Normal file
30
suixinkanTests/Fixtures/message_list_success.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"has_more": 1,
|
||||
"last_id": "101",
|
||||
"items": [
|
||||
{
|
||||
"id": "101",
|
||||
"type": "1",
|
||||
"type_name": "订单",
|
||||
"title": "新订单提醒",
|
||||
"content": "你有新的拍摄订单",
|
||||
"push_at": "2026-05-23 02:55",
|
||||
"created_at": "2026-05-23 02:55",
|
||||
"is_read": 0
|
||||
},
|
||||
{
|
||||
"id": 100,
|
||||
"type": 2,
|
||||
"type_name": "核销",
|
||||
"title": "",
|
||||
"content": "订单已核销",
|
||||
"push_at": "2026-05-23 02:50",
|
||||
"created_at": "2026-05-23 02:50",
|
||||
"is_read": "1"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"project_name": "多点旅拍",
|
||||
"project_type": "19",
|
||||
"project_type_name": "多点旅拍",
|
||||
"photog_spot_list": [
|
||||
{
|
||||
"scenic_spot_id": "901",
|
||||
"photog_uid": "42",
|
||||
"scenic_spot_name": "湖心亭",
|
||||
"photog_nickname": "小景",
|
||||
"photog_name": "测试摄影师",
|
||||
"files": [
|
||||
{
|
||||
"file_name": "spot-901.jpg",
|
||||
"file_url": "https://cdn.example.com/multi/spot-901.jpg",
|
||||
"file_type": "1",
|
||||
"file_size": "204800",
|
||||
"cover_url": "",
|
||||
"upload_time": "2026-05-23 04:22:00"
|
||||
},
|
||||
{
|
||||
"file_name": "spot-901.mp4",
|
||||
"file_url": "https://cdn.example.com/multi/spot-901.mp4",
|
||||
"file_type": 2,
|
||||
"file_size": 1048576,
|
||||
"cover_url": "https://cdn.example.com/multi/spot-901-cover.jpg",
|
||||
"upload_time": "2026-05-23 04:23:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": [
|
||||
{
|
||||
"id": 901,
|
||||
"name": "湖心亭"
|
||||
},
|
||||
{
|
||||
"id": "902",
|
||||
"name": "观景台"
|
||||
}
|
||||
]
|
||||
}
|
||||
63
suixinkanTests/Fixtures/scenic_queue_home_success.json
Normal file
63
suixinkanTests/Fixtures/scenic_queue_home_success.json
Normal file
@ -0,0 +1,63 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"stats": {
|
||||
"type": "1"
|
||||
},
|
||||
"list": {
|
||||
"total": "2",
|
||||
"page": "1",
|
||||
"page_size": "20",
|
||||
"list": [
|
||||
{
|
||||
"id": "1001",
|
||||
"queue_code": "A001",
|
||||
"mobile": "13800000000",
|
||||
"status": "1",
|
||||
"status_text": "等待中",
|
||||
"wait_min": "8",
|
||||
"ahead_count": "0",
|
||||
"is_called": "1",
|
||||
"queue_ban_label": "限制排队7天",
|
||||
"identity_tag": "打野摄影师",
|
||||
"queue_time": "2026-05-23 03:05:00",
|
||||
"queue_count_today": "2",
|
||||
"uid": "7001",
|
||||
"mark_as_photog": "0",
|
||||
"mark_as_freelance_photog": "1",
|
||||
"is_miss_requeue": "1",
|
||||
"is_miss_requeue_text": "重排",
|
||||
"created_at": "2026-05-23 03:05",
|
||||
"called_at": "2026-05-23 03:06",
|
||||
"expired_at": "",
|
||||
"finished_at": ""
|
||||
},
|
||||
{
|
||||
"id": 1002,
|
||||
"queue_code": "A002",
|
||||
"mobile": "13900000000",
|
||||
"status": 1,
|
||||
"status_text": "等待中",
|
||||
"wait_min": 10,
|
||||
"ahead_count": 1,
|
||||
"is_called": 0,
|
||||
"queue_ban_label": "",
|
||||
"identity_tag": "普通用户",
|
||||
"queue_time": "05-23 03:07",
|
||||
"queue_count_today": 1,
|
||||
"uid": 7002,
|
||||
"mark_as_photog": 1,
|
||||
"mark_as_freelance_photog": 0,
|
||||
"is_miss_requeue": 0,
|
||||
"is_miss_requeue_text": "",
|
||||
"created_at": "2026-05-23 03:07",
|
||||
"called_at": "",
|
||||
"expired_at": "",
|
||||
"finished_at": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"time": "2026-05-23 03:08"
|
||||
}
|
||||
}
|
||||
7
suixinkanTests/Fixtures/scenic_queue_qrcode_success.json
Normal file
7
suixinkanTests/Fixtures/scenic_queue_qrcode_success.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"qrcode_url": "https://cdn.example.com/queue/spot-9.png"
|
||||
}
|
||||
}
|
||||
40
suixinkanTests/Fixtures/scenic_queue_setting_success.json
Normal file
40
suixinkanTests/Fixtures/scenic_queue_setting_success.json
Normal file
@ -0,0 +1,40 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"exists": "1",
|
||||
"setting": {
|
||||
"id": "11",
|
||||
"scenic_id": "88",
|
||||
"scenic_spot_id": "9",
|
||||
"scenic_spot_name": "东门打卡点",
|
||||
"photo_estimate_min": "2",
|
||||
"photo_estimate_sec": "30",
|
||||
"first_notice_threshold_pos": "3",
|
||||
"first_notice_sms_enabled": "1",
|
||||
"first_notice_call_enabled": "1",
|
||||
"second_notice_threshold_pos": "1",
|
||||
"second_notice_sms_enabled": "0",
|
||||
"second_notice_call_enabled": "1",
|
||||
"countdown_broadcast_interval_sec": "15",
|
||||
"countdown_readable_threshold_sec": "60",
|
||||
"business_start_time": "08:00:00",
|
||||
"business_end_time": "22:00:59",
|
||||
"status": "1",
|
||||
"remark": "自动叫号",
|
||||
"voice_broadcasts": [
|
||||
{
|
||||
"content": "A001请到拍摄点",
|
||||
"sort_order": "1"
|
||||
},
|
||||
{
|
||||
"content": "请后续游客到等候区",
|
||||
"sort_order": "2"
|
||||
}
|
||||
],
|
||||
"created_at": "2026-05-23 03:10",
|
||||
"updated_at": "2026-05-23 03:11"
|
||||
},
|
||||
"time": "2026-05-23 03:12"
|
||||
}
|
||||
}
|
||||
9
suixinkanTests/Fixtures/scenic_queue_stats_success.json
Normal file
9
suixinkanTests/Fixtures/scenic_queue_stats_success.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"queue_count": "5",
|
||||
"avg_wait_min": "12.5",
|
||||
"time": "2026-05-23 03:10"
|
||||
}
|
||||
}
|
||||
21
suixinkanTests/Fixtures/scenic_spot_list_all_success.json
Normal file
21
suixinkanTests/Fixtures/scenic_spot_list_all_success.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"total": "2",
|
||||
"list": [
|
||||
{
|
||||
"id": "901",
|
||||
"name": "湖心亭",
|
||||
"status": "1",
|
||||
"status_label": "启用"
|
||||
},
|
||||
{
|
||||
"id": 902,
|
||||
"name": "观景台",
|
||||
"status": 0,
|
||||
"status_label": "停用"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"scenic_spot_id": "901",
|
||||
"scenic_spot_name": "湖心亭",
|
||||
"order_type": "4",
|
||||
"order_type_name": "押金订单",
|
||||
"order_comment": {
|
||||
"star_shooting": "5",
|
||||
"star_retouching": "4",
|
||||
"star_scenery": "5",
|
||||
"star_service": "5",
|
||||
"star_camera": "4",
|
||||
"content": "拍摄体验很好",
|
||||
"created_at": "2026-05-23 12:00:00"
|
||||
},
|
||||
"material_list": [
|
||||
{
|
||||
"file_name": "negative-video.mp4",
|
||||
"file_url": "https://cdn.example.com/deposit/negative-video.mp4",
|
||||
"file_type": "1",
|
||||
"file_size": "10485760",
|
||||
"cover_url": "https://cdn.example.com/deposit/negative-video.jpg"
|
||||
},
|
||||
{
|
||||
"file_name": "negative-photo.jpg",
|
||||
"file_url": "https://cdn.example.com/deposit/negative-photo.jpg",
|
||||
"file_type": 2,
|
||||
"file_size": 204800,
|
||||
"cover_url": ""
|
||||
}
|
||||
],
|
||||
"complete_list": [
|
||||
{
|
||||
"file_name": "retouch.jpg",
|
||||
"file_url": "https://cdn.example.com/deposit/retouch.jpg",
|
||||
"file_type": "2",
|
||||
"file_size": "409600",
|
||||
"cover_url": "https://cdn.example.com/deposit/retouch-thumb.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -53,21 +53,22 @@ final class HomeMenuRouterTests: XCTestCase {
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "registration_invitation", title: ""), .destination(.photographerInvite))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_invite", title: ""), .destination(.photographerInvite))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "invite_record", title: ""), .destination(.inviteRecord))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order_detail", title: ""), .destination(.depositOrders))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order", title: ""), .destination(.depositOrders))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order_shooting_info", title: ""), .destination(.depositOrders))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "withdrawal_audit", title: ""), .destination(.withdrawalAudit))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenic_settlement", title: ""), .destination(.scenicSettlement))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenic_settlement_review", title: ""), .destination(.scenicSettlementReview))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "message_center", title: ""), .destination(.messageCenter))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "/scenic-queue", title: ""), .destination(.queueManagement))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "queue_management", title: ""), .destination(.queueManagement))
|
||||
}
|
||||
|
||||
/// 测试已知但未迁移的首页路由会进入安全占位。
|
||||
func testKnownUnmigratedRoutesResolveToPlaceholders() {
|
||||
XCTAssertEqual(
|
||||
HomeMenuRouter.resolve(uri: "scenic_settlement", title: ""),
|
||||
.destination(.modulePlaceholder(uri: "scenic_settlement", title: "景区结算"))
|
||||
)
|
||||
XCTAssertEqual(
|
||||
HomeMenuRouter.resolve(uri: "scenic_settlement_review", title: ""),
|
||||
.destination(.modulePlaceholder(uri: "scenic_settlement_review", title: "结算审核"))
|
||||
)
|
||||
XCTAssertEqual(
|
||||
HomeMenuRouter.resolve(uri: "message_center", title: ""),
|
||||
.destination(.modulePlaceholder(uri: "message_center", title: "消息中心"))
|
||||
HomeMenuRouter.resolve(uri: "live_stream_management", title: ""),
|
||||
.destination(.modulePlaceholder(uri: "live_stream_management", title: "直播管理"))
|
||||
)
|
||||
}
|
||||
|
||||
@ -114,6 +115,8 @@ final class HomeMenuRouterTests: XCTestCase {
|
||||
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "payment_code"), "payment_collection")
|
||||
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "deposit_order_shooting_info"), "deposit_order")
|
||||
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "/scenic-order-manage"), "photographer_orders")
|
||||
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "/scenic-queue"), "queue_management")
|
||||
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "queue_management"), "queue_management")
|
||||
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "android_only_module"), "android_only_module")
|
||||
}
|
||||
|
||||
@ -187,6 +190,11 @@ final class HomeMenuRouterTests: XCTestCase {
|
||||
XCTAssertTrue(uris.contains("schedule_management"))
|
||||
XCTAssertTrue(uris.contains("registration_invitation"))
|
||||
XCTAssertTrue(uris.contains("invite_record"))
|
||||
XCTAssertTrue(uris.contains("deposit_order"))
|
||||
XCTAssertTrue(uris.contains("deposit_order_shooting_info"))
|
||||
XCTAssertTrue(uris.contains("message_center"))
|
||||
XCTAssertTrue(uris.contains("/scenic-queue"))
|
||||
XCTAssertTrue(uris.contains("queue_management"))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "album_list", title: ""), .destination(.albumList))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_management", title: ""), .destination(.sampleLibrary))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_upload", title: ""), .destination(.sampleUpload))
|
||||
@ -197,6 +205,11 @@ final class HomeMenuRouterTests: XCTestCase {
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "schedule_management", title: ""), .destination(.scheduleManagement))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "registration_invitation", title: ""), .destination(.photographerInvite))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "invite_record", title: ""), .destination(.inviteRecord))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order", title: ""), .destination(.depositOrders))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order_shooting_info", title: ""), .destination(.depositOrders))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "message_center", title: ""), .destination(.messageCenter))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "/scenic-queue", title: ""), .destination(.queueManagement))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "queue_management", title: ""), .destination(.queueManagement))
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
@ -103,4 +103,41 @@ private final class MockMainTabOrderService: OrderServing {
|
||||
func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse {
|
||||
try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success")
|
||||
}
|
||||
|
||||
/// 角标测试不会请求押金订单列表。
|
||||
func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload<DepositOrderListItem> {
|
||||
ListPayload(total: 0, list: [])
|
||||
}
|
||||
|
||||
/// 角标测试不会触发押金核销。
|
||||
func depositOrderWriteOff(orderNumber: String) async throws {}
|
||||
|
||||
/// 角标测试不会触发押金退款。
|
||||
func depositOrderRefund(orderNumber: String, refundReason: String) async throws {}
|
||||
|
||||
/// 角标测试不会请求拍摄信息。
|
||||
func storeOrderShootingDetail(
|
||||
storeId: Int,
|
||||
orderNumber: String,
|
||||
scenicSpotId: Int,
|
||||
photogUid: Int
|
||||
) async throws -> StoreOrderShootingDetailResponse {
|
||||
try TestFixture.payload(StoreOrderShootingDetailResponse.self, named: "store_order_shooting_detail_success")
|
||||
}
|
||||
|
||||
/// 角标测试不会触发普通退款。
|
||||
func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws {}
|
||||
|
||||
/// 角标测试不会请求历史拍摄。
|
||||
func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse {
|
||||
try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success")
|
||||
}
|
||||
|
||||
/// 角标测试不会请求已核销打卡点。
|
||||
func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] {
|
||||
[]
|
||||
}
|
||||
|
||||
/// 角标测试不会提交多点旅拍素材。
|
||||
func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws {}
|
||||
}
|
||||
|
||||
167
suixinkanTests/MessageCenter/MessageCenterViewModelTests.swift
Normal file
167
suixinkanTests/MessageCenter/MessageCenterViewModelTests.swift
Normal file
@ -0,0 +1,167 @@
|
||||
//
|
||||
// MessageCenterViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 消息中心测试,覆盖分页、筛选、已读和删除。
|
||||
final class MessageCenterViewModelTests: XCTestCase {
|
||||
func testReloadFirstPageMapsMessagesAndPaginationState() async {
|
||||
let api = MessageCenterMock()
|
||||
api.listResponses = [
|
||||
MessageListResponse(hasMore: true, lastId: 101, items: [
|
||||
MessageEntity(id: 101, type: 1, typeName: "", title: "新订单提醒", content: "有新订单", pushAt: "2026-06-25 10:00", isRead: false),
|
||||
MessageEntity(id: 100, type: 2, typeName: "核销", title: "", content: "核销完成", pushAt: "2026-06-25 09:00", isRead: true)
|
||||
])
|
||||
]
|
||||
let viewModel = MessageCenterViewModel()
|
||||
viewModel.selectedFilter = .unread
|
||||
|
||||
await viewModel.reloadFirstPage(api: api)
|
||||
|
||||
XCTAssertEqual(api.listRequests.map(\.unread), [1])
|
||||
XCTAssertEqual(viewModel.messages.map(\.id), ["msg_101", "msg_100"])
|
||||
XCTAssertEqual(viewModel.messages[0].title, "新订单提醒")
|
||||
XCTAssertEqual(viewModel.messages[0].type, .order)
|
||||
XCTAssertEqual(viewModel.messages[1].title, "核销")
|
||||
XCTAssertEqual(viewModel.messages[1].type, .writeOff)
|
||||
XCTAssertEqual(viewModel.unreadCount, 1)
|
||||
XCTAssertTrue(viewModel.hasMoreMessages)
|
||||
XCTAssertEqual(viewModel.lastId, 101)
|
||||
}
|
||||
|
||||
func testLoadMoreDeduplicatesSortsAndStopsAtTail() async {
|
||||
let api = MessageCenterMock()
|
||||
api.listResponses = [
|
||||
MessageListResponse(hasMore: true, lastId: 101, items: [
|
||||
MessageEntity(id: 101, type: 1, title: "A", content: "A", pushAt: "2026-06-25 10:00"),
|
||||
MessageEntity(id: 100, type: 3, title: "B", content: "B", pushAt: "2026-06-25 09:00")
|
||||
]),
|
||||
MessageListResponse(hasMore: false, lastId: 99, items: [
|
||||
MessageEntity(id: 100, type: 3, title: "B2", content: "B2", pushAt: "2026-06-25 09:00"),
|
||||
MessageEntity(id: 99, type: 0, title: "C", content: "C", pushAt: "2026-06-24 08:00")
|
||||
])
|
||||
]
|
||||
let viewModel = MessageCenterViewModel()
|
||||
|
||||
await viewModel.reloadFirstPage(api: api)
|
||||
await viewModel.loadMore(api: api)
|
||||
await viewModel.loadMore(api: api)
|
||||
|
||||
XCTAssertEqual(api.listRequests.map(\.lastId), [0, 101])
|
||||
XCTAssertEqual(viewModel.messages.map(\.id), ["msg_101", "msg_100", "msg_99"])
|
||||
XCTAssertEqual(viewModel.messages[1].title, "B2")
|
||||
XCTAssertFalse(viewModel.hasMoreMessages)
|
||||
XCTAssertEqual(viewModel.lastId, 99)
|
||||
}
|
||||
|
||||
func testReloadFailureClearsStaleMessagesAndPaging() async {
|
||||
let api = MessageCenterMock()
|
||||
api.listResponses = [
|
||||
MessageListResponse(hasMore: true, lastId: 101, items: [
|
||||
MessageEntity(id: 101, type: 1, title: "A")
|
||||
])
|
||||
]
|
||||
let viewModel = MessageCenterViewModel()
|
||||
await viewModel.reloadFirstPage(api: api)
|
||||
|
||||
api.listError = TestError.sample
|
||||
await viewModel.reloadFirstPage(api: api)
|
||||
|
||||
XCTAssertTrue(viewModel.messages.isEmpty)
|
||||
XCTAssertFalse(viewModel.hasMoreMessages)
|
||||
XCTAssertEqual(viewModel.lastId, 0)
|
||||
XCTAssertTrue(viewModel.loadFailed)
|
||||
}
|
||||
|
||||
func testMarkReadAllReadAndDeleteUpdateOnlyAfterSuccess() async throws {
|
||||
let api = MessageCenterMock()
|
||||
api.listResponses = [
|
||||
MessageListResponse(items: [
|
||||
MessageEntity(id: 101, type: 1, title: "A", isRead: false),
|
||||
MessageEntity(id: 100, type: 0, title: "B", isRead: false)
|
||||
])
|
||||
]
|
||||
let viewModel = MessageCenterViewModel()
|
||||
await viewModel.reloadFirstPage(api: api)
|
||||
|
||||
try await viewModel.markAsRead(api: api, item: try XCTUnwrap(viewModel.messages.first))
|
||||
XCTAssertEqual(api.readIds, [101])
|
||||
XCTAssertEqual(viewModel.messages.map(\.isRead), [true, false])
|
||||
|
||||
api.readIds = []
|
||||
try await viewModel.markAllAsRead(api: api)
|
||||
XCTAssertEqual(api.readIds, [100])
|
||||
XCTAssertTrue(viewModel.messages.allSatisfy(\.isRead))
|
||||
|
||||
let item = try XCTUnwrap(viewModel.messages.first)
|
||||
try await viewModel.deleteMessage(api: api, item: item)
|
||||
XCTAssertEqual(api.deleteIds, [101])
|
||||
XCTAssertFalse(viewModel.messages.contains(where: { $0.id == item.id }))
|
||||
}
|
||||
|
||||
func testFailedReadAndDeleteKeepLocalState() async throws {
|
||||
let api = MessageCenterMock()
|
||||
api.listResponses = [MessageListResponse(items: [MessageEntity(id: 101, type: 1, title: "A", isRead: false)])]
|
||||
let viewModel = MessageCenterViewModel()
|
||||
await viewModel.reloadFirstPage(api: api)
|
||||
let item = try XCTUnwrap(viewModel.messages.first)
|
||||
|
||||
api.readError = TestError.sample
|
||||
await XCTAssertThrowsErrorAsync(try await viewModel.markAsRead(api: api, item: item))
|
||||
XCTAssertFalse(try XCTUnwrap(viewModel.messages.first).isRead)
|
||||
|
||||
api.readError = nil
|
||||
api.deleteError = TestError.sample
|
||||
await XCTAssertThrowsErrorAsync(try await viewModel.deleteMessage(api: api, item: item))
|
||||
XCTAssertEqual(viewModel.messages.map(\.id), [item.id])
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MessageCenterMock: MessageCenterServing {
|
||||
var listResponses: [MessageListResponse] = []
|
||||
var listError: Error?
|
||||
var readError: Error?
|
||||
var deleteError: Error?
|
||||
var readIds: [Int] = []
|
||||
var deleteIds: [Int] = []
|
||||
private(set) var listRequests: [(lastId: Int, limit: Int, unread: Int)] = []
|
||||
|
||||
func messageList(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse {
|
||||
listRequests.append((lastId, limit, unread))
|
||||
if let listError { throw listError }
|
||||
return listResponses.isEmpty ? MessageListResponse() : listResponses.removeFirst()
|
||||
}
|
||||
|
||||
func messageRead(id: Int) async throws {
|
||||
if let readError { throw readError }
|
||||
readIds.append(id)
|
||||
}
|
||||
|
||||
func messageDelete(id: Int) async throws {
|
||||
if let deleteError { throw deleteError }
|
||||
deleteIds.append(id)
|
||||
}
|
||||
}
|
||||
|
||||
private enum TestError: LocalizedError {
|
||||
case sample
|
||||
var errorDescription: String? { "测试错误" }
|
||||
}
|
||||
|
||||
private func XCTAssertThrowsErrorAsync(
|
||||
_ expression: @autoclosure () async throws -> Void,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) async {
|
||||
do {
|
||||
try await expression()
|
||||
XCTFail("Expected error", file: file, line: line)
|
||||
} catch {}
|
||||
}
|
||||
@ -18,6 +18,8 @@ final class NavigationRouterTests: XCTestCase {
|
||||
XCTAssertTrue(AppRoute.home(.wallet).hidesTabBarWhenPushed)
|
||||
XCTAssertTrue(AppRoute.orders(.storeDetail(try! Self.firstOrder())).hidesTabBarWhenPushed)
|
||||
XCTAssertTrue(AppRoute.orders(.writeOffDetail(try! Self.firstWriteOffOrder())).hidesTabBarWhenPushed)
|
||||
XCTAssertTrue(AppRoute.orders(.multiTravelTaskUpload(orderNumber: "MT001")).hidesTabBarWhenPushed)
|
||||
XCTAssertTrue(AppRoute.orders(.orderTrailer(orderNumber: "MT001", title: "尾片上传")).hidesTabBarWhenPushed)
|
||||
}
|
||||
|
||||
/// 测试切换订单 Tab 时会保存订单内部子入口。
|
||||
@ -70,10 +72,14 @@ final class NavigationRouterTests: XCTestCase {
|
||||
|
||||
router.navigate(to: .orders(.storeDetail(order)))
|
||||
router.navigate(to: .orders(.writeOffDetail(writeOff)))
|
||||
router.navigate(to: .orders(.multiTravelTaskUpload(orderNumber: "MT001")))
|
||||
router.navigate(to: .orders(.orderTrailer(orderNumber: "MT001", title: "视频预告")))
|
||||
|
||||
XCTAssertEqual(router.path.count, 2)
|
||||
XCTAssertEqual(router.path.count, 4)
|
||||
XCTAssertEqual(router.path[0], .orders(.storeDetail(order)))
|
||||
XCTAssertEqual(router.path[1], .orders(.writeOffDetail(writeOff)))
|
||||
XCTAssertEqual(router.path[2], .orders(.multiTravelTaskUpload(orderNumber: "MT001")))
|
||||
XCTAssertEqual(router.path[3], .orders(.orderTrailer(orderNumber: "MT001", title: "视频预告")))
|
||||
}
|
||||
|
||||
/// 返回测试用订单管理列表项。
|
||||
|
||||
@ -117,4 +117,53 @@ private final class DetailMockOrderService: OrderServing {
|
||||
}
|
||||
return detailResponses.isEmpty ? try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success") : detailResponses.removeFirst()
|
||||
}
|
||||
|
||||
/// 押金订单列表接口在详情测试中不会被调用。
|
||||
func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload<DepositOrderListItem> {
|
||||
XCTFail("depositOrderList should not be called")
|
||||
return ListPayload(total: 0, list: [])
|
||||
}
|
||||
|
||||
/// 押金核销接口在详情测试中不会被调用。
|
||||
func depositOrderWriteOff(orderNumber: String) async throws {
|
||||
XCTFail("depositOrderWriteOff should not be called")
|
||||
}
|
||||
|
||||
/// 押金退款接口在详情测试中不会被调用。
|
||||
func depositOrderRefund(orderNumber: String, refundReason: String) async throws {
|
||||
XCTFail("depositOrderRefund should not be called")
|
||||
}
|
||||
|
||||
/// 拍摄信息接口在详情测试中不会被调用。
|
||||
func storeOrderShootingDetail(
|
||||
storeId: Int,
|
||||
orderNumber: String,
|
||||
scenicSpotId: Int,
|
||||
photogUid: Int
|
||||
) async throws -> StoreOrderShootingDetailResponse {
|
||||
XCTFail("storeOrderShootingDetail should not be called")
|
||||
return try TestFixture.payload(StoreOrderShootingDetailResponse.self, named: "store_order_shooting_detail_success")
|
||||
}
|
||||
|
||||
/// 普通退款接口在详情测试中不会被调用。
|
||||
func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws {
|
||||
XCTFail("orderRefund should not be called")
|
||||
}
|
||||
|
||||
/// 历史拍摄接口在详情测试中不会被调用。
|
||||
func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse {
|
||||
XCTFail("multiTravelShootHistory should not be called")
|
||||
return try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success")
|
||||
}
|
||||
|
||||
/// 已核销打卡点接口在详情测试中不会被调用。
|
||||
func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] {
|
||||
XCTFail("multiTravelVerifiedScenicSpotList should not be called")
|
||||
return []
|
||||
}
|
||||
|
||||
/// 多点旅拍素材提交接口在详情测试中不会被调用。
|
||||
func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws {
|
||||
XCTFail("multiTravelUploadMaterial should not be called")
|
||||
}
|
||||
}
|
||||
|
||||
596
suixinkanTests/OrderLongTailTests.swift
Normal file
596
suixinkanTests/OrderLongTailTests.swift
Normal file
@ -0,0 +1,596 @@
|
||||
//
|
||||
// OrderLongTailTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 订单长尾测试,覆盖押金订单、退款和历史拍摄的 API 与 ViewModel 规则。
|
||||
final class OrderLongTailTests: XCTestCase {
|
||||
/// 测试押金订单接口使用旧工程路径、分页兜底和请求体。
|
||||
func testDepositOrderAPIRequestsAndDecodesLossyFields() async throws {
|
||||
let session = LongTailRecordingURLSession(responses: [
|
||||
try TestFixture.data(named: "deposit_order_list_success"),
|
||||
try TestFixture.data(named: "empty_success"),
|
||||
try TestFixture.data(named: "empty_success")
|
||||
])
|
||||
let api = OrdersAPI(client: APIClient(session: session))
|
||||
|
||||
let list = try await api.depositOrderList(scenicId: 88, page: 0, pageSize: 0)
|
||||
try await api.depositOrderWriteOff(orderNumber: "DEP001")
|
||||
try await api.depositOrderRefund(orderNumber: "DEP002", refundReason: "游客取消行程")
|
||||
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||||
"/api/yf-handset-app/photog/order/deposit-list",
|
||||
"/api/yf-handset-app/photog/order/deposit-writeoff",
|
||||
"/api/yf-handset-app/photog/order/deposit-refund"
|
||||
])
|
||||
let query = longTailQueryItems(from: try XCTUnwrap(session.requests.first))
|
||||
XCTAssertEqual(query["scenic_id"], "88")
|
||||
XCTAssertEqual(query["page"], "1")
|
||||
XCTAssertEqual(query["page_size"], "1")
|
||||
XCTAssertEqual(list.total, 2)
|
||||
XCTAssertEqual(list.list[1].id, 4102)
|
||||
XCTAssertEqual(list.list[1].userPhone, "13900000000")
|
||||
|
||||
let writeOffBody = try longTailBodyObject(from: session.requests[1])
|
||||
XCTAssertEqual(writeOffBody["order_number"] as? String, "DEP001")
|
||||
let refundBody = try longTailBodyObject(from: session.requests[2])
|
||||
XCTAssertEqual(refundBody["order_number"] as? String, "DEP002")
|
||||
XCTAssertEqual(refundBody["refund_reason"] as? String, "游客取消行程")
|
||||
}
|
||||
|
||||
/// 测试拍摄信息、普通退款和历史拍摄接口 path、query、body 与宽松解码。
|
||||
func testShootingRefundAndHistoryAPIRequestsAndDecodes() async throws {
|
||||
let session = LongTailRecordingURLSession(responses: [
|
||||
try TestFixture.data(named: "store_order_shooting_detail_success"),
|
||||
try TestFixture.data(named: "multi_travel_shoot_history_success"),
|
||||
try TestFixture.data(named: "empty_success")
|
||||
])
|
||||
let api = OrdersAPI(client: APIClient(session: session))
|
||||
|
||||
let shooting = try await api.storeOrderShootingDetail(
|
||||
storeId: 66,
|
||||
orderNumber: "DEP001",
|
||||
scenicSpotId: 901,
|
||||
photogUid: 42
|
||||
)
|
||||
let history = try await api.multiTravelShootHistory(orderNumber: "MT001")
|
||||
try await api.orderRefund(
|
||||
orderNumber: "STORE001",
|
||||
refundType: .partial,
|
||||
refundAmount: "10.50",
|
||||
refundReason: "游客取消部分项目"
|
||||
)
|
||||
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||||
"/api/app/store/order/shooting-detail",
|
||||
"/api/yf-handset-app/photog/order/multi-travel/shoot-history",
|
||||
"/api/yf-handset-app/photog/order/refund"
|
||||
])
|
||||
let shootingQuery = longTailQueryItems(from: session.requests[0])
|
||||
XCTAssertEqual(shootingQuery["store_id"], "66")
|
||||
XCTAssertEqual(shootingQuery["order_number"], "DEP001")
|
||||
XCTAssertEqual(shootingQuery["scenic_spot_id"], "901")
|
||||
XCTAssertEqual(shootingQuery["photog_uid"], "42")
|
||||
XCTAssertEqual(shooting.orderComment?.starShooting, 5)
|
||||
XCTAssertEqual(shooting.materialList.first?.fileSize, 10_485_760)
|
||||
XCTAssertTrue(shooting.materialList.first?.isVideo == true)
|
||||
|
||||
XCTAssertEqual(longTailQueryItems(from: session.requests[1])["order_number"], "MT001")
|
||||
XCTAssertEqual(history.projectType, 19)
|
||||
XCTAssertEqual(history.photogSpotList.first?.photographerDisplayName, "小景")
|
||||
XCTAssertEqual(history.photogSpotList.first?.files.count, 2)
|
||||
|
||||
let refundBody = try longTailBodyObject(from: session.requests[2])
|
||||
XCTAssertEqual(refundBody["order_number"] as? String, "STORE001")
|
||||
XCTAssertEqual(refundBody["refund_type"] as? Int, OrderRefundMode.partial.rawValue)
|
||||
XCTAssertEqual(refundBody["refund_amount"] as? String, "10.50")
|
||||
XCTAssertNil(refundBody["store_id"])
|
||||
}
|
||||
|
||||
/// 测试已核销打卡点和多点旅拍素材提交接口 path、query、body。
|
||||
func testMultiTravelTaskUploadAPIRequestsAndDecodes() async throws {
|
||||
let session = LongTailRecordingURLSession(responses: [
|
||||
try TestFixture.data(named: "multi_travel_verified_spots_success"),
|
||||
try TestFixture.data(named: "empty_success")
|
||||
])
|
||||
let api = OrdersAPI(client: APIClient(session: session))
|
||||
|
||||
let spots = try await api.multiTravelVerifiedScenicSpotList(orderNumber: "MT001")
|
||||
try await api.multiTravelUploadMaterial(
|
||||
MultiTravelUploadMaterialRequest(
|
||||
orderNumber: "MT001",
|
||||
scenicSpotId: 901,
|
||||
cloudFile: [MultiTravelCloudFileItem(fileId: 7001)],
|
||||
uploadFile: [MultiTravelUploadFileItem(fileName: "spot.jpg", fileUrl: "https://cdn.example.com/spot.jpg")]
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||||
"/api/yf-handset-app/photog/order/multi-travel/verified-scenic-spot-list",
|
||||
"/api/yf-handset-app/photog/order/multi-travel/upload-material"
|
||||
])
|
||||
XCTAssertEqual(longTailQueryItems(from: session.requests[0])["order_number"], "MT001")
|
||||
XCTAssertEqual(spots.map(\.id), [901, 902])
|
||||
XCTAssertEqual(spots.last?.name, "观景台")
|
||||
let body = try longTailBodyObject(from: session.requests[1])
|
||||
XCTAssertEqual(body["order_number"] as? String, "MT001")
|
||||
XCTAssertEqual(body["scenic_spot_id"] as? Int, 901)
|
||||
XCTAssertEqual((body["cloud_file"] as? [[String: Any]])?.first?["file_id"] as? Int, 7001)
|
||||
XCTAssertEqual((body["upload_file"] as? [[String: Any]])?.first?["file_url"] as? String, "https://cdn.example.com/spot.jpg")
|
||||
}
|
||||
|
||||
/// 测试押金订单无景区时清空旧数据且不请求接口。
|
||||
func testDepositListClearsWithoutScenicAndStopsAtLastPage() async throws {
|
||||
let service = LongTailMockOrderService()
|
||||
service.depositListResponses = [try Self.depositPayload()]
|
||||
let viewModel = DepositOrderListViewModel()
|
||||
|
||||
await viewModel.reload(api: service, scenicId: 88, reset: true)
|
||||
await viewModel.reload(api: service, scenicId: 88, reset: false)
|
||||
await viewModel.reload(api: service, scenicId: nil, reset: true)
|
||||
|
||||
XCTAssertTrue(viewModel.orders.isEmpty)
|
||||
XCTAssertEqual(viewModel.total, 0)
|
||||
XCTAssertEqual(viewModel.page, 1)
|
||||
XCTAssertEqual(service.depositListCalls, [
|
||||
.init(scenicId: 88, page: 1, pageSize: 10)
|
||||
])
|
||||
}
|
||||
|
||||
/// 测试押金核销和押金退款成功后刷新列表,失败时保留旧列表并清理操作状态。
|
||||
func testDepositWriteOffAndRefundRefreshOrKeepListOnFailure() async throws {
|
||||
let service = LongTailMockOrderService()
|
||||
service.depositListResponses = [
|
||||
try Self.depositPayload(),
|
||||
try Self.depositPayload(),
|
||||
try Self.depositPayload()
|
||||
]
|
||||
let viewModel = DepositOrderListViewModel()
|
||||
await viewModel.reload(api: service, scenicId: 88, reset: true)
|
||||
|
||||
let writeOffSuccess = await viewModel.writeOff(api: service, scenicId: 88, orderNumber: "DEP001")
|
||||
let emptyReason = await viewModel.refund(api: service, scenicId: 88, orderNumber: "DEP001", reason: " ")
|
||||
service.depositRefundError = APIError.serverCode(500, "退款失败")
|
||||
let refundFailure = await viewModel.refund(api: service, scenicId: 88, orderNumber: "DEP001", reason: "游客取消")
|
||||
|
||||
XCTAssertTrue(writeOffSuccess)
|
||||
XCTAssertFalse(emptyReason)
|
||||
XCTAssertFalse(refundFailure)
|
||||
XCTAssertEqual(service.depositWriteOffNumbers, ["DEP001"])
|
||||
XCTAssertEqual(service.depositRefundCalls, [.init(orderNumber: "DEP001", reason: "游客取消")])
|
||||
XCTAssertEqual(service.depositListCalls.count, 2)
|
||||
XCTAssertEqual(viewModel.orders.count, 2)
|
||||
XCTAssertNil(viewModel.operatingOrderNumber)
|
||||
XCTAssertEqual(viewModel.errorMessage, "退款失败")
|
||||
}
|
||||
|
||||
/// 测试押金详情和拍摄信息缺少门店时不请求接口。
|
||||
func testDepositDetailAndShootingSkipWithoutStore() async {
|
||||
let service = LongTailMockOrderService()
|
||||
let detailViewModel = DepositOrderDetailViewModel()
|
||||
let shootingViewModel = DepositOrderShootingInfoViewModel()
|
||||
|
||||
await detailViewModel.load(api: service, storeId: nil, orderNumber: "DEP001")
|
||||
await shootingViewModel.load(api: service, storeId: nil, orderNumber: "DEP001", scenicSpotId: 901, photogUid: 42)
|
||||
|
||||
XCTAssertTrue(service.storeDetailCalls.isEmpty)
|
||||
XCTAssertTrue(service.storeShootingCalls.isEmpty)
|
||||
XCTAssertEqual(detailViewModel.errorMessage, "当前账号缺少门店信息")
|
||||
XCTAssertEqual(shootingViewModel.errorMessage, "当前账号缺少门店信息")
|
||||
}
|
||||
|
||||
/// 测试普通退款入口规则、金额校验、重复提交保护和成功提交参数。
|
||||
func testOrderRefundValidationAndSubmit() async throws {
|
||||
let service = LongTailMockOrderService()
|
||||
let viewModel = OrderRefundViewModel()
|
||||
let refundable = try Self.orderEntity(orderStatus: 18, orderType: 4, actualPayAmount: "99.50")
|
||||
let unavailable = try Self.orderEntity(orderStatus: 50, orderType: 4, actualPayAmount: "99.50")
|
||||
|
||||
XCTAssertTrue(viewModel.canRefund(refundable))
|
||||
XCTAssertFalse(viewModel.canRefund(unavailable))
|
||||
viewModel.begin(item: refundable)
|
||||
XCTAssertEqual(viewModel.amount, "99.50")
|
||||
let missingReasonSuccess = await viewModel.submit(api: service, item: refundable)
|
||||
XCTAssertFalse(missingReasonSuccess)
|
||||
XCTAssertEqual(viewModel.errorMessage, "请输入退款原因")
|
||||
|
||||
viewModel.reason = "部分退款"
|
||||
viewModel.mode = .partial
|
||||
viewModel.amount = "12.345"
|
||||
let invalidAmountSuccess = await viewModel.submit(api: service, item: refundable)
|
||||
XCTAssertFalse(invalidAmountSuccess)
|
||||
XCTAssertEqual(viewModel.errorMessage, "请输入有效的退款金额")
|
||||
|
||||
viewModel.amount = "120.00"
|
||||
let overLimitSuccess = await viewModel.submit(api: service, item: refundable)
|
||||
XCTAssertFalse(overLimitSuccess)
|
||||
XCTAssertEqual(viewModel.errorMessage, "退款金额不能大于可退金额")
|
||||
|
||||
viewModel.amount = "12.3"
|
||||
let submitSuccess = await viewModel.submit(api: service, item: refundable)
|
||||
XCTAssertTrue(submitSuccess)
|
||||
XCTAssertEqual(service.orderRefundCalls, [
|
||||
.init(orderNumber: "STORE001", mode: .partial, amount: "12.30", reason: "部分退款")
|
||||
])
|
||||
XCTAssertNil(viewModel.errorMessage)
|
||||
XCTAssertFalse(viewModel.submitting)
|
||||
}
|
||||
|
||||
/// 测试历史拍摄空订单不请求,成功后展示项目和媒体,失败后清空旧数据。
|
||||
func testHistoricalShootingLoadSuccessAndFailure() async throws {
|
||||
let service = LongTailMockOrderService()
|
||||
service.historyResponses = [try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success")]
|
||||
let viewModel = HistoricalShootingInfoViewModel()
|
||||
|
||||
await viewModel.load(api: service, orderNumber: " ")
|
||||
XCTAssertTrue(service.historyCalls.isEmpty)
|
||||
XCTAssertEqual(viewModel.errorMessage, "订单号不能为空")
|
||||
|
||||
await viewModel.load(api: service, orderNumber: "MT001")
|
||||
XCTAssertEqual(viewModel.projectName, "多点旅拍")
|
||||
XCTAssertEqual(viewModel.spots.first?.scenicSpotName, "湖心亭")
|
||||
XCTAssertEqual(viewModel.spots.first?.files.count, 2)
|
||||
|
||||
service.historyError = APIError.serverCode(500, "历史拍摄失败")
|
||||
await viewModel.load(api: service, orderNumber: "MT001")
|
||||
XCTAssertTrue(viewModel.spots.isEmpty)
|
||||
XCTAssertEqual(viewModel.errorMessage, "历史拍摄失败")
|
||||
}
|
||||
|
||||
/// 测试任务上传空订单不请求,加载成功默认选中首个打卡点,加载失败清空状态。
|
||||
func testMultiTravelTaskUploadLoadsAndRepairsSpotSelection() async throws {
|
||||
let service = LongTailMockOrderService()
|
||||
service.verifiedSpotResponses = [
|
||||
try Self.verifiedSpots(),
|
||||
[try Self.verifiedSpot(id: 902, name: "观景台")]
|
||||
]
|
||||
let viewModel = MultiTravelTaskUploadViewModel()
|
||||
|
||||
await viewModel.loadSpots(api: service)
|
||||
XCTAssertTrue(service.verifiedSpotCalls.isEmpty)
|
||||
|
||||
viewModel.orderNumber = "MT001"
|
||||
await viewModel.loadSpots(api: service)
|
||||
XCTAssertEqual(viewModel.selectedSpotId, 901)
|
||||
viewModel.selectSpot(id: 901)
|
||||
await viewModel.loadSpots(api: service)
|
||||
XCTAssertEqual(viewModel.selectedSpotId, 902)
|
||||
|
||||
service.verifiedSpotError = APIError.serverCode(500, "打卡点失败")
|
||||
await viewModel.loadSpots(api: service)
|
||||
XCTAssertTrue(viewModel.spots.isEmpty)
|
||||
XCTAssertNil(viewModel.selectedSpotId)
|
||||
XCTAssertEqual(viewModel.errorMessage, "打卡点失败")
|
||||
}
|
||||
|
||||
/// 测试任务上传提交校验和仅云盘文件直接提交。
|
||||
func testMultiTravelTaskUploadValidatesAndSubmitsCloudFiles() async throws {
|
||||
let service = LongTailMockOrderService()
|
||||
service.verifiedSpotResponses = [try Self.verifiedSpots()]
|
||||
let uploader = LongTailMockUploader()
|
||||
let viewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: "MT001")
|
||||
|
||||
let missingScenicSuccess = await viewModel.submit(api: service, uploadService: uploader, scenicId: nil)
|
||||
XCTAssertFalse(missingScenicSuccess)
|
||||
XCTAssertEqual(viewModel.errorMessage, "请先选择景区")
|
||||
|
||||
await viewModel.loadSpots(api: service)
|
||||
let missingFileSuccess = await viewModel.submit(api: service, uploadService: uploader, scenicId: 88)
|
||||
XCTAssertFalse(missingFileSuccess)
|
||||
XCTAssertEqual(viewModel.errorMessage, "请至少选择一个素材文件")
|
||||
|
||||
viewModel.mergeCloudFiles([TaskCloudSelectionItem(id: 7001, fileName: "cloud.jpg", fileType: 2, remark: "")])
|
||||
let submitSuccess = await viewModel.submit(api: service, uploadService: uploader, scenicId: 88)
|
||||
|
||||
XCTAssertTrue(submitSuccess)
|
||||
XCTAssertTrue(viewModel.didSubmitSuccessfully)
|
||||
XCTAssertEqual(service.uploadMaterialRequests, [
|
||||
MultiTravelUploadMaterialRequest(
|
||||
orderNumber: "MT001",
|
||||
scenicSpotId: 901,
|
||||
cloudFile: [MultiTravelCloudFileItem(fileId: 7001)],
|
||||
uploadFile: []
|
||||
)
|
||||
])
|
||||
XCTAssertTrue(uploader.uploadTaskFiles.isEmpty)
|
||||
}
|
||||
|
||||
/// 测试任务上传本地文件先上传 OSS,上传失败不提交业务接口。
|
||||
func testMultiTravelTaskUploadUploadsLocalFilesBeforeSubmit() async throws {
|
||||
let service = LongTailMockOrderService()
|
||||
service.verifiedSpotResponses = [try Self.verifiedSpots()]
|
||||
let uploader = LongTailMockUploader()
|
||||
uploader.uploadTaskFileURLs = ["https://cdn.example.com/local.jpg"]
|
||||
let viewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: "MT001")
|
||||
await viewModel.loadSpots(api: service)
|
||||
viewModel.addLocalFile(data: Data([1, 2, 3]), fileName: "local.jpg")
|
||||
|
||||
let success = await viewModel.submit(api: service, uploadService: uploader, scenicId: 88)
|
||||
|
||||
XCTAssertTrue(success)
|
||||
XCTAssertEqual(uploader.uploadTaskFiles.map(\.fileName), ["local.jpg"])
|
||||
XCTAssertEqual(service.uploadMaterialRequests.first?.uploadFile, [
|
||||
MultiTravelUploadFileItem(fileName: "local.jpg", fileUrl: "https://cdn.example.com/local.jpg")
|
||||
])
|
||||
|
||||
let failingService = LongTailMockOrderService()
|
||||
failingService.verifiedSpotResponses = [try Self.verifiedSpots()]
|
||||
let failingUploader = LongTailMockUploader()
|
||||
failingUploader.uploadError = APIError.serverCode(500, "上传失败")
|
||||
let failingViewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: "MT001")
|
||||
await failingViewModel.loadSpots(api: failingService)
|
||||
failingViewModel.addLocalFile(data: Data([1]), fileName: "bad.mp4")
|
||||
|
||||
let failure = await failingViewModel.submit(api: failingService, uploadService: failingUploader, scenicId: 88)
|
||||
|
||||
XCTAssertFalse(failure)
|
||||
XCTAssertTrue(failingService.uploadMaterialRequests.isEmpty)
|
||||
XCTAssertEqual(failingViewModel.errorMessage, "上传失败")
|
||||
}
|
||||
|
||||
/// 读取押金订单列表 fixture。
|
||||
private static func depositPayload() throws -> ListPayload<DepositOrderListItem> {
|
||||
try TestFixture.payload(ListPayload<DepositOrderListItem>.self, named: "deposit_order_list_success")
|
||||
}
|
||||
|
||||
/// 构建最小普通订单实体,用于退款规则测试。
|
||||
private static func orderEntity(orderStatus: Int, orderType: Int, actualPayAmount: String) throws -> OrderEntity {
|
||||
let json = """
|
||||
{
|
||||
"order_number": "STORE001",
|
||||
"order_status": "\(orderStatus)",
|
||||
"order_type": "\(orderType)",
|
||||
"actual_pay_amount": "\(actualPayAmount)",
|
||||
"actual_refund_amount": "0",
|
||||
"refund_amount": "0",
|
||||
"order_type_label": "旅拍订单",
|
||||
"order_status_name": "待退款"
|
||||
}
|
||||
"""
|
||||
return try JSONDecoder().decode(OrderEntity.self, from: Data(json.utf8))
|
||||
}
|
||||
|
||||
/// 读取已核销打卡点 fixture。
|
||||
private static func verifiedSpots() throws -> [MultiTravelVerifiedScenicSpotItem] {
|
||||
try TestFixture.payload([MultiTravelVerifiedScenicSpotItem].self, named: "multi_travel_verified_spots_success")
|
||||
}
|
||||
|
||||
/// 构建已核销打卡点测试实体。
|
||||
private static func verifiedSpot(id: Int, name: String) throws -> MultiTravelVerifiedScenicSpotItem {
|
||||
let json = #"{"id":"\#(id)","name":"\#(name)"}"#
|
||||
return try JSONDecoder().decode(MultiTravelVerifiedScenicSpotItem.self, from: Data(json.utf8))
|
||||
}
|
||||
}
|
||||
|
||||
/// 长尾订单 URLSession 测试替身,按调用顺序返回响应并记录请求。
|
||||
private final class LongTailRecordingURLSession: URLSessionProtocol {
|
||||
private var responses: [Data]
|
||||
private(set) var requests: [URLRequest] = []
|
||||
|
||||
init(responses: [Data]) {
|
||||
self.responses = responses
|
||||
}
|
||||
|
||||
/// 返回队列中的下一个响应。
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
requests.append(request)
|
||||
let data = responses.isEmpty ? Data(#"{"code":100000,"msg":"success"}"#.utf8) : responses.removeFirst()
|
||||
return (
|
||||
data,
|
||||
HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 长尾订单服务测试替身,记录押金、退款和历史拍摄调用。
|
||||
private final class LongTailMockOrderService: OrderServing {
|
||||
struct DepositListCall: Equatable {
|
||||
let scenicId: Int
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
}
|
||||
|
||||
struct DepositRefundCall: Equatable {
|
||||
let orderNumber: String
|
||||
let reason: String
|
||||
}
|
||||
|
||||
struct OrderRefundCall: Equatable {
|
||||
let orderNumber: String
|
||||
let mode: OrderRefundMode
|
||||
let amount: String
|
||||
let reason: String
|
||||
}
|
||||
|
||||
var depositListResponses: [ListPayload<DepositOrderListItem>] = []
|
||||
var detailResponses: [StoreOrderDetailResponse] = []
|
||||
var shootingResponses: [StoreOrderShootingDetailResponse] = []
|
||||
var historyResponses: [MultiTravelShootHistoryResponse] = []
|
||||
var verifiedSpotResponses: [[MultiTravelVerifiedScenicSpotItem]] = []
|
||||
var depositWriteOffError: Error?
|
||||
var depositRefundError: Error?
|
||||
var orderRefundError: Error?
|
||||
var historyError: Error?
|
||||
var verifiedSpotError: Error?
|
||||
var uploadMaterialError: Error?
|
||||
|
||||
private(set) var depositListCalls: [DepositListCall] = []
|
||||
private(set) var depositWriteOffNumbers: [String] = []
|
||||
private(set) var depositRefundCalls: [DepositRefundCall] = []
|
||||
private(set) var orderRefundCalls: [OrderRefundCall] = []
|
||||
private(set) var storeDetailCalls: [(storeId: Int, orderNumber: String)] = []
|
||||
private(set) var storeShootingCalls: [(storeId: Int, orderNumber: String, scenicSpotId: Int, photogUid: Int)] = []
|
||||
private(set) var historyCalls: [String] = []
|
||||
private(set) var verifiedSpotCalls: [String] = []
|
||||
private(set) var uploadMaterialRequests: [MultiTravelUploadMaterialRequest] = []
|
||||
|
||||
/// 基础订单列表接口在长尾测试中不使用。
|
||||
func orderList(
|
||||
scenicId: Int,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
orderStatus: Int?,
|
||||
userPhone: String?,
|
||||
startTime: String?,
|
||||
endTime: String?,
|
||||
isRefined: Int?,
|
||||
isScenicAdmin: Bool
|
||||
) async throws -> ListPayload<OrderEntity> {
|
||||
ListPayload(total: 0, list: [])
|
||||
}
|
||||
|
||||
/// 核销列表接口在长尾测试中不使用。
|
||||
func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload<WriteOffOrderItem> {
|
||||
ListPayload(total: 0, list: [])
|
||||
}
|
||||
|
||||
/// 普通核销接口在长尾测试中不使用。
|
||||
func writeOff(orderNumber: String) async throws {}
|
||||
|
||||
/// 返回门店订单详情。
|
||||
func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse {
|
||||
storeDetailCalls.append((storeId: storeId, orderNumber: orderNumber))
|
||||
return detailResponses.isEmpty ? try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success") : detailResponses.removeFirst()
|
||||
}
|
||||
|
||||
/// 返回押金订单列表。
|
||||
func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload<DepositOrderListItem> {
|
||||
depositListCalls.append(.init(scenicId: scenicId, page: page, pageSize: pageSize))
|
||||
return depositListResponses.isEmpty ? ListPayload(total: 0, list: []) : depositListResponses.removeFirst()
|
||||
}
|
||||
|
||||
/// 记录押金核销订单号。
|
||||
func depositOrderWriteOff(orderNumber: String) async throws {
|
||||
depositWriteOffNumbers.append(orderNumber)
|
||||
if let depositWriteOffError {
|
||||
throw depositWriteOffError
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录押金退款参数。
|
||||
func depositOrderRefund(orderNumber: String, refundReason: String) async throws {
|
||||
depositRefundCalls.append(.init(orderNumber: orderNumber, reason: refundReason))
|
||||
if let depositRefundError {
|
||||
throw depositRefundError
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回拍摄信息详情。
|
||||
func storeOrderShootingDetail(
|
||||
storeId: Int,
|
||||
orderNumber: String,
|
||||
scenicSpotId: Int,
|
||||
photogUid: Int
|
||||
) async throws -> StoreOrderShootingDetailResponse {
|
||||
storeShootingCalls.append((storeId: storeId, orderNumber: orderNumber, scenicSpotId: scenicSpotId, photogUid: photogUid))
|
||||
return shootingResponses.isEmpty ? try TestFixture.payload(StoreOrderShootingDetailResponse.self, named: "store_order_shooting_detail_success") : shootingResponses.removeFirst()
|
||||
}
|
||||
|
||||
/// 记录普通退款参数。
|
||||
func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws {
|
||||
orderRefundCalls.append(.init(orderNumber: orderNumber, mode: refundType, amount: refundAmount, reason: refundReason))
|
||||
if let orderRefundError {
|
||||
throw orderRefundError
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回历史拍摄信息。
|
||||
func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse {
|
||||
historyCalls.append(orderNumber)
|
||||
if let historyError {
|
||||
throw historyError
|
||||
}
|
||||
return historyResponses.isEmpty ? try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success") : historyResponses.removeFirst()
|
||||
}
|
||||
|
||||
/// 返回已核销打卡点列表。
|
||||
func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] {
|
||||
verifiedSpotCalls.append(orderNumber)
|
||||
if let verifiedSpotError {
|
||||
throw verifiedSpotError
|
||||
}
|
||||
return verifiedSpotResponses.isEmpty ? [] : verifiedSpotResponses.removeFirst()
|
||||
}
|
||||
|
||||
/// 记录多点旅拍素材提交请求。
|
||||
func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws {
|
||||
uploadMaterialRequests.append(request)
|
||||
if let uploadMaterialError {
|
||||
throw uploadMaterialError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 长尾订单 OSS 上传测试替身,只记录任务附件上传。
|
||||
private final class LongTailMockUploader: OSSUploadServing {
|
||||
struct UploadTaskFileCall: Equatable {
|
||||
let fileName: String
|
||||
let fileType: Int
|
||||
let scenicId: Int
|
||||
let dataSize: Int
|
||||
}
|
||||
|
||||
var uploadTaskFileURLs: [String] = []
|
||||
var uploadError: Error?
|
||||
private(set) var uploadTaskFiles: [UploadTaskFileCall] = []
|
||||
|
||||
/// 返回预设任务附件 URL。
|
||||
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
uploadTaskFiles.append(.init(fileName: fileName, fileType: fileType, scenicId: scenicId, dataSize: data.count))
|
||||
if let uploadError {
|
||||
throw uploadError
|
||||
}
|
||||
onProgress(100)
|
||||
return uploadTaskFileURLs.isEmpty ? "https://cdn.example.com/\(fileName)" : uploadTaskFileURLs.removeFirst()
|
||||
}
|
||||
|
||||
/// 其他上传入口在本测试中不使用。
|
||||
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
|
||||
/// 其他上传入口在本测试中不使用。
|
||||
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
|
||||
/// 其他上传入口在本测试中不使用。
|
||||
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
|
||||
/// 其他上传入口在本测试中不使用。
|
||||
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
|
||||
/// 其他上传入口在本测试中不使用。
|
||||
func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
|
||||
/// 其他上传入口在本测试中不使用。
|
||||
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
|
||||
/// 其他上传入口在本测试中不使用。
|
||||
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
|
||||
/// 其他上传入口在本测试中不使用。
|
||||
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
}
|
||||
|
||||
/// 提取请求 query 方便断言。
|
||||
private func longTailQueryItems(from request: URLRequest) -> [String: String] {
|
||||
guard let url = request.url,
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
return [:]
|
||||
}
|
||||
return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in
|
||||
item.value.map { (item.name, $0) }
|
||||
})
|
||||
}
|
||||
|
||||
/// 将 JSON body 解成字典方便断言。
|
||||
private func longTailBodyObject(from request: URLRequest) throws -> [String: Any] {
|
||||
let data = try XCTUnwrap(request.httpBody)
|
||||
let object = try JSONSerialization.jsonObject(with: data)
|
||||
return try XCTUnwrap(object as? [String: Any])
|
||||
}
|
||||
@ -309,6 +309,55 @@ private final class MockOrderService: OrderServing {
|
||||
detailCalls.append((storeId: storeId, orderNumber: orderNumber))
|
||||
return detailResponses.isEmpty ? try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success") : detailResponses.removeFirst()
|
||||
}
|
||||
|
||||
/// 押金订单列表接口在基础订单测试中不会被调用。
|
||||
func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload<DepositOrderListItem> {
|
||||
XCTFail("depositOrderList should not be called")
|
||||
return ListPayload(total: 0, list: [])
|
||||
}
|
||||
|
||||
/// 押金核销接口在基础订单测试中不会被调用。
|
||||
func depositOrderWriteOff(orderNumber: String) async throws {
|
||||
XCTFail("depositOrderWriteOff should not be called")
|
||||
}
|
||||
|
||||
/// 押金退款接口在基础订单测试中不会被调用。
|
||||
func depositOrderRefund(orderNumber: String, refundReason: String) async throws {
|
||||
XCTFail("depositOrderRefund should not be called")
|
||||
}
|
||||
|
||||
/// 拍摄信息接口在基础订单测试中不会被调用。
|
||||
func storeOrderShootingDetail(
|
||||
storeId: Int,
|
||||
orderNumber: String,
|
||||
scenicSpotId: Int,
|
||||
photogUid: Int
|
||||
) async throws -> StoreOrderShootingDetailResponse {
|
||||
XCTFail("storeOrderShootingDetail should not be called")
|
||||
return try TestFixture.payload(StoreOrderShootingDetailResponse.self, named: "store_order_shooting_detail_success")
|
||||
}
|
||||
|
||||
/// 普通退款接口在基础订单测试中不会被调用。
|
||||
func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws {
|
||||
XCTFail("orderRefund should not be called")
|
||||
}
|
||||
|
||||
/// 历史拍摄接口在基础订单测试中不会被调用。
|
||||
func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse {
|
||||
XCTFail("multiTravelShootHistory should not be called")
|
||||
return try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success")
|
||||
}
|
||||
|
||||
/// 已核销打卡点接口在基础订单测试中不会被调用。
|
||||
func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] {
|
||||
XCTFail("multiTravelVerifiedScenicSpotList should not be called")
|
||||
return []
|
||||
}
|
||||
|
||||
/// 多点旅拍素材提交接口在基础订单测试中不会被调用。
|
||||
func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws {
|
||||
XCTFail("multiTravelUploadMaterial should not be called")
|
||||
}
|
||||
}
|
||||
|
||||
/// URLSession 测试替身,记录请求并返回固定响应。
|
||||
|
||||
@ -0,0 +1,227 @@
|
||||
//
|
||||
// QueueManagementViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 排队管理列表测试,覆盖点位门禁、列表分页和动作。
|
||||
final class QueueManagementViewModelTests: XCTestCase {
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
clearQueueDefaults()
|
||||
}
|
||||
|
||||
func testReloadWithoutScenicClearsState() async {
|
||||
let api = ScenicQueueMock()
|
||||
let viewModel = QueueManagementViewModel()
|
||||
|
||||
await viewModel.reload(api: api, scenicId: nil, userId: "77", spots: sampleSpots)
|
||||
|
||||
XCTAssertTrue(viewModel.items.isEmpty)
|
||||
XCTAssertNil(viewModel.selectedSpotId)
|
||||
XCTAssertFalse(viewModel.queueGatePassed)
|
||||
XCTAssertEqual(viewModel.queueCount, 0)
|
||||
}
|
||||
|
||||
func testReloadWithoutSavedSpotShowsGateAndDoesNotRequestQueue() async {
|
||||
let api = ScenicQueueMock()
|
||||
let viewModel = QueueManagementViewModel()
|
||||
|
||||
await viewModel.reload(api: api, scenicId: 88, userId: "77", spots: sampleSpots)
|
||||
|
||||
XCTAssertEqual(viewModel.scenicSpots.map(\.id), [901, 902])
|
||||
XCTAssertNil(viewModel.selectedSpotId)
|
||||
XCTAssertFalse(viewModel.queueGatePassed)
|
||||
XCTAssertTrue(api.homeRequests.isEmpty)
|
||||
XCTAssertTrue(api.statsRequests.isEmpty)
|
||||
}
|
||||
|
||||
func testReloadWithSavedSpotLoadsHomeAndStats() async {
|
||||
saveSelectedSpot()
|
||||
let api = ScenicQueueMock()
|
||||
api.homeResponses = [ScenicQueueHomeData(list: ScenicQueueHomeListBlock(list: [ticket(id: 1001), ticket(id: 1002)], total: 2), time: "2026-06-25 10:01")]
|
||||
api.statsResponse = ScenicQueueStatsData(queueCount: 5, avgWaitMin: 12.5, time: "2026-06-25 10:02")
|
||||
let viewModel = QueueManagementViewModel()
|
||||
|
||||
await viewModel.reload(api: api, scenicId: 88, userId: "77", spots: sampleSpots)
|
||||
|
||||
XCTAssertEqual(viewModel.selectedSpotId, 901)
|
||||
XCTAssertTrue(viewModel.queueGatePassed)
|
||||
XCTAssertEqual(viewModel.items.map(\.id), [1001, 1002])
|
||||
XCTAssertEqual(viewModel.queueCount, 5)
|
||||
XCTAssertEqual(viewModel.avgWaitMin, 12.5)
|
||||
XCTAssertEqual(viewModel.lastSyncTimeText, "2026-06-25 10:02")
|
||||
XCTAssertEqual(api.homeRequests.first?.type, QueueListType.queueing.rawValue)
|
||||
}
|
||||
|
||||
func testStatsFailureKeepsQueueList() async {
|
||||
saveSelectedSpot()
|
||||
let api = ScenicQueueMock()
|
||||
api.homeResponses = [ScenicQueueHomeData(list: ScenicQueueHomeListBlock(list: [ticket(id: 1001)], total: 1), time: "home-time")]
|
||||
api.statsError = QueueTestError.sample
|
||||
let viewModel = QueueManagementViewModel()
|
||||
|
||||
await viewModel.reload(api: api, scenicId: 88, userId: "77", spots: sampleSpots)
|
||||
|
||||
XCTAssertEqual(viewModel.items.map(\.id), [1001])
|
||||
XCTAssertEqual(viewModel.queueCount, 0)
|
||||
XCTAssertEqual(viewModel.lastSyncTimeText, "home-time")
|
||||
}
|
||||
|
||||
func testCallPassFinishRequeueAndUserMark() async throws {
|
||||
saveSelectedSpot()
|
||||
let api = ScenicQueueMock()
|
||||
api.homeResponses = Array(repeating: ScenicQueueHomeData(list: ScenicQueueHomeListBlock(list: [ticket(id: 1001)], total: 1)), count: 8)
|
||||
let viewModel = QueueManagementViewModel()
|
||||
await viewModel.reload(api: api, scenicId: 88, userId: "77", spots: sampleSpots)
|
||||
|
||||
try await viewModel.callQueue(api: api, id: 1001)
|
||||
XCTAssertEqual(api.callIds, [1001])
|
||||
XCTAssertEqual(viewModel.items.first?.isCalled, 1)
|
||||
|
||||
try await viewModel.passQueue(api: api, scenicId: 88, userId: "77", id: 1001)
|
||||
XCTAssertEqual(api.passIds, [1001])
|
||||
|
||||
try await viewModel.finishQueue(api: api, scenicId: 88, userId: "77", id: 1001)
|
||||
XCTAssertEqual(api.finishIds, [1001])
|
||||
|
||||
try await viewModel.requeue(api: api, scenicId: 88, userId: "77", id: 1001, operatorId: 77)
|
||||
XCTAssertEqual(api.requeueRequests.map(\.recordId), [1001])
|
||||
XCTAssertEqual(api.requeueRequests.map(\.operatorId), [77])
|
||||
XCTAssertEqual(viewModel.selectedListType, .queueing)
|
||||
|
||||
try await viewModel.userMark(
|
||||
api: api,
|
||||
scenicId: 88,
|
||||
userId: "77",
|
||||
request: ScenicQueueUserMarkRequest(uid: 7001, scenicId: 88, markAsFreelancePhotog: 1, queueBanDays: 14, operatorId: 77)
|
||||
)
|
||||
XCTAssertEqual(api.userMarkRequests.first?.uid, 7001)
|
||||
XCTAssertEqual(api.userMarkRequests.first?.queueBanDays, 14)
|
||||
}
|
||||
|
||||
func testSocketSubscriptionAndParse() {
|
||||
XCTAssertEqual(ScenicQueueSocketClient.subscriptionPayloads(scenicSpotId: 901), [
|
||||
#"{"type":306,"params":{"scenic_spot_id":901}}"#,
|
||||
#"{"type":307,"params":{"scenic_spot_id":901}}"#
|
||||
])
|
||||
|
||||
let message = ScenicQueueSocketClient.parseMessage("""
|
||||
{"code":100000,"data":{"action":"scenic_queue_ticket_called","params":{"scenic_spot_id":"901","record_id":"1002","operator_uid":"77","event_id":"evt-1"}}}
|
||||
""")
|
||||
|
||||
XCTAssertEqual(message?.code, 100000)
|
||||
XCTAssertTrue(message?.isScenicQueueEvent == true)
|
||||
XCTAssertEqual(message?.data?.params?.scenicSpotId, 901)
|
||||
XCTAssertEqual(message?.data?.params?.recordId, 1002)
|
||||
XCTAssertEqual(message?.data?.params?.operatorUid, 77)
|
||||
XCTAssertEqual(message?.data?.params?.eventId, "evt-1")
|
||||
}
|
||||
}
|
||||
|
||||
private let sampleSpots = [
|
||||
ScenicSpotItem(id: 901, name: "东门打卡点"),
|
||||
ScenicSpotItem(id: 902, name: "西门打卡点")
|
||||
]
|
||||
|
||||
private func saveSelectedSpot() {
|
||||
ScenicQueueSettingsStore.saveSelectedSpot(id: 901, name: "东门打卡点", userId: "77", scenicId: 88)
|
||||
}
|
||||
|
||||
private func clearQueueDefaults() {
|
||||
let defaults = UserDefaults.standard
|
||||
[
|
||||
ScenicQueueLocalSettings.selectedSpotIdKey,
|
||||
ScenicQueueLocalSettings.selectedSpotNameKey,
|
||||
ScenicQueueSettingsStore.scopedKey(base: ScenicQueueLocalSettings.selectedSpotIdKey, userId: "77", scenicId: 88),
|
||||
ScenicQueueSettingsStore.scopedKey(base: ScenicQueueLocalSettings.selectedSpotNameKey, userId: "77", scenicId: 88),
|
||||
ScenicQueueSettingsStore.scopedSpotKey(base: ScenicQueueLocalSettings.settingsSnapshotKey, userId: "77", scenicId: 88, spotId: 901)
|
||||
].forEach(defaults.removeObject)
|
||||
}
|
||||
|
||||
private func ticket(id: Int64, isCalled: Int = 0) -> ScenicQueueTicket {
|
||||
ScenicQueueTicket(
|
||||
id: id,
|
||||
queueCode: "A\(id)",
|
||||
mobile: "13800000000",
|
||||
status: 0,
|
||||
statusText: "",
|
||||
waitMin: 5,
|
||||
aheadCount: 1,
|
||||
isCalled: isCalled,
|
||||
createdAt: "2026-06-25 10:00:00",
|
||||
calledAt: "",
|
||||
expiredAt: "",
|
||||
finishedAt: "",
|
||||
queueCountToday: 2,
|
||||
uid: 7001
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ScenicQueueMock: ScenicQueueServing {
|
||||
var homeResponses: [ScenicQueueHomeData] = []
|
||||
var statsResponse = ScenicQueueStatsData()
|
||||
var statsError: Error?
|
||||
var settingResponse = ScenicQueueSettingData()
|
||||
var callIds: [Int64] = []
|
||||
var passIds: [Int64] = []
|
||||
var finishIds: [Int64] = []
|
||||
var requeueRequests: [(recordId: Int64, operatorId: Int)] = []
|
||||
var userMarkRequests: [ScenicQueueUserMarkRequest] = []
|
||||
var saveSettingRequests: [ScenicQueueSaveSettingRequest] = []
|
||||
var homeRequests: [(scenicId: Int, scenicSpotId: Int, type: Int, page: Int, pageSize: Int)] = []
|
||||
var statsRequests: [(scenicId: Int, scenicSpotId: Int)] = []
|
||||
var qrcodeURL = "https://example.com/qrcode.png"
|
||||
var logs = ScenicQueueSettingChangeLogData()
|
||||
|
||||
func scenicQueueStats(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueStatsData {
|
||||
statsRequests.append((scenicId, scenicSpotId))
|
||||
if let statsError { throw statsError }
|
||||
return statsResponse
|
||||
}
|
||||
|
||||
func scenicQueueHome(scenicId: Int, scenicSpotId: Int, type: Int, page: Int, pageSize: Int) async throws -> ScenicQueueHomeData {
|
||||
homeRequests.append((scenicId, scenicSpotId, type, page, pageSize))
|
||||
return homeResponses.isEmpty ? ScenicQueueHomeData() : homeResponses.removeFirst()
|
||||
}
|
||||
|
||||
func socketToken() async throws -> SocketTokenResponse { SocketTokenResponse(socketToken: "token") }
|
||||
func scenicQueueCall(id: Int64) async throws -> ScenicQueueCallData {
|
||||
callIds.append(id)
|
||||
return ScenicQueueCallData(id: id, status: 1, statusText: "已叫号")
|
||||
}
|
||||
func scenicQueuePass(id: Int64) async throws -> ScenicQueuePassData {
|
||||
passIds.append(id)
|
||||
return ScenicQueuePassData(id: id)
|
||||
}
|
||||
func scenicQueueFinish(id: Int64) async throws -> ScenicQueueFinishData {
|
||||
finishIds.append(id)
|
||||
return ScenicQueueFinishData(id: id)
|
||||
}
|
||||
func scenicQueueRequeueInsertBefore(recordId: Int64, operatorId: Int) async throws {
|
||||
requeueRequests.append((recordId, operatorId))
|
||||
}
|
||||
func scenicQueueUserMark(_ request: ScenicQueueUserMarkRequest) async throws {
|
||||
userMarkRequests.append(request)
|
||||
}
|
||||
func scenicQueueSetting(scenicId: Int, scenicSpotId: Int?) async throws -> ScenicQueueSettingData { settingResponse }
|
||||
func scenicQueueSaveSetting(_ request: ScenicQueueSaveSettingRequest) async throws {
|
||||
saveSettingRequests.append(request)
|
||||
}
|
||||
func scenicQueueShootQueueQRCode(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueShootQueueQRCodeData {
|
||||
ScenicQueueShootQueueQRCodeData(qrcodeUrl: qrcodeURL)
|
||||
}
|
||||
func scenicQueueSettingChangeLog(scenicId: Int, scenicSpotId: Int?, page: Int, pageSize: Int) async throws -> ScenicQueueSettingChangeLogData {
|
||||
logs
|
||||
}
|
||||
}
|
||||
|
||||
private enum QueueTestError: Error {
|
||||
case sample
|
||||
}
|
||||
@ -0,0 +1,182 @@
|
||||
//
|
||||
// ScenicQueueSettingsViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 排队设置测试,覆盖加载、保存和校验。
|
||||
final class ScenicQueueSettingsViewModelTests: XCTestCase {
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
clearQueueSettingsDefaults()
|
||||
}
|
||||
|
||||
func testLoadSelectsFirstSpotAndAppliesLocalSnapshotWhenServerMissing() async {
|
||||
let snapshot = ScenicQueueSettingsSnapshot(
|
||||
shootMinute: 4,
|
||||
shootSecond: 15,
|
||||
firstAheadCount: 6,
|
||||
firstSms: false,
|
||||
firstPhone: true,
|
||||
secondAheadCount: 2,
|
||||
secondSms: true,
|
||||
secondPhone: false,
|
||||
broadcastIntervalSec: 55,
|
||||
countdownThresholdSec: 25,
|
||||
queueDistanceMeter: 8_880,
|
||||
queueTakeLimit: 11,
|
||||
missCallRequeueOffset: 5,
|
||||
showStartShootingButton: false,
|
||||
autoCallAheadCount: 3,
|
||||
quickCallButtonEnabled: true,
|
||||
prepareCallButtonEnabled: true,
|
||||
businessOpen: false,
|
||||
businessStartTime: "09:30",
|
||||
businessEndTime: "18:45"
|
||||
)
|
||||
ScenicQueueSettingsStore.saveSettingsSnapshot(snapshot, userId: "77", scenicId: 88, spotId: 901)
|
||||
let api = ScenicQueueMock()
|
||||
api.settingResponse = ScenicQueueSettingData(exists: false)
|
||||
let viewModel = ScenicQueueSettingsViewModel()
|
||||
|
||||
await viewModel.load(api: api, scenicId: 88, userId: "77", spots: settingsSpots)
|
||||
|
||||
XCTAssertEqual(viewModel.selectedSpotId, 901)
|
||||
XCTAssertEqual(viewModel.photoEstimateMin, "4")
|
||||
XCTAssertEqual(viewModel.photoEstimateSec, "15")
|
||||
XCTAssertEqual(viewModel.firstAhead, "6")
|
||||
XCTAssertTrue(viewModel.firstPhone)
|
||||
XCTAssertEqual(viewModel.secondAhead, "2")
|
||||
XCTAssertEqual(viewModel.broadcastIntervalSec, "55")
|
||||
XCTAssertEqual(viewModel.countdownThresholdSec, "25")
|
||||
XCTAssertEqual(viewModel.maxQueueRangeKm, "8.88")
|
||||
XCTAssertEqual(viewModel.localQueueLimit, "11")
|
||||
XCTAssertEqual(viewModel.localPassDelay, "5")
|
||||
XCTAssertFalse(viewModel.showStartShootingButton)
|
||||
XCTAssertEqual(viewModel.autoCallAheadCount, "3")
|
||||
XCTAssertTrue(viewModel.quickCallButtonEnabled)
|
||||
XCTAssertTrue(viewModel.prepareCallButtonEnabled)
|
||||
XCTAssertFalse(viewModel.queueOpen)
|
||||
}
|
||||
|
||||
func testSaveNormalizesBodyAndPersistsSnapshot() async throws {
|
||||
let api = ScenicQueueMock()
|
||||
let viewModel = ScenicQueueSettingsViewModel()
|
||||
viewModel.scenicSpots = settingsSpots
|
||||
viewModel.selectedSpotId = 901
|
||||
viewModel.photoEstimateMin = "1"
|
||||
viewModel.photoEstimateSec = "20"
|
||||
viewModel.firstAhead = "5"
|
||||
viewModel.secondAhead = "2"
|
||||
viewModel.broadcastIntervalSec = "45"
|
||||
viewModel.countdownThresholdSec = "20"
|
||||
viewModel.localQueueLimit = "7"
|
||||
viewModel.localPassDelay = "3"
|
||||
viewModel.showStartShootingButton = false
|
||||
viewModel.autoCallAheadCount = "4"
|
||||
viewModel.quickCallButtonEnabled = true
|
||||
viewModel.prepareCallButtonEnabled = true
|
||||
viewModel.queueOpen = false
|
||||
viewModel.customTtsText = " {number} 请到拍摄点 "
|
||||
viewModel.presetVoices = ["预设播报", " ", "第二条"]
|
||||
viewModel.businessStartTime = ScenicQueueSettingsViewModel.businessTime(hour: 11, minute: 0)
|
||||
viewModel.businessEndTime = ScenicQueueSettingsViewModel.businessTime(hour: 21, minute: 30)
|
||||
viewModel.updateMaxQueueRange("12.3")
|
||||
|
||||
try await viewModel.save(api: api, scenicId: 88, userId: "77")
|
||||
|
||||
let request = try XCTUnwrap(api.saveSettingRequests.first)
|
||||
XCTAssertEqual(request.scenicId, 88)
|
||||
XCTAssertEqual(request.scenicSpotId, 901)
|
||||
XCTAssertEqual(request.photoEstimateMin, 1)
|
||||
XCTAssertEqual(request.photoEstimateSec, 20)
|
||||
XCTAssertEqual(request.queueDistanceMeter, 12_300)
|
||||
XCTAssertEqual(request.queueTakeLimit, 7)
|
||||
XCTAssertEqual(request.missCallRequeueOffset, 3)
|
||||
XCTAssertEqual(request.showStartShootButton, 0)
|
||||
XCTAssertEqual(request.autoCallNextCount, 4)
|
||||
XCTAssertEqual(request.businessStartTime, "11:00")
|
||||
XCTAssertEqual(request.businessEndTime, "21:30")
|
||||
XCTAssertEqual(request.voiceBroadcasts.map(\.content), ["预设播报", "第二条"])
|
||||
XCTAssertEqual(request.status, 0)
|
||||
XCTAssertEqual(request.remark, "{number} 请到拍摄点")
|
||||
|
||||
XCTAssertEqual(ScenicQueueSettingsStore.selectedSpotId(userId: "77", scenicId: 88), 901)
|
||||
let snapshot = try XCTUnwrap(ScenicQueueSettingsStore.settingsSnapshot(userId: "77", scenicId: 88, spotId: 901))
|
||||
XCTAssertEqual(snapshot.queueDistanceMeter, 12_300)
|
||||
XCTAssertEqual(snapshot.autoCallAheadCount, 4)
|
||||
XCTAssertTrue(snapshot.quickCallButtonEnabled)
|
||||
}
|
||||
|
||||
func testValidationRejectsInvalidValues() async {
|
||||
let api = ScenicQueueMock()
|
||||
let viewModel = ScenicQueueSettingsViewModel()
|
||||
viewModel.selectedSpotId = 901
|
||||
viewModel.photoEstimateMin = "0"
|
||||
viewModel.photoEstimateSec = "0"
|
||||
|
||||
await XCTAssertThrowsErrorAsync(try await viewModel.save(api: api, scenicId: 88, userId: "77"))
|
||||
|
||||
viewModel.photoEstimateSec = "20"
|
||||
viewModel.firstAhead = "1"
|
||||
viewModel.secondAhead = "2"
|
||||
await XCTAssertThrowsErrorAsync(try await viewModel.save(api: api, scenicId: 88, userId: "77"))
|
||||
|
||||
viewModel.firstAhead = "2"
|
||||
viewModel.secondAhead = "1"
|
||||
viewModel.broadcastIntervalSec = "30"
|
||||
await XCTAssertThrowsErrorAsync(try await viewModel.save(api: api, scenicId: 88, userId: "77"))
|
||||
}
|
||||
|
||||
func testFetchQRCodeRejectsEmptyURL() async {
|
||||
let api = ScenicQueueMock()
|
||||
api.qrcodeURL = ""
|
||||
let viewModel = ScenicQueueSettingsViewModel()
|
||||
viewModel.selectedSpotId = 901
|
||||
|
||||
await XCTAssertThrowsErrorAsync(try await viewModel.fetchQRCode(api: api, scenicId: 88))
|
||||
}
|
||||
}
|
||||
|
||||
private let settingsSpots = [
|
||||
ScenicSpotItem(id: 901, name: "东门打卡点"),
|
||||
ScenicSpotItem(id: 902, name: "西门打卡点")
|
||||
]
|
||||
|
||||
private func clearQueueSettingsDefaults() {
|
||||
let defaults = UserDefaults.standard
|
||||
[
|
||||
ScenicQueueLocalSettings.selectedSpotIdKey,
|
||||
ScenicQueueLocalSettings.selectedSpotNameKey,
|
||||
ScenicQueueLocalSettings.customTtsTextKey,
|
||||
ScenicQueueLocalSettings.photoEstimateSecondsKey,
|
||||
ScenicQueueLocalSettings.broadcastIntervalSecondsKey,
|
||||
ScenicQueueLocalSettings.countdownThresholdSecondsKey,
|
||||
ScenicQueueLocalSettings.showStartShootingButtonKey,
|
||||
ScenicQueueLocalSettings.autoCallAheadCountKey,
|
||||
ScenicQueueLocalSettings.quickCallButtonEnabledKey,
|
||||
ScenicQueueLocalSettings.prepareCallButtonEnabledKey,
|
||||
ScenicQueueSettingsStore.scopedKey(base: ScenicQueueLocalSettings.selectedSpotIdKey, userId: "77", scenicId: 88),
|
||||
ScenicQueueSettingsStore.scopedKey(base: ScenicQueueLocalSettings.selectedSpotNameKey, userId: "77", scenicId: 88),
|
||||
ScenicQueueSettingsStore.scopedSpotKey(base: ScenicQueueLocalSettings.settingsSnapshotKey, userId: "77", scenicId: 88, spotId: 901),
|
||||
ScenicQueueSettingsStore.scopedSpotKey(base: ScenicQueueLocalSettings.customTtsTextKey, userId: "77", scenicId: 88, spotId: 901),
|
||||
ScenicQueueSettingsStore.scopedSpotKey(base: ScenicQueueLocalSettings.presetVoicesKey, userId: "77", scenicId: 88, spotId: 901)
|
||||
].forEach(defaults.removeObject)
|
||||
}
|
||||
|
||||
private func XCTAssertThrowsErrorAsync<T>(
|
||||
_ expression: @autoclosure () async throws -> T,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) async {
|
||||
do {
|
||||
try await expression()
|
||||
XCTFail("Expected error to be thrown", file: file, line: line)
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,212 @@
|
||||
//
|
||||
// ScenicSettlementViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 景区结算测试,覆盖结算申请和审核记录聚合。
|
||||
final class ScenicSettlementViewModelTests: XCTestCase {
|
||||
/// 测试已有景区会从可申请结算景区中排除。
|
||||
func testSettlementLoadExcludesExistingScenics() async {
|
||||
let permissionAPI = ScenicSettlementPermissionMock()
|
||||
permissionAPI.scenicList = ScenicListAllResponse(total: 3, list: [
|
||||
ScenicListItem(id: 1, name: "已有景区"),
|
||||
ScenicListItem(id: 2, name: "新增景区"),
|
||||
ScenicListItem(id: 3, name: "另一个景区")
|
||||
])
|
||||
let viewModel = ScenicSettlementViewModel()
|
||||
|
||||
await viewModel.load(
|
||||
api: permissionAPI,
|
||||
existingScenics: [BusinessScope(id: 1, name: "已有景区", kind: .scenic)]
|
||||
)
|
||||
|
||||
XCTAssertEqual(viewModel.options.map(\.id), [2, 3])
|
||||
}
|
||||
|
||||
/// 测试金额校验覆盖空值、0、负数、超过两位小数和有效金额。
|
||||
func testSettlementAmountValidation() {
|
||||
let viewModel = ScenicSettlementViewModel()
|
||||
|
||||
XCTAssertEqual(viewModel.amountValidation, .empty)
|
||||
|
||||
viewModel.amountText = "0"
|
||||
XCTAssertEqual(viewModel.amountValidation, .notPositive)
|
||||
|
||||
viewModel.amountText = "-1"
|
||||
XCTAssertEqual(viewModel.amountValidation, .invalidFormat)
|
||||
|
||||
viewModel.amountText = "1.234"
|
||||
XCTAssertEqual(viewModel.amountValidation, .invalidFormat)
|
||||
|
||||
viewModel.amountText = "12.30"
|
||||
XCTAssertEqual(viewModel.amountValidation, .valid)
|
||||
}
|
||||
|
||||
/// 测试多景区提交 body 正确且成功后清空表单。
|
||||
func testSettlementSubmitMultipleScenicsAndClearsForm() async {
|
||||
let submitAPI = ScenicSettlementSubmitMock()
|
||||
let viewModel = ScenicSettlementViewModel()
|
||||
viewModel.options = [
|
||||
ScenicSettlementOption(id: 3, name: "三号", selected: false),
|
||||
ScenicSettlementOption(id: 2, name: "二号", selected: false)
|
||||
]
|
||||
viewModel.toggleScenic(id: 3)
|
||||
viewModel.toggleScenic(id: 2)
|
||||
viewModel.amountText = "99"
|
||||
viewModel.remarkText = " 活动结算 "
|
||||
|
||||
let success = await viewModel.submit(api: submitAPI)
|
||||
|
||||
XCTAssertTrue(success)
|
||||
XCTAssertEqual(submitAPI.requests, [
|
||||
ScenicSettlementSubmitRequest(scenicId: 2, applyAmount: "99.00", applyRemark: "活动结算"),
|
||||
ScenicSettlementSubmitRequest(scenicId: 3, applyAmount: "99.00", applyRemark: "活动结算")
|
||||
])
|
||||
XCTAssertTrue(viewModel.selectedScenicIds.isEmpty)
|
||||
XCTAssertEqual(viewModel.amountText, "")
|
||||
XCTAssertEqual(viewModel.remarkText, "")
|
||||
XCTAssertEqual(viewModel.message, "提交成功,等待审核")
|
||||
}
|
||||
|
||||
/// 测试任一提交失败会保留当前表单并透出错误。
|
||||
func testSettlementSubmitFailureKeepsForm() async {
|
||||
let submitAPI = ScenicSettlementSubmitMock()
|
||||
submitAPI.errorAfterRequestCount = 1
|
||||
let viewModel = ScenicSettlementViewModel()
|
||||
viewModel.options = [
|
||||
ScenicSettlementOption(id: 1, name: "一号", selected: false),
|
||||
ScenicSettlementOption(id: 2, name: "二号", selected: false)
|
||||
]
|
||||
viewModel.toggleScenic(id: 1)
|
||||
viewModel.toggleScenic(id: 2)
|
||||
viewModel.amountText = "10"
|
||||
|
||||
let success = await viewModel.submit(api: submitAPI)
|
||||
|
||||
XCTAssertFalse(success)
|
||||
XCTAssertEqual(viewModel.selectedScenicIds, [1, 2])
|
||||
XCTAssertEqual(viewModel.amountText, "10")
|
||||
XCTAssertEqual(viewModel.message, TestError.sample.localizedDescription)
|
||||
}
|
||||
|
||||
/// 测试审核记录两个通道都成功时汇总正确。
|
||||
func testReviewLoadSuccessAggregatesRecords() async {
|
||||
let permissionAPI = ScenicSettlementPermissionMock()
|
||||
permissionAPI.scenicPendings = ScenicApplicationPendingsResponse(items: [
|
||||
ScenicApplicationPendingResponse(id: 1, scenicName: "景区", status: 1)
|
||||
])
|
||||
permissionAPI.roleApplies = [
|
||||
RoleApplyPendingResponse(id: 2, roleId: 1, roleName: "摄影师", status: 2)
|
||||
]
|
||||
let viewModel = ScenicSettlementReviewViewModel()
|
||||
|
||||
await viewModel.load(api: permissionAPI)
|
||||
|
||||
XCTAssertEqual(viewModel.scenicApplications.count, 1)
|
||||
XCTAssertEqual(viewModel.roleApplications.count, 1)
|
||||
XCTAssertEqual(viewModel.pendingCount, 1)
|
||||
XCTAssertFalse(viewModel.loadFailedAll)
|
||||
}
|
||||
|
||||
/// 测试单通道失败时保留另一通道数据。
|
||||
func testReviewSingleChannelFailureKeepsOtherChannel() async {
|
||||
let permissionAPI = ScenicSettlementPermissionMock()
|
||||
permissionAPI.scenicPendingsError = TestError.sample
|
||||
permissionAPI.roleApplies = [
|
||||
RoleApplyPendingResponse(id: 2, roleId: 1, roleName: "摄影师", status: 1)
|
||||
]
|
||||
let viewModel = ScenicSettlementReviewViewModel()
|
||||
|
||||
await viewModel.load(api: permissionAPI)
|
||||
|
||||
XCTAssertTrue(viewModel.scenicLoadFailed)
|
||||
XCTAssertFalse(viewModel.roleLoadFailed)
|
||||
XCTAssertFalse(viewModel.loadFailedAll)
|
||||
XCTAssertEqual(viewModel.roleApplications.count, 1)
|
||||
}
|
||||
|
||||
/// 测试双通道失败时进入整页失败态。
|
||||
func testReviewBothChannelsFailureMarksFullFailure() async {
|
||||
let permissionAPI = ScenicSettlementPermissionMock()
|
||||
permissionAPI.scenicPendingsError = TestError.sample
|
||||
permissionAPI.roleAppliesError = TestError.sample
|
||||
let viewModel = ScenicSettlementReviewViewModel()
|
||||
|
||||
await viewModel.load(api: permissionAPI)
|
||||
|
||||
XCTAssertTrue(viewModel.loadFailedAll)
|
||||
XCTAssertTrue(viewModel.scenicApplications.isEmpty)
|
||||
XCTAssertTrue(viewModel.roleApplications.isEmpty)
|
||||
}
|
||||
|
||||
/// 测试审核状态文案和语义映射。
|
||||
func testReviewStatusMapping() {
|
||||
let viewModel = ScenicSettlementReviewViewModel()
|
||||
|
||||
XCTAssertEqual(viewModel.statusText(1), "待审核")
|
||||
XCTAssertEqual(viewModel.statusText(2), "已通过")
|
||||
XCTAssertEqual(viewModel.statusText(3), "已驳回")
|
||||
XCTAssertEqual(viewModel.statusText(9), "已取消")
|
||||
XCTAssertEqual(viewModel.statusText(99), "未知")
|
||||
XCTAssertEqual(viewModel.statusIcon(2), "checkmark.circle.fill")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class ScenicSettlementPermissionMock: ScenicPermissionServing {
|
||||
var scenicList = ScenicListAllResponse(total: 0, list: [])
|
||||
var scenicListError: Error?
|
||||
var scenicPendings = ScenicApplicationPendingsResponse()
|
||||
var scenicPendingsError: Error?
|
||||
var roleApplies: [RoleApplyPendingResponse] = []
|
||||
var roleAppliesError: Error?
|
||||
|
||||
func scenicListAll() async throws -> ScenicListAllResponse {
|
||||
if let scenicListError { throw scenicListError }
|
||||
return scenicList
|
||||
}
|
||||
|
||||
func areas() async throws -> [ScenicAreaNode] { [] }
|
||||
|
||||
func scenicApplicationPendingAll() async throws -> ScenicApplicationPendingsResponse {
|
||||
if let scenicPendingsError { throw scenicPendingsError }
|
||||
return scenicPendings
|
||||
}
|
||||
|
||||
func scenicSubmit(_ request: ScenicApplicationSubmitRequest) async throws {}
|
||||
func scenicApplicationUploadPlaceholder(_ items: [ScenicApplicationUploadPlaceholder]) async throws {}
|
||||
|
||||
func roleApplyAll() async throws -> [RoleApplyPendingResponse] {
|
||||
if let roleAppliesError { throw roleAppliesError }
|
||||
return roleApplies
|
||||
}
|
||||
|
||||
func roleApplySubmit(roleId: Int, scenicIds: [Int]) async throws {}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class ScenicSettlementSubmitMock: ScenicSettlementServing {
|
||||
var requests: [ScenicSettlementSubmitRequest] = []
|
||||
var errorAfterRequestCount: Int?
|
||||
|
||||
func scenicSettlementSubmit(_ request: ScenicSettlementSubmitRequest) async throws {
|
||||
requests.append(request)
|
||||
if let errorAfterRequestCount, requests.count >= errorAfterRequestCount {
|
||||
throw TestError.sample
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum TestError: LocalizedError {
|
||||
case sample
|
||||
|
||||
var errorDescription: String? {
|
||||
"测试错误"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,125 @@
|
||||
//
|
||||
// WithdrawalAuditViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 提现审核测试,覆盖分页、筛选和失败清理。
|
||||
final class WithdrawalAuditViewModelTests: XCTestCase {
|
||||
/// 测试首次加载、筛选统计和分页加载。
|
||||
func testLoadFilterAndLoadMore() async throws {
|
||||
let api = WithdrawalAuditWalletMock()
|
||||
api.withdrawResponses = [
|
||||
WalletWithdrawListResponse(total: 3, item: [
|
||||
try record(id: 1, status: "审核中"),
|
||||
try record(id: 2, status: "已到账")
|
||||
]),
|
||||
WalletWithdrawListResponse(total: 3, item: [
|
||||
try record(id: 3, status: "待打款")
|
||||
])
|
||||
]
|
||||
let viewModel = WithdrawalAuditViewModel()
|
||||
|
||||
await viewModel.reload(api: api)
|
||||
|
||||
XCTAssertEqual(api.withdrawPages, [1])
|
||||
XCTAssertEqual(viewModel.totalCount, 3)
|
||||
XCTAssertEqual(viewModel.processingCount, 1)
|
||||
XCTAssertEqual(viewModel.completedCount, 1)
|
||||
XCTAssertTrue(viewModel.hasMore)
|
||||
|
||||
viewModel.selectFilter(.processing)
|
||||
XCTAssertEqual(viewModel.filteredRecords.map(\.id), [1])
|
||||
|
||||
await viewModel.loadMore(api: api)
|
||||
|
||||
XCTAssertEqual(api.withdrawPages, [1, 2])
|
||||
XCTAssertEqual(viewModel.records.map(\.id), [1, 2, 3])
|
||||
XCTAssertEqual(viewModel.processingCount, 2)
|
||||
XCTAssertFalse(viewModel.hasMore)
|
||||
}
|
||||
|
||||
/// 测试刷新失败会清空旧记录和分页状态。
|
||||
func testReloadFailureClearsStaleRecordsAndPaging() async throws {
|
||||
let api = WithdrawalAuditWalletMock()
|
||||
api.withdrawResponses = [
|
||||
WalletWithdrawListResponse(total: 2, item: [try record(id: 1, status: "审核中")])
|
||||
]
|
||||
let viewModel = WithdrawalAuditViewModel()
|
||||
|
||||
await viewModel.reload(api: api)
|
||||
XCTAssertEqual(viewModel.records.map(\.id), [1])
|
||||
XCTAssertTrue(viewModel.hasMore)
|
||||
|
||||
api.withdrawError = TestError.sample
|
||||
await viewModel.reload(api: api)
|
||||
|
||||
XCTAssertTrue(viewModel.records.isEmpty)
|
||||
XCTAssertFalse(viewModel.hasMore)
|
||||
XCTAssertTrue(viewModel.loadFailed)
|
||||
XCTAssertEqual(viewModel.loadFailureReason, TestError.sample.localizedDescription)
|
||||
}
|
||||
|
||||
/// 测试空列表和尾页边界不会继续加载。
|
||||
func testEmptyListAndLastPageDoNotLoadMore() async {
|
||||
let api = WithdrawalAuditWalletMock()
|
||||
api.withdrawResponses = [WalletWithdrawListResponse(total: 0, item: [])]
|
||||
let viewModel = WithdrawalAuditViewModel()
|
||||
|
||||
await viewModel.reload(api: api)
|
||||
await viewModel.loadMore(api: api)
|
||||
|
||||
XCTAssertTrue(viewModel.records.isEmpty)
|
||||
XCTAssertEqual(api.withdrawPages, [1])
|
||||
XCTAssertFalse(viewModel.hasMore)
|
||||
}
|
||||
|
||||
private func record(id: Int64, amount: String = "10", status: String) throws -> WalletWithdrawRecord {
|
||||
let data = """
|
||||
{"id":\(id),"amount":"\(amount)","created_at":"2026-06-25 10:00","status_label":"\(status)","expected_at":"2026-06-26","audit_time":"2026-06-25 11:00","completed_at":""}
|
||||
""".data(using: .utf8)!
|
||||
return try JSONDecoder().decode(WalletWithdrawRecord.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class WithdrawalAuditWalletMock: WalletServing {
|
||||
var withdrawResponses: [WalletWithdrawListResponse] = []
|
||||
var withdrawError: Error?
|
||||
private(set) var withdrawPages: [Int] = []
|
||||
|
||||
func walletSummary(type: Int) async throws -> WalletSummaryResponse { WalletSummaryResponse() }
|
||||
func walletEarningDetail(startDate: String, endDate: String, page: Int, pageSize: Int) async throws -> WalletEarningDetailResponse { WalletEarningDetailResponse() }
|
||||
func walletWithdrawList(page: Int, pageSize: Int) async throws -> WalletWithdrawListResponse {
|
||||
withdrawPages.append(page)
|
||||
if let withdrawError { throw withdrawError }
|
||||
return withdrawResponses.isEmpty ? WalletWithdrawListResponse() : withdrawResponses.removeFirst()
|
||||
}
|
||||
func withdrawInfo() async throws -> WithdrawInfoResponse {
|
||||
let data = #"{"amount_withdrawable":"0","min_withdraw_amount":"1","max_single_withdraw_amount":"100","max_daily_withdraw_amount":"100","user_phone":"13800000000","bank_card":{},"withdraw_info":[]}"#.data(using: .utf8)!
|
||||
return try JSONDecoder().decode(WithdrawInfoResponse.self, from: data)
|
||||
}
|
||||
func withdrawSendSms() async throws {}
|
||||
func withdrawApply(amount: String, smsCode: String) async throws {}
|
||||
func bankCardInfo() async throws -> BankCardInfoResponse { BankCardInfoResponse(bankCard: nil) }
|
||||
func bankList() async throws -> BankListResponse { BankListResponse() }
|
||||
func areas() async throws -> [AreaNode] { [] }
|
||||
func bankCardVerifyCode() async throws {}
|
||||
func updateBankInfo(_ request: UpdateBankInfoRequest) async throws {}
|
||||
func pointOverview(staffId: Int) async throws -> PointOverviewResponse { PointOverviewResponse() }
|
||||
func pointWithdrawApply(points: Int, remark: String) async throws {}
|
||||
func pointWithdrawList(status: Int?, page: Int, pageSize: Int) async throws -> PointWithdrawListResponse { PointWithdrawListResponse() }
|
||||
}
|
||||
|
||||
private enum TestError: LocalizedError {
|
||||
case sample
|
||||
|
||||
var errorDescription: String? {
|
||||
"测试错误"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user