// // OrderDetailViewModelTests.swift // suixinkanTests // // Created by Codex on 2026/6/22. // import XCTest @testable import suixinkan @MainActor /// 订单详情 ViewModel 测试,覆盖详情加载、缺失上下文和错误兜底。 final class OrderDetailViewModelTests: XCTestCase { /// 测试有门店 ID 时请求详情接口并展示服务端字段。 func testLoadRequestsDetailWhenStoreIdExists() async throws { let item = try firstOrder() let api = DetailMockOrderService() api.detailResponses = [try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success")] let viewModel = OrderDetailViewModel(item: item) await viewModel.load(api: api, fallbackStoreId: 66) XCTAssertEqual(api.detailCalls.count, 1) XCTAssertEqual(api.detailCalls[0].storeId, 66) XCTAssertEqual(api.detailCalls[0].orderNumber, "ORDV2001") XCTAssertEqual(viewModel.display.projectName, "亲子跟拍") XCTAssertEqual(viewModel.projectInfo?.settleSpotNum, 3) XCTAssertEqual(viewModel.shootingList.first?.scenicSpotName, "东门") XCTAssertNil(viewModel.errorMessage) } /// 测试缺少门店 ID 时不请求接口并保留列表摘要。 func testLoadWithoutStoreIdUsesSummaryOnly() async throws { let item = try firstOrder() let api = DetailMockOrderService() let viewModel = OrderDetailViewModel(item: item) await viewModel.load(api: api, fallbackStoreId: nil) XCTAssertTrue(api.detailCalls.isEmpty) XCTAssertEqual(viewModel.display.orderNumber, "ORDV2001") XCTAssertEqual(viewModel.contextMessage, "缺少门店上下文,当前展示订单摘要。") } /// 测试详情接口失败时保留摘要并暴露错误信息。 func testLoadFailureKeepsSummaryAndExposesError() async throws { let item = try firstOrder() let api = DetailMockOrderService() api.detailError = APIError.httpStatus(500, "server error") let viewModel = OrderDetailViewModel(item: item) await viewModel.load(api: api, fallbackStoreId: 66) XCTAssertEqual(api.detailCalls.count, 1) XCTAssertEqual(viewModel.display.orderNumber, "ORDV2001") XCTAssertEqual(viewModel.contextMessage, "详情加载失败,当前展示订单摘要。") XCTAssertNotNil(viewModel.errorMessage) } /// 测试详情模型兼容后端数字和字符串混合字段。 func testDetailResponseDecodesMixedNumericAndStringFields() throws { let detail = try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success") XCTAssertEqual(detail.orderType, 19) XCTAssertEqual(detail.actualRefundAmount, "0") XCTAssertEqual(detail.userId, 7001) XCTAssertEqual(detail.multiTravel?.projectInfo?.singleSpotVideoNum, 1) XCTAssertEqual(detail.multiTravel?.shootingList.first?.startAvg, 4.8) } /// 返回订单列表 fixture 中的第一条订单。 private func firstOrder() throws -> OrderEntity { let payload = try TestFixture.payload(ListPayload.self, named: "order_v2_success") return try XCTUnwrap(payload.list.first) } } @MainActor /// 订单详情服务测试替身,专门用于详情 ViewModel 测试。 private final class DetailMockOrderService: OrderServing { var detailResponses: [StoreOrderDetailResponse] = [] var detailError: Error? private(set) var detailCalls: [(storeId: Int, orderNumber: String)] = [] /// 订单列表接口在详情测试中不会被调用。 func orderList( scenicId: Int, page: Int, pageSize: Int, orderStatus: Int?, userPhone: String?, startTime: String?, endTime: String?, isRefined: Int?, isScenicAdmin: Bool ) async throws -> ListPayload { XCTFail("orderList should not be called") return ListPayload(total: 0, list: []) } /// 核销列表接口在详情测试中不会被调用。 func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload { XCTFail("writeOffList should not be called") return ListPayload(total: 0, list: []) } /// 核销接口在详情测试中不会被调用。 func writeOff(orderNumber: String) async throws { XCTFail("writeOff should not be called") } /// 返回预置的订单详情响应。 func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse { detailCalls.append((storeId: storeId, orderNumber: orderNumber)) if let detailError { throw detailError } 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 { 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") } }