同步已迁移的 iOS 模块
This commit is contained in:
4
suixinkanTests/Fixtures/empty_success.json
Normal file
4
suixinkanTests/Fixtures/empty_success.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success"
|
||||
}
|
||||
83
suixinkanTests/Fixtures/order_v2_success.json
Normal file
83
suixinkanTests/Fixtures/order_v2_success.json
Normal file
@ -0,0 +1,83 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"total": "2",
|
||||
"list": [
|
||||
{
|
||||
"photog_uid": "42",
|
||||
"order_number": "ORDV2001",
|
||||
"remark": "需要精修",
|
||||
"scenic_area_id": "88",
|
||||
"pay_time": "2026-05-23 04:40:00",
|
||||
"complete_time": "",
|
||||
"order_status": "20",
|
||||
"order_status_name": "已支付",
|
||||
"user_id": "7001",
|
||||
"project_id": "5101",
|
||||
"phone": "13800000000",
|
||||
"order_amount": "299.50",
|
||||
"actual_pay_amount": "299.50",
|
||||
"actual_refund_amount": "0",
|
||||
"refund_amount": "0",
|
||||
"pay_type_name": "微信支付",
|
||||
"pay_type": "1",
|
||||
"project_name": "亲子跟拍",
|
||||
"order_type": "19",
|
||||
"order_type_label": "多点旅拍",
|
||||
"deposit_pay_time": "",
|
||||
"created_at": "2026-05-23 04:39:00",
|
||||
"photo_travel": {
|
||||
"order_photo_num": "9",
|
||||
"order_video_num": "1",
|
||||
"can_cancel_order": "1",
|
||||
"need_check_in": "0",
|
||||
"check_in_time": "",
|
||||
"can_gift_retouch": "1",
|
||||
"retouch_gift_time": "2026-05-24 04:40:00",
|
||||
"retouch_gift_photo_num": "2",
|
||||
"retouch_gift_video_num": "0"
|
||||
},
|
||||
"multi_travel": {
|
||||
"material_list": [
|
||||
{
|
||||
"file_name": "spot-901.jpg",
|
||||
"file_url": "https://cdn.example.com/order/spot-901.jpg",
|
||||
"file_type": "1",
|
||||
"file_size": "204800",
|
||||
"cover_url": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"isNeedEdit": "1",
|
||||
"is_refined": "1"
|
||||
},
|
||||
{
|
||||
"photog_uid": 43,
|
||||
"order_number": "ORDV2002",
|
||||
"remark": "",
|
||||
"scenic_area_id": 88,
|
||||
"pay_time": "2026-05-23 04:42:00",
|
||||
"complete_time": "2026-05-23 05:00:00",
|
||||
"order_status": 30,
|
||||
"order_status_name": "已完成",
|
||||
"user_id": 7002,
|
||||
"project_id": 5102,
|
||||
"phone": "13900000000",
|
||||
"order_amount": "99.00",
|
||||
"actual_pay_amount": "99.00",
|
||||
"actual_refund_amount": "0",
|
||||
"refund_amount": "0",
|
||||
"pay_type_name": "余额支付",
|
||||
"pay_type": 3,
|
||||
"project_name": "押金体验",
|
||||
"order_type": 4,
|
||||
"order_type_label": "押金",
|
||||
"deposit_pay_time": "2026-05-23 04:41:00",
|
||||
"created_at": "2026-05-23 04:41:00",
|
||||
"isNeedEdit": false,
|
||||
"is_refined": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
26
suixinkanTests/Fixtures/real_name_info_success.json
Normal file
26
suixinkanTests/Fixtures/real_name_info_success.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"real_name_info": {
|
||||
"real_name": "测试摄影师",
|
||||
"id_card_no": "320100********0012",
|
||||
"verified": "1",
|
||||
"start_date": "2026-01-01",
|
||||
"end_date": "2046-01-01",
|
||||
"is_long_valid": "0",
|
||||
"front_url": "https://cdn.example.com/front.jpg",
|
||||
"back_url": "https://cdn.example.com/back.jpg",
|
||||
"created_at": "2026-05-23 04:00:00",
|
||||
"audit_status": "2",
|
||||
"audit_status_text": "审核通过",
|
||||
"audit_at": "2026-05-23 04:05:00",
|
||||
"auditor_id": "7",
|
||||
"auditor": {
|
||||
"id": 7,
|
||||
"name": "审核员"
|
||||
},
|
||||
"reject_reason": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
4
suixinkanTests/Fixtures/server_code_error.json
Normal file
4
suixinkanTests/Fixtures/server_code_error.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"code": 200001,
|
||||
"msg": "登录已过期"
|
||||
}
|
||||
16
suixinkanTests/Fixtures/statistics_daily_page1_total3.json
Normal file
16
suixinkanTests/Fixtures/statistics_daily_page1_total3.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"total": 3,
|
||||
"data": [
|
||||
{
|
||||
"date": "2026-05-23",
|
||||
"order_count": "5",
|
||||
"order_price": "1299.50",
|
||||
"refund": "0",
|
||||
"received": "1299.50"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
23
suixinkanTests/Fixtures/statistics_daily_page2_total3.json
Normal file
23
suixinkanTests/Fixtures/statistics_daily_page2_total3.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"total": 3,
|
||||
"data": [
|
||||
{
|
||||
"date": "2026-05-22",
|
||||
"order_count": 3,
|
||||
"order_price": "650.00",
|
||||
"refund": "50.00",
|
||||
"received": 600
|
||||
},
|
||||
{
|
||||
"date": "2026-05-21",
|
||||
"order_count": 2,
|
||||
"order_price": "420.00",
|
||||
"refund": "0",
|
||||
"received": "420.00"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
11
suixinkanTests/Fixtures/statistics_summary_success.json
Normal file
11
suixinkanTests/Fixtures/statistics_summary_success.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"order_amount_sum": "¥12,345.67",
|
||||
"order_count": "18",
|
||||
"order_price_avg": "685.87",
|
||||
"received_amount_sum": "12000.00",
|
||||
"refund_amount_sum": "345.67"
|
||||
}
|
||||
}
|
||||
13
suixinkanTests/Fixtures/user_info_success.json
Normal file
13
suixinkanTests/Fixtures/user_info_success.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"avatar": "https://example.com/avatar.jpg",
|
||||
"real_name": "测试摄影师",
|
||||
"phone": "13800000000",
|
||||
"nickname": "跟拍小苏",
|
||||
"role_name": "摄影师",
|
||||
"status": 1,
|
||||
"status_name": "已认证"
|
||||
}
|
||||
}
|
||||
67
suixinkanTests/Fixtures/v9_login_multi_success.json
Normal file
67
suixinkanTests/Fixtures/v9_login_multi_success.json
Normal file
@ -0,0 +1,67 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"token": "person-temp-token",
|
||||
"scenic_users": [
|
||||
{
|
||||
"account_type": "scenic_user",
|
||||
"id": 101,
|
||||
"user_id": 101,
|
||||
"scenic_user_id": 101,
|
||||
"ss_user_id": 101,
|
||||
"username": "scenic_admin",
|
||||
"real_name": "张三",
|
||||
"nickname": "张三",
|
||||
"phone": "13800138000",
|
||||
"scenic_id": 10,
|
||||
"scenic_name": "示例景区",
|
||||
"status": 1,
|
||||
"status_label": "正常",
|
||||
"app_role_id": 3,
|
||||
"app_role_code": "scenic_admin",
|
||||
"app_role_name": "景区管理员",
|
||||
"app_role": {
|
||||
"id": 3,
|
||||
"legacy_role_id": 53,
|
||||
"code": "scenic_admin",
|
||||
"name": "景区管理员",
|
||||
"notes": "",
|
||||
"status": 1
|
||||
},
|
||||
"is_current": false
|
||||
}
|
||||
],
|
||||
"store_users": [
|
||||
{
|
||||
"account_type": "store_user",
|
||||
"id": 201,
|
||||
"user_id": 201,
|
||||
"store_user_id": 201,
|
||||
"username": "store_admin",
|
||||
"user_name": "store_admin",
|
||||
"real_name": "张三",
|
||||
"phone": "13800138000",
|
||||
"avatar": "https://example.com/avatar.png",
|
||||
"scenic_id": 10,
|
||||
"scenic_name": "示例景区",
|
||||
"store_id": 20,
|
||||
"store_name": "示例门店",
|
||||
"status": 1,
|
||||
"status_label": "正常",
|
||||
"app_role_id": 1,
|
||||
"app_role_code": "store_admin",
|
||||
"app_role_name": "店铺管理员",
|
||||
"app_role": {
|
||||
"id": 1,
|
||||
"legacy_role_id": 46,
|
||||
"code": "store_admin",
|
||||
"name": "店铺管理员",
|
||||
"notes": "",
|
||||
"status": 1
|
||||
},
|
||||
"is_current": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
39
suixinkanTests/Fixtures/v9_set_store_user_success.json
Normal file
39
suixinkanTests/Fixtures/v9_set_store_user_success.json
Normal file
@ -0,0 +1,39 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"token": "business-token-store",
|
||||
"scenic_users": [],
|
||||
"store_users": [
|
||||
{
|
||||
"account_type": "store_user",
|
||||
"id": 201,
|
||||
"user_id": 201,
|
||||
"store_user_id": 201,
|
||||
"username": "store_admin",
|
||||
"user_name": "store_admin",
|
||||
"real_name": "张三",
|
||||
"phone": "13800138000",
|
||||
"avatar": "https://example.com/avatar.png",
|
||||
"scenic_id": 10,
|
||||
"scenic_name": "示例景区",
|
||||
"store_id": 20,
|
||||
"store_name": "示例门店",
|
||||
"status": 1,
|
||||
"status_label": "正常",
|
||||
"app_role_id": 1,
|
||||
"app_role_code": "store_admin",
|
||||
"app_role_name": "店铺管理员",
|
||||
"app_role": {
|
||||
"id": 1,
|
||||
"legacy_role_id": 46,
|
||||
"code": "store_admin",
|
||||
"name": "店铺管理员",
|
||||
"notes": "",
|
||||
"status": 1
|
||||
},
|
||||
"is_current": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
19
suixinkanTests/Fixtures/writeoff_list_success.json
Normal file
19
suixinkanTests/Fixtures/writeoff_list_success.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"total": 1,
|
||||
"list": [
|
||||
{
|
||||
"order_number": "VERIFY001",
|
||||
"order_verification_status": "待核销",
|
||||
"pay_time": "2026-05-23 03:03",
|
||||
"project_name": "核销套餐",
|
||||
"user_phone": "13800000000",
|
||||
"order_amount": "99.00",
|
||||
"order_status_name": "已支付",
|
||||
"order_verification_time": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -41,9 +41,9 @@ final class HomeMenuRouterTests: XCTestCase {
|
||||
|
||||
/// 测试 Tab 路由仍集中在 HomeMenuRouter。
|
||||
func testTabRoutesStayCentralized() {
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_orders", title: ""), .tab(.orders))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "/scenic-order-manage", title: ""), .tab(.orders))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "verification_order", title: ""), .tab(.orders))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_orders", title: ""), .orders(.storeOrders))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "/scenic-order-manage", title: ""), .orders(.storeOrders))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "verification_order", title: ""), .orders(.verificationOrders))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_stats", title: ""), .tab(.statistics))
|
||||
}
|
||||
|
||||
|
||||
@ -15,4 +15,14 @@ final class NavigationRouterTests: XCTestCase {
|
||||
XCTAssertTrue(AppRoute.placeholder(title: "详情").hidesTabBarWhenPushed)
|
||||
XCTAssertTrue(AppRoute.home(.moreFunctions).hidesTabBarWhenPushed)
|
||||
}
|
||||
|
||||
/// 测试切换订单 Tab 时会保存订单内部子入口。
|
||||
func testSelectOrdersUpdatesTabAndEntry() {
|
||||
let router = AppRouter()
|
||||
|
||||
router.selectOrders(entry: .verificationOrders)
|
||||
|
||||
XCTAssertEqual(router.selectedTab, .orders)
|
||||
XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders)
|
||||
}
|
||||
}
|
||||
|
||||
306
suixinkanTests/OrdersViewModelTests.swift
Normal file
306
suixinkanTests/OrdersViewModelTests.swift
Normal file
@ -0,0 +1,306 @@
|
||||
//
|
||||
// OrdersViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 订单 ViewModel 测试,覆盖列表分页、筛选条件和手动核销状态。
|
||||
final class OrdersViewModelTests: XCTestCase {
|
||||
/// 测试无景区时清空订单管理旧数据且不请求接口。
|
||||
func testStoreOrdersClearStaleDataWhenScenicIsMissing() async throws {
|
||||
let api = MockOrderService()
|
||||
api.storeResponses = [try TestFixture.payload(ListPayload<OrderEntity>.self, named: "order_v2_success")]
|
||||
let viewModel = OrdersViewModel()
|
||||
|
||||
try await viewModel.reloadStoreOrders(api: api, scenicId: 88, roleId: nil)
|
||||
XCTAssertFalse(viewModel.storeOrders.isEmpty)
|
||||
|
||||
try await viewModel.reloadStoreOrders(api: api, scenicId: nil, roleId: nil)
|
||||
|
||||
XCTAssertTrue(viewModel.storeOrders.isEmpty)
|
||||
XCTAssertEqual(viewModel.storeTotal, 0)
|
||||
XCTAssertEqual(viewModel.storePage, 1)
|
||||
XCTAssertFalse(viewModel.storeHasMore)
|
||||
XCTAssertEqual(api.orderListCalls.count, 1)
|
||||
}
|
||||
|
||||
/// 测试订单管理最后一页不会继续请求。
|
||||
func testStoreOrdersDoNotRequestMoreAfterLastPage() async throws {
|
||||
let api = MockOrderService()
|
||||
api.storeResponses = [try TestFixture.payload(ListPayload<OrderEntity>.self, named: "order_v2_success")]
|
||||
let viewModel = OrdersViewModel()
|
||||
|
||||
try await viewModel.reloadStoreOrders(api: api, scenicId: 88, roleId: nil)
|
||||
try await viewModel.loadMoreStoreOrders(api: api, scenicId: 88, roleId: nil)
|
||||
|
||||
XCTAssertEqual(viewModel.storeOrders.count, 2)
|
||||
XCTAssertEqual(viewModel.storeTotal, 2)
|
||||
XCTAssertEqual(viewModel.storePage, 1)
|
||||
XCTAssertFalse(viewModel.storeHasMore)
|
||||
XCTAssertEqual(api.orderListCalls.count, 1)
|
||||
}
|
||||
|
||||
/// 测试订单管理筛选参数会传入服务层并重置到第一页。
|
||||
func testStoreOrderFilterUsesExpectedQueries() async throws {
|
||||
let api = MockOrderService()
|
||||
api.storeResponses = [
|
||||
try TestFixture.payload(ListPayload<OrderEntity>.self, named: "order_v2_success"),
|
||||
try TestFixture.payload(ListPayload<OrderEntity>.self, named: "order_v2_success")
|
||||
]
|
||||
let viewModel = OrdersViewModel()
|
||||
viewModel.selectedStatus = -1
|
||||
viewModel.searchPhone = " 13800000000 "
|
||||
viewModel.filterStartDate = Self.date("2026-05-01")
|
||||
viewModel.filterEndDate = Self.date("2026-05-31")
|
||||
|
||||
try await viewModel.reloadStoreOrders(api: api, scenicId: 88, roleId: nil)
|
||||
|
||||
viewModel.selectedStatus = 30
|
||||
viewModel.searchPhone = " "
|
||||
viewModel.filterStartDate = nil
|
||||
viewModel.filterEndDate = nil
|
||||
try await viewModel.reloadStoreOrders(api: api, scenicId: 88, roleId: 53)
|
||||
|
||||
XCTAssertEqual(api.orderListCalls.count, 2)
|
||||
XCTAssertEqual(api.orderListCalls[0].page, 1)
|
||||
XCTAssertEqual(api.orderListCalls[0].isRefined, 1)
|
||||
XCTAssertEqual(api.orderListCalls[0].userPhone, "13800000000")
|
||||
XCTAssertNil(api.orderListCalls[0].orderStatus)
|
||||
XCTAssertEqual(api.orderListCalls[0].startTime, "2026-05-01")
|
||||
XCTAssertEqual(api.orderListCalls[0].endTime, "2026-05-31")
|
||||
XCTAssertFalse(api.orderListCalls[0].isScenicAdmin)
|
||||
XCTAssertEqual(api.orderListCalls[1].orderStatus, 30)
|
||||
XCTAssertNil(api.orderListCalls[1].isRefined)
|
||||
XCTAssertNil(api.orderListCalls[1].userPhone)
|
||||
XCTAssertTrue(api.orderListCalls[1].isScenicAdmin)
|
||||
XCTAssertEqual(viewModel.storePage, 1)
|
||||
}
|
||||
|
||||
/// 测试无景区时清空核销订单旧数据且不请求接口。
|
||||
func testWriteOffOrdersClearStaleDataWhenScenicIsMissing() async throws {
|
||||
let api = MockOrderService()
|
||||
api.writeOffResponses = [try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success")]
|
||||
let viewModel = OrdersViewModel()
|
||||
|
||||
try await viewModel.reloadWriteOffOrders(api: api, scenicId: 88)
|
||||
XCTAssertFalse(viewModel.writeOffOrders.isEmpty)
|
||||
|
||||
try await viewModel.reloadWriteOffOrders(api: api, scenicId: nil)
|
||||
|
||||
XCTAssertTrue(viewModel.writeOffOrders.isEmpty)
|
||||
XCTAssertEqual(viewModel.writeOffTotal, 0)
|
||||
XCTAssertEqual(viewModel.writeOffPage, 1)
|
||||
XCTAssertFalse(viewModel.writeOffHasMore)
|
||||
XCTAssertEqual(api.writeOffListCalls.count, 1)
|
||||
}
|
||||
|
||||
/// 测试核销订单最后一页不会继续请求。
|
||||
func testWriteOffOrdersDoNotRequestMoreAfterLastPage() async throws {
|
||||
let api = MockOrderService()
|
||||
api.writeOffResponses = [try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success")]
|
||||
let viewModel = OrdersViewModel()
|
||||
|
||||
try await viewModel.reloadWriteOffOrders(api: api, scenicId: 88)
|
||||
try await viewModel.loadMoreWriteOffOrders(api: api, scenicId: 88)
|
||||
|
||||
XCTAssertEqual(viewModel.writeOffOrders.count, 1)
|
||||
XCTAssertEqual(viewModel.writeOffTotal, 1)
|
||||
XCTAssertEqual(viewModel.writeOffPage, 1)
|
||||
XCTAssertFalse(viewModel.writeOffHasMore)
|
||||
XCTAssertEqual(api.writeOffListCalls.count, 1)
|
||||
}
|
||||
|
||||
/// 测试核销成功后刷新列表并重置分页状态。
|
||||
func testVerifyRefreshesWriteOffOrdersAndResetsPagingState() async throws {
|
||||
let api = MockOrderService()
|
||||
api.writeOffResponses = [try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success")]
|
||||
let viewModel = OrdersViewModel()
|
||||
|
||||
try await viewModel.verify(api: api, scenicId: 88, orderNumber: "VERIFY001")
|
||||
|
||||
XCTAssertEqual(api.verifiedOrderNumbers, ["VERIFY001"])
|
||||
XCTAssertEqual(api.writeOffListCalls.count, 1)
|
||||
XCTAssertEqual(api.writeOffListCalls[0].scenicId, 88)
|
||||
XCTAssertEqual(api.writeOffListCalls[0].page, 1)
|
||||
XCTAssertEqual(viewModel.writeOffOrders.count, 1)
|
||||
XCTAssertEqual(viewModel.writeOffPage, 1)
|
||||
XCTAssertFalse(viewModel.isVerifying)
|
||||
XCTAssertNil(viewModel.currentVerifyingOrderNumber)
|
||||
}
|
||||
|
||||
/// 测试重复核销提交会被忽略。
|
||||
func testVerifyIgnoresDuplicateSubmitWhileAlreadyVerifying() async throws {
|
||||
let api = MockOrderService()
|
||||
let viewModel = OrdersViewModel()
|
||||
viewModel.markVerifyingForTests(orderNumber: "VERIFY001")
|
||||
|
||||
try await viewModel.verify(api: api, scenicId: 88, orderNumber: "VERIFY002")
|
||||
|
||||
XCTAssertTrue(api.verifiedOrderNumbers.isEmpty)
|
||||
XCTAssertTrue(api.writeOffListCalls.isEmpty)
|
||||
XCTAssertTrue(viewModel.isVerifying)
|
||||
XCTAssertEqual(viewModel.currentVerifyingOrderNumber, "VERIFY001")
|
||||
}
|
||||
|
||||
/// 测试核销失败时清理提交状态且不刷新列表。
|
||||
func testVerifyFailureClearsSubmittingStateWithoutRefreshingList() async throws {
|
||||
let api = MockOrderService()
|
||||
api.verifyError = APIError.httpStatus(500, "server error")
|
||||
let viewModel = OrdersViewModel()
|
||||
|
||||
do {
|
||||
try await viewModel.verify(api: api, scenicId: 88, orderNumber: "VERIFY001")
|
||||
XCTFail("Expected verify to fail")
|
||||
} catch APIError.httpStatus(let statusCode, _) {
|
||||
XCTAssertEqual(statusCode, 500)
|
||||
} catch {
|
||||
XCTFail("Unexpected error: \(error)")
|
||||
}
|
||||
|
||||
XCTAssertEqual(api.verifiedOrderNumbers, ["VERIFY001"])
|
||||
XCTAssertTrue(api.writeOffListCalls.isEmpty)
|
||||
XCTAssertFalse(viewModel.isVerifying)
|
||||
XCTAssertNil(viewModel.currentVerifyingOrderNumber)
|
||||
}
|
||||
|
||||
/// 测试订单 API 会按角色选择正确接口路径。
|
||||
func testOrdersAPIUsesExpectedEndpointForRole() async throws {
|
||||
let session = RecordingURLSession(data: try TestFixture.data(named: "order_v2_success"))
|
||||
let api = OrdersAPI(client: APIClient(session: session))
|
||||
|
||||
_ = try await api.orderList(
|
||||
scenicId: 88,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
orderStatus: 30,
|
||||
userPhone: "13800000000",
|
||||
startTime: "2026-05-01",
|
||||
endTime: "2026-05-31",
|
||||
isRefined: nil,
|
||||
isScenicAdmin: true
|
||||
)
|
||||
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.url?.path, "/api/app/scenic-admin/order/list")
|
||||
let query = queryItems(from: request)
|
||||
XCTAssertEqual(query["scenic_id"], "88")
|
||||
XCTAssertEqual(query["order_status"], "30")
|
||||
XCTAssertEqual(query["user_phone"], "13800000000")
|
||||
XCTAssertEqual(query["start_time"], "2026-05-01")
|
||||
XCTAssertEqual(query["end_time"], "2026-05-31")
|
||||
}
|
||||
|
||||
private static func date(_ text: String) -> Date {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.date(from: text)!
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 订单服务测试替身,记录请求参数并返回预设响应。
|
||||
private final class MockOrderService: OrderServing {
|
||||
struct OrderListCall: Equatable {
|
||||
let scenicId: Int
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
let orderStatus: Int?
|
||||
let userPhone: String?
|
||||
let startTime: String?
|
||||
let endTime: String?
|
||||
let isRefined: Int?
|
||||
let isScenicAdmin: Bool
|
||||
}
|
||||
|
||||
struct WriteOffListCall: Equatable {
|
||||
let scenicId: Int
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
}
|
||||
|
||||
var storeResponses: [ListPayload<OrderEntity>] = []
|
||||
var writeOffResponses: [ListPayload<WriteOffOrderItem>] = []
|
||||
var verifyError: Error?
|
||||
private(set) var orderListCalls: [OrderListCall] = []
|
||||
private(set) var writeOffListCalls: [WriteOffListCall] = []
|
||||
private(set) var verifiedOrderNumbers: [String] = []
|
||||
|
||||
/// 返回测试订单管理列表。
|
||||
func orderList(
|
||||
scenicId: Int,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
orderStatus: Int?,
|
||||
userPhone: String?,
|
||||
startTime: String?,
|
||||
endTime: String?,
|
||||
isRefined: Int?,
|
||||
isScenicAdmin: Bool
|
||||
) async throws -> ListPayload<OrderEntity> {
|
||||
orderListCalls.append(
|
||||
OrderListCall(
|
||||
scenicId: scenicId,
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
orderStatus: orderStatus,
|
||||
userPhone: userPhone,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
isRefined: isRefined,
|
||||
isScenicAdmin: isScenicAdmin
|
||||
)
|
||||
)
|
||||
return storeResponses.isEmpty ? ListPayload(total: 0, list: []) : storeResponses.removeFirst()
|
||||
}
|
||||
|
||||
/// 返回测试核销订单列表。
|
||||
func writeOffList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload<WriteOffOrderItem> {
|
||||
writeOffListCalls.append(WriteOffListCall(scenicId: scenicId, page: page, pageSize: pageSize))
|
||||
return writeOffResponses.isEmpty ? ListPayload(total: 0, list: []) : writeOffResponses.removeFirst()
|
||||
}
|
||||
|
||||
/// 记录测试核销订单号。
|
||||
func writeOff(orderNumber: String) async throws {
|
||||
verifiedOrderNumbers.append(orderNumber)
|
||||
if let verifyError {
|
||||
throw verifyError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// URLSession 测试替身,记录请求并返回固定响应。
|
||||
private final class RecordingURLSession: URLSessionProtocol {
|
||||
let data: Data
|
||||
var statusCode: Int
|
||||
private(set) var requests: [URLRequest] = []
|
||||
|
||||
init(data: Data, statusCode: Int = 200) {
|
||||
self.data = data
|
||||
self.statusCode = statusCode
|
||||
}
|
||||
|
||||
/// 返回固定数据和 HTTP 响应。
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
requests.append(request)
|
||||
return (
|
||||
data,
|
||||
HTTPURLResponse(url: request.url!, statusCode: statusCode, httpVersion: nil, headerFields: nil)!
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func queryItems(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) }
|
||||
})
|
||||
}
|
||||
201
suixinkanTests/ProfileSecondaryPagesTests.swift
Normal file
201
suixinkanTests/ProfileSecondaryPagesTests.swift
Normal file
@ -0,0 +1,201 @@
|
||||
//
|
||||
// ProfileSecondaryPagesTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 个人中心二级页面测试,覆盖设置、账号切换和实名认证基础逻辑。
|
||||
final class ProfileSecondaryPagesTests: XCTestCase {
|
||||
/// 测试设置页版本号展示规则。
|
||||
func testSettingsVersionTextUsesBuildNumberWhenNeeded() {
|
||||
XCTAssertEqual(
|
||||
SettingsDisplayPolicy.versionText(infoDictionary: [
|
||||
"CFBundleShortVersionString": "1.2",
|
||||
"CFBundleVersion": "45"
|
||||
]),
|
||||
"1.2.45"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
SettingsDisplayPolicy.versionText(infoDictionary: [
|
||||
"CFBundleShortVersionString": "1.2.3",
|
||||
"CFBundleVersion": "45"
|
||||
]),
|
||||
"1.2.3"
|
||||
)
|
||||
XCTAssertEqual(SettingsDisplayPolicy.versionText(infoDictionary: nil), "1.0.0")
|
||||
}
|
||||
|
||||
/// 测试协议页面映射到预期 H5 路径。
|
||||
func testAgreementPagesUseExpectedPaths() {
|
||||
let pages: [(AgreementPage, String, String)] = [
|
||||
(.about, "关于我们", "/h5/app/about-us"),
|
||||
(.userAgreement, "用户协议", "/h5/app/user-agreement"),
|
||||
(.privacyPolicy, "隐私政策", "/h5/app/privacy-policy"),
|
||||
(.walletUserNotice, "钱包用户须知", "/h5/app/wallet-user-notice"),
|
||||
(.walletPrivacy, "钱包隐私政策", "/h5/app/wallet-privacy")
|
||||
]
|
||||
|
||||
for (page, title, path) in pages {
|
||||
XCTAssertEqual(page.title, title)
|
||||
XCTAssertEqual(page.id, title)
|
||||
XCTAssertEqual(page.url.path, path)
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试账号切换列表接口使用旧工程一致的 v9 accounts 路径。
|
||||
func testSwitchableAccountsUsesExpectedEndpoint() async throws {
|
||||
let session = ProfileSecondaryURLSession(responses: [
|
||||
try TestFixture.data(named: "v9_login_multi_success")
|
||||
])
|
||||
let api = ProfileAPI(client: APIClient(session: session))
|
||||
|
||||
let response = try await api.switchableAccounts()
|
||||
|
||||
XCTAssertEqual(response.accounts.count, 2)
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.httpMethod, "GET")
|
||||
XCTAssertEqual(request.url?.path, "/api/app/v9/accounts")
|
||||
}
|
||||
|
||||
/// 测试账号切换 ViewModel 能加载账号并默认选中第一项。
|
||||
func testAccountSwitchViewModelLoadsAccounts() async throws {
|
||||
let session = ProfileSecondaryURLSession(responses: [
|
||||
try TestFixture.data(named: "v9_login_multi_success")
|
||||
])
|
||||
let api = ProfileAPI(client: APIClient(session: session))
|
||||
let viewModel = AccountSwitchViewModel()
|
||||
|
||||
try await viewModel.load(api: api)
|
||||
|
||||
XCTAssertEqual(viewModel.accounts.map(\.businessUserId), [101, 201])
|
||||
XCTAssertEqual(viewModel.selectedAccount?.businessUserId, 101)
|
||||
XCTAssertFalse(viewModel.loading)
|
||||
}
|
||||
|
||||
/// 测试账号切换提交使用 set-user 接口和账号对应请求体。
|
||||
func testAccountSwitchViewModelSubmitsSetUser() async throws {
|
||||
let session = ProfileSecondaryURLSession(responses: [
|
||||
try TestFixture.data(named: "v9_set_store_user_success")
|
||||
])
|
||||
let api = AuthAPI(client: APIClient(session: session))
|
||||
let account = AccountSwitchAccount(
|
||||
accountType: V9StoreUser.accountTypeValue,
|
||||
businessUserId: 201,
|
||||
title: "示例门店",
|
||||
subtitle: "示例景区",
|
||||
phone: "13800138000",
|
||||
avatar: "",
|
||||
scenicName: "示例景区",
|
||||
storeId: 20,
|
||||
storeName: "示例门店",
|
||||
scenicId: 10,
|
||||
isCurrent: false
|
||||
)
|
||||
let viewModel = AccountSwitchViewModel()
|
||||
|
||||
let response = try await viewModel.switchAccount(account, api: api)
|
||||
|
||||
XCTAssertFalse(response.token.isEmpty)
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.httpMethod, "POST")
|
||||
XCTAssertEqual(request.url?.path, "/api/app/v9/set-user")
|
||||
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(request.httpBody)) as? [String: Any])
|
||||
XCTAssertEqual(json["store_user_id"] as? Int, 201)
|
||||
XCTAssertNil(json["ss_user_id"] as? Int)
|
||||
}
|
||||
|
||||
/// 测试实名认证信息解码兼容字符串数字字段。
|
||||
func testRealNameInfoDecodesLossyFields() async throws {
|
||||
let session = ProfileSecondaryURLSession(responses: [
|
||||
try TestFixture.data(named: "real_name_info_success")
|
||||
])
|
||||
let api = ProfileAPI(client: APIClient(session: session))
|
||||
|
||||
let response = try await api.realNameInfo()
|
||||
let info = try XCTUnwrap(response.realNameInfo)
|
||||
|
||||
XCTAssertEqual(info.realName, "测试摄影师")
|
||||
XCTAssertEqual(info.idCardNo, "320100********0012")
|
||||
XCTAssertTrue(info.verified)
|
||||
XCTAssertFalse(info.isLongValid)
|
||||
XCTAssertEqual(info.auditStatus, 2)
|
||||
XCTAssertEqual(info.auditorId, 7)
|
||||
XCTAssertEqual(info.auditor?.name, "审核员")
|
||||
XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/real-name/info")
|
||||
}
|
||||
|
||||
/// 测试实名认证短信和提交接口路径及请求体。
|
||||
func testRealNameSmsAndSubmitUseExpectedEndpoints() async throws {
|
||||
let session = ProfileSecondaryURLSession(responses: [
|
||||
try TestFixture.data(named: "empty_success"),
|
||||
try TestFixture.data(named: "empty_success")
|
||||
])
|
||||
let api = ProfileAPI(client: APIClient(session: session))
|
||||
|
||||
try await api.realNameSmsVerifyCode()
|
||||
try await api.realNameSubmit(
|
||||
RealNameAuthRequest(
|
||||
realName: "测试摄影师",
|
||||
idCardNo: "11010519491231002X",
|
||||
smsVerifyCode: "123456",
|
||||
startDate: "2026-01-01",
|
||||
endDate: "2046-01-01",
|
||||
isLongValid: 0,
|
||||
frontUrl: "https://cdn.example.com/front.jpg",
|
||||
backUrl: "https://cdn.example.com/back.jpg"
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||||
"/api/yf-handset-app/photog/real-name/sms-verify-code",
|
||||
"/api/yf-handset-app/photog/real-name/submit"
|
||||
])
|
||||
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(session.requests.last?.httpBody)) as? [String: Any])
|
||||
XCTAssertEqual(json["real_name"] as? String, "测试摄影师")
|
||||
XCTAssertEqual(json["id_card_no"] as? String, "11010519491231002X")
|
||||
XCTAssertEqual(json["sms_verify_code"] as? String, "123456")
|
||||
XCTAssertEqual(json["is_long_valid"] as? Int, 0)
|
||||
}
|
||||
|
||||
/// 测试身份证号校验和格式归一化。
|
||||
func testRealNameIDCardValidation() {
|
||||
XCTAssertTrue(RealNameAuthViewModel.isValidMainlandIDCardNumber("11010519491231002x"))
|
||||
XCTAssertEqual(RealNameAuthViewModel.normalizedIDCardNumber(" 11010519491231002x "), "11010519491231002X")
|
||||
XCTAssertFalse(RealNameAuthViewModel.isValidMainlandIDCardNumber("110105194912310021"))
|
||||
XCTAssertFalse(RealNameAuthViewModel.isValidMainlandIDCardNumber("123"))
|
||||
}
|
||||
|
||||
/// 测试首页系统设置 URI 已接入真实设置页。
|
||||
func testHomeSystemSettingsRouteResolvesToSettingsPage() {
|
||||
XCTAssertEqual(
|
||||
HomeMenuRouter.resolve(uri: "system_settings", title: "设置中心"),
|
||||
.destination(.settings)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 个人中心测试用 URLSession 替身,按顺序返回预设响应。
|
||||
private final class ProfileSecondaryURLSession: 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() : responses.removeFirst()
|
||||
return (
|
||||
data,
|
||||
HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||
)
|
||||
}
|
||||
}
|
||||
238
suixinkanTests/StatisticsViewModelTests.swift
Normal file
238
suixinkanTests/StatisticsViewModelTests.swift
Normal file
@ -0,0 +1,238 @@
|
||||
//
|
||||
// StatisticsViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 数据统计 ViewModel 测试,覆盖汇总、日数据、分页和角色接口选择。
|
||||
final class StatisticsViewModelTests: XCTestCase {
|
||||
/// 测试首屏会加载汇总和第一页日数据。
|
||||
func testReloadLoadsSummaryAndDailyItems() async throws {
|
||||
let api = MockStatisticsService()
|
||||
api.summaryResponses = [try TestFixture.payload(StatisticsSummaryResponse.self, named: "statistics_summary_success")]
|
||||
api.dailyResponses = [try TestFixture.payload(DataListPayload<StatisticsDailyItem>.self, named: "statistics_daily_page1_total3")]
|
||||
let viewModel = StatisticsViewModel()
|
||||
|
||||
try await viewModel.reload(api: api, scenicId: 88, roleId: nil)
|
||||
|
||||
XCTAssertEqual(viewModel.summary.orderCount, 18)
|
||||
XCTAssertEqual(viewModel.dailyItems.map(\.date), ["2026-05-23"])
|
||||
XCTAssertEqual(viewModel.totalDailyCount, 3)
|
||||
XCTAssertTrue(viewModel.hasMore)
|
||||
XCTAssertFalse(viewModel.loading)
|
||||
XCTAssertNotNil(viewModel.lastRefreshAt)
|
||||
XCTAssertEqual(api.summaryCalls.map(\.range), ["1"])
|
||||
XCTAssertEqual(api.dailyCalls.map(\.page), [1])
|
||||
}
|
||||
|
||||
/// 测试切换时间段会重置分页并使用对应 range。
|
||||
func testSelectPeriodResetsPaginationAndUsesExpectedRange() async throws {
|
||||
let api = MockStatisticsService()
|
||||
api.summaryResponses = [
|
||||
try TestFixture.payload(StatisticsSummaryResponse.self, named: "statistics_summary_success"),
|
||||
try TestFixture.payload(StatisticsSummaryResponse.self, named: "statistics_summary_success")
|
||||
]
|
||||
api.dailyResponses = [
|
||||
try TestFixture.payload(DataListPayload<StatisticsDailyItem>.self, named: "statistics_daily_page1_total3"),
|
||||
try TestFixture.payload(DataListPayload<StatisticsDailyItem>.self, named: "statistics_daily_page2_total3"),
|
||||
try TestFixture.payload(DataListPayload<StatisticsDailyItem>.self, named: "statistics_daily_page1_total3")
|
||||
]
|
||||
let viewModel = StatisticsViewModel()
|
||||
|
||||
try await viewModel.reload(api: api, scenicId: 88, roleId: nil)
|
||||
try await viewModel.loadMore(api: api, scenicId: 88, roleId: nil)
|
||||
XCTAssertEqual(viewModel.dailyItems.count, 3)
|
||||
|
||||
try await viewModel.selectPeriod(.sevenDays, api: api, scenicId: 88, roleId: nil)
|
||||
|
||||
XCTAssertEqual(viewModel.selectedPeriod, .sevenDays)
|
||||
XCTAssertEqual(viewModel.dailyItems.map(\.date), ["2026-05-23"])
|
||||
XCTAssertEqual(viewModel.totalDailyCount, 3)
|
||||
XCTAssertTrue(viewModel.hasMore)
|
||||
XCTAssertEqual(api.summaryCalls.last?.range, "3")
|
||||
XCTAssertEqual(api.dailyCalls.last?.page, 1)
|
||||
XCTAssertFalse(api.dailyCalls.last?.startTime.isEmpty ?? true)
|
||||
XCTAssertFalse(api.dailyCalls.last?.endTime.isEmpty ?? true)
|
||||
}
|
||||
|
||||
/// 测试加载更多会追加日数据。
|
||||
func testLoadMoreAppendsDailyItems() async throws {
|
||||
let api = MockStatisticsService()
|
||||
api.summaryResponses = [try TestFixture.payload(StatisticsSummaryResponse.self, named: "statistics_summary_success")]
|
||||
api.dailyResponses = [
|
||||
try TestFixture.payload(DataListPayload<StatisticsDailyItem>.self, named: "statistics_daily_page1_total3"),
|
||||
try TestFixture.payload(DataListPayload<StatisticsDailyItem>.self, named: "statistics_daily_page2_total3")
|
||||
]
|
||||
let viewModel = StatisticsViewModel()
|
||||
|
||||
try await viewModel.reload(api: api, scenicId: 88, roleId: nil)
|
||||
try await viewModel.loadMore(api: api, scenicId: 88, roleId: nil)
|
||||
|
||||
XCTAssertEqual(viewModel.dailyItems.map(\.date), ["2026-05-23", "2026-05-22", "2026-05-21"])
|
||||
XCTAssertFalse(viewModel.hasMore)
|
||||
XCTAssertFalse(viewModel.loadingMore)
|
||||
XCTAssertEqual(api.dailyCalls.map(\.page), [1, 2])
|
||||
}
|
||||
|
||||
/// 测试加载更多失败时保留已有数据和下一页能力。
|
||||
func testLoadMoreFailureKeepsCurrentItems() async throws {
|
||||
let api = MockStatisticsService()
|
||||
api.summaryResponses = [try TestFixture.payload(StatisticsSummaryResponse.self, named: "statistics_summary_success")]
|
||||
api.dailyResponses = [try TestFixture.payload(DataListPayload<StatisticsDailyItem>.self, named: "statistics_daily_page1_total3")]
|
||||
let viewModel = StatisticsViewModel()
|
||||
|
||||
try await viewModel.reload(api: api, scenicId: 88, roleId: nil)
|
||||
api.nextDailyError = APIError.httpStatus(500, "server error")
|
||||
|
||||
do {
|
||||
try await viewModel.loadMore(api: api, scenicId: 88, roleId: nil)
|
||||
XCTFail("Expected load more to fail")
|
||||
} catch APIError.httpStatus(let statusCode, _) {
|
||||
XCTAssertEqual(statusCode, 500)
|
||||
} catch {
|
||||
XCTFail("Unexpected error: \(error)")
|
||||
}
|
||||
|
||||
XCTAssertEqual(viewModel.dailyItems.map(\.date), ["2026-05-23"])
|
||||
XCTAssertTrue(viewModel.hasMore)
|
||||
XCTAssertFalse(viewModel.loadingMore)
|
||||
}
|
||||
|
||||
/// 测试无景区时清空旧统计状态。
|
||||
func testReloadWithoutScenicClearsStaleStatisticsState() async throws {
|
||||
let api = MockStatisticsService()
|
||||
api.summaryResponses = [try TestFixture.payload(StatisticsSummaryResponse.self, named: "statistics_summary_success")]
|
||||
api.dailyResponses = [try TestFixture.payload(DataListPayload<StatisticsDailyItem>.self, named: "statistics_daily_page1_total3")]
|
||||
let viewModel = StatisticsViewModel()
|
||||
|
||||
try await viewModel.reload(api: api, scenicId: 88, roleId: nil)
|
||||
XCTAssertEqual(viewModel.summary.orderCount, 18)
|
||||
|
||||
try await viewModel.reload(api: api, scenicId: nil, roleId: nil)
|
||||
|
||||
XCTAssertEqual(api.summaryCalls.count, 1)
|
||||
XCTAssertEqual(viewModel.summary.orderCount, 0)
|
||||
XCTAssertTrue(viewModel.dailyItems.isEmpty)
|
||||
XCTAssertEqual(viewModel.totalDailyCount, 0)
|
||||
XCTAssertFalse(viewModel.hasMore)
|
||||
XCTAssertNil(viewModel.lastRefreshAt)
|
||||
}
|
||||
|
||||
/// 测试景区管理员角色使用管理员统计接口。
|
||||
func testStatisticsAPIUsesAdminEndpointForScenicAdminRole() async throws {
|
||||
let session = SequencedURLSession(responses: [
|
||||
try TestFixture.data(named: "statistics_summary_success"),
|
||||
try TestFixture.data(named: "statistics_daily_page1_total3")
|
||||
])
|
||||
let api = StatisticsAPI(client: APIClient(session: session))
|
||||
|
||||
_ = try await api.summary(scenicId: 88, range: "1", isScenicAdmin: true)
|
||||
_ = try await api.dailyList(
|
||||
scenicId: 88,
|
||||
startTime: "2026-05-01",
|
||||
endTime: "2026-05-31",
|
||||
page: 2,
|
||||
pageSize: 15,
|
||||
isScenicAdmin: true
|
||||
)
|
||||
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/app/scenic-admin/analyse", "/api/app/scenic-admin/analyse/daily"])
|
||||
let dailyQuery = queryItems(from: try XCTUnwrap(session.requests.last))
|
||||
XCTAssertEqual(dailyQuery["scenic_id"], "88")
|
||||
XCTAssertEqual(dailyQuery["page"], "2")
|
||||
XCTAssertEqual(dailyQuery["start_time"], "2026-05-01")
|
||||
XCTAssertEqual(dailyQuery["end_time"], "2026-05-31")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 数据统计服务测试替身,记录请求参数并返回预设响应。
|
||||
private final class MockStatisticsService: StatisticsServing {
|
||||
struct SummaryCall: Equatable {
|
||||
let scenicId: Int
|
||||
let range: String
|
||||
let isScenicAdmin: Bool
|
||||
}
|
||||
|
||||
struct DailyCall: Equatable {
|
||||
let scenicId: Int
|
||||
let startTime: String
|
||||
let endTime: String
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
let isScenicAdmin: Bool
|
||||
}
|
||||
|
||||
var summaryResponses: [StatisticsSummaryResponse] = []
|
||||
var dailyResponses: [DataListPayload<StatisticsDailyItem>] = []
|
||||
var nextDailyError: Error?
|
||||
private(set) var summaryCalls: [SummaryCall] = []
|
||||
private(set) var dailyCalls: [DailyCall] = []
|
||||
|
||||
/// 返回测试汇总数据。
|
||||
func summary(scenicId: Int, range: String, isScenicAdmin: Bool) async throws -> StatisticsSummaryResponse {
|
||||
summaryCalls.append(SummaryCall(scenicId: scenicId, range: range, isScenicAdmin: isScenicAdmin))
|
||||
return summaryResponses.isEmpty ? StatisticsSummaryResponse() : summaryResponses.removeFirst()
|
||||
}
|
||||
|
||||
/// 返回测试每日明细数据。
|
||||
func dailyList(
|
||||
scenicId: Int,
|
||||
startTime: String,
|
||||
endTime: String,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
isScenicAdmin: Bool
|
||||
) async throws -> DataListPayload<StatisticsDailyItem> {
|
||||
dailyCalls.append(
|
||||
DailyCall(
|
||||
scenicId: scenicId,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
isScenicAdmin: isScenicAdmin
|
||||
)
|
||||
)
|
||||
if let nextDailyError {
|
||||
self.nextDailyError = nil
|
||||
throw nextDailyError
|
||||
}
|
||||
return dailyResponses.isEmpty ? DataListPayload(total: 0, data: []) : dailyResponses.removeFirst()
|
||||
}
|
||||
}
|
||||
|
||||
/// URLSession 顺序响应测试替身。
|
||||
private final class SequencedURLSession: 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() : responses.removeFirst()
|
||||
return (
|
||||
data,
|
||||
HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func queryItems(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) }
|
||||
})
|
||||
}
|
||||
35
suixinkanTests/TestFixture.swift
Normal file
35
suixinkanTests/TestFixture.swift
Normal file
@ -0,0 +1,35 @@
|
||||
//
|
||||
// TestFixture.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@testable import suixinkan
|
||||
|
||||
/// 测试 Fixture 读取工具,用于复用旧工程迁移过来的 JSON 响应。
|
||||
enum TestFixture {
|
||||
/// 读取指定名称的 JSON fixture。
|
||||
static func data(named name: String, filePath: String = #filePath) throws -> Data {
|
||||
let url = URL(fileURLWithPath: filePath)
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Fixtures")
|
||||
.appendingPathComponent("\(name).json")
|
||||
return try Data(contentsOf: url)
|
||||
}
|
||||
|
||||
/// 读取并解包后端统一 Envelope。
|
||||
static func payload<T: Decodable>(_ type: T.Type, named name: String) throws -> T {
|
||||
let envelope = try JSONDecoder().decode(APIEnvelope<T>.self, from: data(named: name))
|
||||
guard let payload = envelope.data else {
|
||||
throw FixtureError.emptyPayload
|
||||
}
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试 Fixture 错误实体。
|
||||
enum FixtureError: Error {
|
||||
case emptyPayload
|
||||
}
|
||||
Reference in New Issue
Block a user