// // TravelAlbumViewModelTests.swift // suixinkanTests // import XCTest @testable import suixinkan @MainActor /// 旅拍相册 ViewModel 测试。 final class TravelAlbumEntryViewModelTests: XCTestCase { func testLoadAlbumsUpdatesListAndTotal() async { let api = TravelAlbumMockAPI() api.listResponse = TravelAlbumListResponse(total: 1, list: [TravelAlbum(id: 1, name: "A")]) let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 }) await viewModel.loadAlbums(api: api) XCTAssertEqual(viewModel.albumTotal, 1) XCTAssertEqual(viewModel.albums.first?.name, "A") } func testPreShootRequiresSinglePrice() async { let api = TravelAlbumMockAPI() let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 }) var message = "" viewModel.onShowMessage = { message = $0 } await viewModel.confirmCreate(mode: .preShoot, freeCount: "", singlePrice: "", packagePrice: "", order: nil, api: api) XCTAssertEqual(message, "请输入单张照片价格") XCTAssertEqual(api.createRequests.count, 0) } func testPreOrderRequiresOrder() async { let api = TravelAlbumMockAPI() let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 }) var message = "" viewModel.onShowMessage = { message = $0 } await viewModel.confirmCreate(mode: .preOrder, freeCount: "", singlePrice: "", packagePrice: "", order: nil, api: api) XCTAssertEqual(message, "请选择绑定订单") } func testCreateSuccessRefreshesAlbums() async { let api = TravelAlbumMockAPI() api.createResponse = TravelAlbumCreateResponse(id: 9) api.listResponse = TravelAlbumListResponse(total: 1, list: [TravelAlbum(id: 9)]) let date = Calendar(identifier: .gregorian).date(from: DateComponents(year: 2026, month: 7, day: 7))! let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 }, dateProvider: { date }) await viewModel.confirmCreate(mode: .preShoot, freeCount: "2", singlePrice: "12.5", packagePrice: "88", order: nil, api: api) XCTAssertEqual(api.createRequests.first?.name, "2026-07-07-001") XCTAssertEqual(api.createRequests.first?.type, 1) XCTAssertEqual(viewModel.albums.first?.id, 9) XCTAssertFalse(viewModel.isCreateSheetVisible) } func testOrderAlreadyBoundReloadsOrders() async { let api = TravelAlbumMockAPI() api.createError = APIError.serverCode(100001, "订单已关联") api.availableOrdersResponse = [TravelAlbumAvailableOrder(projectName: "新订单", orderNumber: "NO1")] let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 }) let order = TravelAlbumAvailableOrder(projectName: "项目", orderNumber: "NO0") await viewModel.confirmCreate(mode: .preOrder, freeCount: "", singlePrice: "", packagePrice: "", order: order, api: api) XCTAssertEqual(api.availableOrdersCallCount, 1) XCTAssertEqual(viewModel.availableOrders.first?.projectName, "新订单") } } @MainActor /// 旅拍相册详情 ViewModel 测试。 final class TravelAlbumDetailViewModelTests: XCTestCase { func testRefreshLoadsInfoAndMaterials() async { let api = TravelAlbumMockAPI() api.infoResponse = TravelAlbum(id: 2, name: "详情") api.materialListResponses = [ TravelAlbumListResponse(total: 2, list: []), TravelAlbumListResponse(total: 2, list: [TravelAlbumMaterial(id: 1), TravelAlbumMaterial(id: 2)]), ] let viewModel = TravelAlbumDetailViewModel(albumId: 2) await viewModel.refreshAll(api: api) XCTAssertEqual(viewModel.album?.name, "详情") XCTAssertEqual(viewModel.allPhotoCount, 2) XCTAssertEqual(viewModel.materials.count, 2) } func testTabAndSortTriggerMaterialRequests() async { let api = TravelAlbumMockAPI() api.materialListResponses = [ TravelAlbumListResponse(total: 0, list: []), TravelAlbumListResponse(total: 0, list: []), ] let viewModel = TravelAlbumDetailViewModel(albumId: 2) await viewModel.selectTab(.purchased, api: api) await viewModel.setSortOption(.fileNameDesc, api: api) XCTAssertEqual(api.materialRequests.first?.isPurchased, 1) XCTAssertEqual(api.materialRequests.last?.orderBy, 4) } func testOnlyUnpurchasedMaterialCanBeSelected() { let viewModel = TravelAlbumDetailViewModel(albumId: 2) viewModel.toggleSelectionMode() viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, status: 2)) XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty) viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, status: 1)) XCTAssertEqual(viewModel.selectedMaterialIds, [1]) } func testDeleteAlbumCallsCallback() async { let api = TravelAlbumMockAPI() let viewModel = TravelAlbumDetailViewModel(albumId: 5) var deletedId = 0 viewModel.onDeletedAlbum = { deletedId = $0 } await viewModel.deleteAlbum(api: api) XCTAssertEqual(api.deletedAlbumIds, [5]) XCTAssertEqual(deletedId, 5) } } /// 有线传输 ViewModel 测试。 final class WiredCameraTransferViewModelTests: XCTestCase { func testDefaultDisconnectedStateDoesNotDependOnBusinessServices() { let viewModel = WiredCameraTransferViewModel(albumId: 1, albumTitle: "相册", headerPhone: "13800138000") var message = "" viewModel.onShowMessage = { message = $0 } XCTAssertEqual(viewModel.cameraStatusText, "未连接 USB") XCTAssertEqual(viewModel.tabCounts, [0, 0, 0]) viewModel.showUIOnlyMessage() XCTAssertEqual(message, "仅同步 UI,暂未接入 OTG 传输") } } @MainActor /// 旅拍相册 API 测试替身。 final class TravelAlbumMockAPI: TravelAlbumServing { struct MaterialRequest: Equatable { let userEquityTravelId: Int let page: Int let pageSize: Int let orderBy: Int let isPurchased: Int? } var availableOrdersResponse: [TravelAlbumAvailableOrder] = [] var listResponse = TravelAlbumListResponse() var createResponse = TravelAlbumCreateResponse(id: 0) var infoResponse = TravelAlbum() var materialListResponses: [TravelAlbumListResponse] = [] var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "") var createError: Error? private(set) var availableOrdersCallCount = 0 private(set) var createRequests: [TravelAlbumCreateRequest] = [] private(set) var materialRequests: [MaterialRequest] = [] private(set) var deletedAlbumIds: [Int] = [] private(set) var deletedMaterialIds: [Int] = [] func availableOrders() async throws -> [TravelAlbumAvailableOrder] { availableOrdersCallCount += 1 return availableOrdersResponse } func create(_ request: TravelAlbumCreateRequest) async throws -> TravelAlbumCreateResponse { createRequests.append(request) if let createError { throw createError } return createResponse } func list(page: Int, pageSize: Int) async throws -> TravelAlbumListResponse { listResponse } func info(id: Int) async throws -> TravelAlbum { infoResponse } func materialList( userEquityTravelId: Int, page: Int, pageSize: Int, orderBy: Int, isPurchased: Int? ) async throws -> TravelAlbumListResponse { materialRequests.append( MaterialRequest( userEquityTravelId: userEquityTravelId, page: page, pageSize: pageSize, orderBy: orderBy, isPurchased: isPurchased ) ) if materialListResponses.isEmpty { return TravelAlbumListResponse() } return materialListResponses.removeFirst() } func deleteAlbum(id: Int) async throws { deletedAlbumIds.append(id) } func deleteMaterial(id: Int) async throws { deletedMaterialIds.append(id) } func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse { mpCodeResponse } }