// // CameraHistoryImportViewModelTests.swift // suixinkanTests // import XCTest @testable import suixinkan @MainActor /// 相机历史导入 ViewModel 测试。 final class CameraHistoryImportViewModelTests: XCTestCase { /// 测试按日分组与今天/昨天标题。 func testLoadPhotosGroupsByDay() async throws { let calendar = Calendar.current let today = calendar.startOfDay(for: Date()) let yesterday = calendar.date(byAdding: .day, value: -1, to: today)! let driver = MockHistoryImportCameraDriver() driver.objects = [ makeObject(id: "today-1", filename: "today1.JPG", day: today, hour: 10), makeObject(id: "today-2", filename: "today2.JPG", day: today, hour: 9), makeObject(id: "yesterday-1", filename: "yesterday1.JPG", day: yesterday, hour: 15), ] let sink = MockHistoryImportPhotoSink() let viewModel = CameraHistoryImportViewModel( albumId: 1, accountKey: "test", driver: driver, photoSink: sink, onImported: {} ) viewModel.loadPhotos() try await waitUntilLoaded(viewModel) guard case .loaded = viewModel.state else { return XCTFail("expected loaded state, got \(viewModel.state)") } XCTAssertEqual(viewModel.sections.count, 2) XCTAssertEqual(viewModel.sections[0].title, "今天") XCTAssertEqual(viewModel.sections[0].photos.map(\.id), ["today-1", "today-2"]) XCTAssertEqual(viewModel.sections[1].title, "昨天") } /// 测试已导入照片不可选。 func testAlreadyImportedPhotoIsNotSelectable() async throws { let driver = MockHistoryImportCameraDriver() driver.objects = [makeObject(id: "dup|1024", filename: "dup.JPG", day: Date(), hour: 12)] let sink = MockHistoryImportPhotoSink() sink.existingIDs = ["dup|1024"] let viewModel = CameraHistoryImportViewModel( albumId: 1, accountKey: "test", driver: driver, photoSink: sink, onImported: {} ) viewModel.loadPhotos() try await waitUntilLoaded(viewModel) XCTAssertTrue(viewModel.isPhotoAlreadyInAlbum(id: "dup|1024")) viewModel.togglePhoto(id: "dup|1024") XCTAssertTrue(viewModel.selectedPhotoIDs.isEmpty) } /// 测试分组全选与取消全选。 func testToggleSectionSelectsImportablePhotosOnly() async throws { let day = Calendar.current.startOfDay(for: Date()) let driver = MockHistoryImportCameraDriver() driver.objects = [ makeObject(id: "a|100", filename: "a.JPG", day: day, hour: 10), makeObject(id: "b|200", filename: "b.JPG", day: day, hour: 9), ] let sink = MockHistoryImportPhotoSink() sink.existingIDs = ["b|200"] let viewModel = CameraHistoryImportViewModel( albumId: 1, accountKey: "test", driver: driver, photoSink: sink, onImported: {} ) viewModel.loadPhotos() try await waitUntilLoaded(viewModel) let section = try XCTUnwrap(viewModel.sections.first) viewModel.toggleSection(section) XCTAssertEqual(viewModel.selectedPhotoIDs, ["a|100"]) viewModel.toggleSection(section) XCTAssertTrue(viewModel.selectedPhotoIDs.isEmpty) } /// 测试 Sony catalog 为空时显示 MTP 帮助入口。 func testSonyEmptyCatalogShowsMTPHelp() async throws { let driver = MockHistoryImportCameraDriver() driver.platform = .sony driver.objects = [] let viewModel = CameraHistoryImportViewModel( albumId: 1, accountKey: "test", driver: driver, photoSink: MockHistoryImportPhotoSink(), onImported: {} ) viewModel.loadPhotos() try await waitForFailedState(viewModel) XCTAssertTrue(viewModel.shouldShowSonyMTPHelp) } private func makeObject(id: String, filename: String, day: Date, hour: Int) -> CameraObject { let capturedAt = Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day return CameraObject(id: id, filename: filename, fileSize: 1024, capturedAt: capturedAt) } private func waitUntilLoaded( _ viewModel: CameraHistoryImportViewModel, timeout: TimeInterval = 2 ) async throws { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { if case .loaded = viewModel.state { return } if case .failed = viewModel.state { XCTFail("unexpected failed: \(viewModel.state)") return } try await Task.sleep(nanoseconds: 50_000_000) } XCTFail("timed out waiting for loaded state") } private func waitForFailedState( _ viewModel: CameraHistoryImportViewModel, timeout: TimeInterval = 2 ) async throws { let deadline = Date().addingTimeInterval(timeout) while Date() < deadline { if case .failed = viewModel.state { return } try await Task.sleep(nanoseconds: 50_000_000) } XCTFail("timed out waiting for failed state") } } @MainActor private final class MockHistoryImportCameraDriver: NSObject, CameraDriver { var platform: CameraPlatform = .canon var deviceInfo = CameraDeviceInfo( name: "Mock Camera", manufacturer: nil, model: nil, serialNumber: nil, usbVendorID: nil, usbProductID: nil ) var objects: [CameraObject] = [] func connect() async throws {} func disconnect() {} func listObjects() async throws -> [CameraObject] { objects } func requestThumbnailData(for object: CameraObject, maxPixelSize: Int) async -> Data? { nil } func downloadObject(_ object: CameraObject, to directory: URL) async throws -> URL { let url = directory.appendingPathComponent(object.filename) try Data(repeating: 0x01, count: 16).write(to: url) return url } } @MainActor private final class MockHistoryImportPhotoSink: OTGPhotoSink { var existingIDs: Set = [] private(set) var savedCount = 0 func saveLiveShot( data: Data, filename: String, capturedAt: Date, cameraObjectID: String ) async throws -> SavedOTGPhoto { savedCount += 1 return SavedOTGPhoto( assetID: cameraObjectID, relativeLocalPath: "mock/\(filename)", filename: filename, capturedAt: capturedAt ) } func saveDownloadedFile( at url: URL, filename: String, capturedAt: Date, cameraObjectID: String ) async throws -> SavedOTGPhoto { savedCount += 1 return SavedOTGPhoto( assetID: cameraObjectID, relativeLocalPath: "mock/\(filename)", filename: filename, capturedAt: capturedAt ) } func existingCameraObjectIDs() async -> Set { existingIDs } }