// // TravelAlbumViewModelTests.swift // suixinkanTests // // Created by Codex on 2026/6/29. // import XCTest @testable import suixinkan @MainActor /// 旅拍相册 ViewModel 测试。 final class TravelAlbumViewModelTests: XCTestCase { /// 测试先拍再买模式缺少单价时创建失败。 func testCreatePreShootRequiresSinglePrice() async { let viewModel = TravelAlbumEntryViewModel() let api = MockTravelAlbumAPI() let success = await viewModel.confirmCreateTask( api: api, mode: TravelAlbumEntryViewModel.modePreShoot, freeCount: "3", singlePrice: "", packagePrice: "99", order: nil, scenicSelected: true ) XCTAssertFalse(success) XCTAssertEqual(viewModel.errorMessage, "请输入单张照片价格") XCTAssertEqual(api.createCallCount, 0) } /// 测试买了再拍模式缺少订单时创建失败。 func testCreatePreOrderRequiresOrder() async { let viewModel = TravelAlbumEntryViewModel() let api = MockTravelAlbumAPI() let success = await viewModel.confirmCreateTask( api: api, mode: TravelAlbumEntryViewModel.modePreOrder, freeCount: "", singlePrice: "", packagePrice: "", order: nil, scenicSelected: true ) XCTAssertFalse(success) XCTAssertEqual(viewModel.errorMessage, "请选择绑定订单") } /// 测试创建成功后刷新列表。 func testCreateSuccessReloadsList() async { let viewModel = TravelAlbumEntryViewModel() let api = MockTravelAlbumAPI() let success = await viewModel.confirmCreateTask( api: api, mode: TravelAlbumEntryViewModel.modePreShoot, freeCount: "3", singlePrice: "9.9", packagePrice: "99", order: nil, scenicSelected: true ) XCTAssertTrue(success) XCTAssertEqual(api.createCallCount, 1) XCTAssertEqual(api.listCallCount, 1) } /// 测试相册名称按日期递增。 func testBuildTodayAlbumNameIncrementsSuffix() { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd" let today = formatter.string(from: Date()) let name = TravelAlbumEntryViewModel.buildTodayAlbumName( existingNames: ["\(today)-001", "\(today)-002", "2026-06-28-001"] ) XCTAssertTrue(name.hasSuffix("003")) } /// 测试切换已购 Tab 时携带 is_purchased 参数并重置选择状态。 func testDetailSelectPurchasedTabUsesPurchasedFilter() async { let viewModel = TravelAlbumDetailViewModel() let api = MockTravelAlbumDetailAPI() await viewModel.refreshAll(api: api, albumID: 8) viewModel.isSelectionMode = true viewModel.selectedMaterialIDs = [1] await viewModel.selectTab(TravelAlbumDetailViewModel.tabPurchased, api: api) XCTAssertEqual(viewModel.selectedTab, TravelAlbumDetailViewModel.tabPurchased) XCTAssertFalse(viewModel.isSelectionMode) XCTAssertTrue(viewModel.selectedMaterialIDs.isEmpty) XCTAssertEqual(api.materialListCalls.last?.isPurchased, 1) } /// 测试排序变更触发重载。 func testDetailSetOrderByReloadsMaterials() async { let viewModel = TravelAlbumDetailViewModel() let api = MockTravelAlbumDetailAPI() await viewModel.refreshAll(api: api, albumID: 8) let initialCount = api.materialListCalls.count await viewModel.setOrderBy(3, api: api) XCTAssertEqual(viewModel.orderBy, 3) XCTAssertGreaterThan(api.materialListCalls.count, initialCount) XCTAssertEqual(api.materialListCalls.last?.orderBy, 3) } /// 测试仅未购买素材可选中。 func testDetailToggleSelectionRejectsPurchasedMaterial() async { let viewModel = TravelAlbumDetailViewModel() viewModel.isSelectionMode = true let purchased = try! decodeMaterial(id: 2, status: 2) viewModel.toggleMaterialSelection(purchased) XCTAssertTrue(viewModel.selectedMaterialIDs.isEmpty) XCTAssertEqual(viewModel.errorMessage, "仅未购买素材可删除") let unpurchased = try! decodeMaterial(id: 3, status: TravelAlbumDetailViewModel.materialStatusUnpurchased) viewModel.toggleMaterialSelection(unpurchased) XCTAssertEqual(viewModel.selectedMaterialIDs, [3]) } /// 测试批量删除按顺序调用 API。 func testDetailConfirmDeleteSelectedMaterialsDeletesInOrder() async { let viewModel = TravelAlbumDetailViewModel() let api = MockTravelAlbumDetailAPI() viewModel.selectedMaterialIDs = [11, 12, 13] let success = await viewModel.confirmDeleteSelectedMaterials(api: api) XCTAssertTrue(success) XCTAssertEqual(Set(api.deletedMaterialIDs), Set([11, 12, 13])) XCTAssertFalse(viewModel.isSelectionMode) XCTAssertTrue(viewModel.selectedMaterialIDs.isEmpty) } /// 测试全部 Tab 下 allPhotoCount 与 total 同步。 func testDetailAllPhotoCountSyncsWithTotalOnAllTab() async { let viewModel = TravelAlbumDetailViewModel() let api = MockTravelAlbumDetailAPI(materialTotal: 42) await viewModel.refreshAll(api: api, albumID: 8) XCTAssertEqual(viewModel.allPhotoCount, 42) } } private func decodeMaterial(id: Int, status: Int) throws -> TravelAlbumMaterial { let json = """ {"code":100000,"msg":"success","data":{"id":"\(id)","user_equity_travel_id":"8","status":"\(status)","order_number":"","user_id":"100","file_name":"DSC.JPG","file_type":"2","file_url":"https://cdn/a.jpg","file_size":"2048","cover_url":"","is_purchased":"0","created_at":"2026","updated_at":"2026"}} """ let data = Data(json.utf8) let envelope = try JSONDecoder().decode(APIEnvelope.self, from: data) return try XCTUnwrap(envelope.data) } @MainActor private final class MockTravelAlbumDetailAPI: TravelAlbumServing { struct MaterialListCall { let orderBy: Int let isPurchased: Int? let page: Int } var materialTotal: Int var materialListCalls: [MaterialListCall] = [] var deletedMaterialIDs: [Int] = [] init(materialTotal: Int = 5) { self.materialTotal = materialTotal } func availableOrders() async throws -> [TravelAlbumAvailableOrder] { [] } func createAlbum(_ request: TravelAlbumCreateRequest) async throws -> TravelAlbumCreateResponse { TravelAlbumCreateResponse(id: 1) } func albumList(page: Int, pageSize: Int) async throws -> ListPayload { ListPayload(total: 0, list: []) } func albumInfo(id: Int) async throws -> TravelAlbumItem { let data = Data( #"{"code":100000,"msg":"success","data":{"id":"8","store_user_id":"1","name":"2026-06-30-001","type":"1","order_number":"","material_num":"3","material_price":"990","material_package_price":"9900","photo_price":"0","cover_url":"","user_id":"100","status":"1","created_at":"2026-06-30","updated_at":"2026-06-30","user":{"id":"200","phone":"13800138000"}}}"#.utf8 ) let envelope = try JSONDecoder().decode(APIEnvelope.self, from: data) return try XCTUnwrap(envelope.data) } func editAlbum(_ request: TravelAlbumEditRequest) async throws {} func deleteAlbum(_ request: TravelAlbumDeleteRequest) async throws {} func materialList( userEquityTravelId: Int, page: Int, pageSize: Int, orderBy: Int, isPurchased: Int? ) async throws -> ListPayload { materialListCalls.append(MaterialListCall(orderBy: orderBy, isPurchased: isPurchased, page: page)) return ListPayload(total: materialTotal, list: []) } func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial { throw APIError.emptyData } func deleteMaterial(_ request: TravelAlbumDeleteMaterialRequest) async throws { deletedMaterialIDs.append(request.id) } func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse { TravelAlbumMpCodeResponse(mpCodeOssURL: "") } } @MainActor private final class MockTravelAlbumAPI: TravelAlbumServing { var createCallCount = 0 var listCallCount = 0 func availableOrders() async throws -> [TravelAlbumAvailableOrder] { [] } func createAlbum(_ request: TravelAlbumCreateRequest) async throws -> TravelAlbumCreateResponse { createCallCount += 1 return TravelAlbumCreateResponse(id: 9) } func albumList(page: Int, pageSize: Int) async throws -> ListPayload { listCallCount += 1 return ListPayload(total: 0, list: []) } func albumInfo(id: Int) async throws -> TravelAlbumItem { throw APIError.emptyData } func editAlbum(_ request: TravelAlbumEditRequest) async throws {} func deleteAlbum(_ request: TravelAlbumDeleteRequest) async throws {} func materialList( userEquityTravelId: Int, page: Int, pageSize: Int, orderBy: Int, isPurchased: Int? ) async throws -> ListPayload { ListPayload(total: 0, list: []) } func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial { throw APIError.emptyData } func deleteMaterial(_ request: TravelAlbumDeleteMaterialRequest) async throws {} func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse { TravelAlbumMpCodeResponse(mpCodeOssURL: "") } }