diff --git a/suixinkan/Features/TravelAlbum/OTG/Models/TravelAlbumOTGTransferModels.swift b/suixinkan/Features/TravelAlbum/OTG/Models/TravelAlbumOTGTransferModels.swift index ab40ca5..ca45afc 100644 --- a/suixinkan/Features/TravelAlbum/OTG/Models/TravelAlbumOTGTransferModels.swift +++ b/suixinkan/Features/TravelAlbum/OTG/Models/TravelAlbumOTGTransferModels.swift @@ -134,17 +134,17 @@ enum TravelAlbumOTGPhotoFormatMatcher { extension TravelAlbumOTGPhotoRecord { /// 转为页面展示项。 - func toPhotoItem() -> TravelAlbumOTGPhotoItem { + func toPhotoItem(storage: TravelAlbumOTGPhotoStore, albumId: Int) -> TravelAlbumOTGPhotoItem { let previewPath = [thumbnailPath, localPath, remoteUrl].first { path in guard !path.isEmpty else { return false } if path.hasPrefix("http") { return true } - return FileManager.default.fileExists(atPath: path) + return storage.fileExists(relativePath: path, albumId: albumId) } ?? "" let previewURL: URL? if previewPath.hasPrefix("http") { previewURL = URL(string: previewPath) } else if !previewPath.isEmpty { - previewURL = URL(fileURLWithPath: previewPath) + previewURL = storage.absoluteURL(for: previewPath, albumId: albumId) } else { previewURL = nil } diff --git a/suixinkan/Features/TravelAlbum/OTG/Storage/TravelAlbumOTGPhotoStore.swift b/suixinkan/Features/TravelAlbum/OTG/Storage/TravelAlbumOTGPhotoStore.swift index 6c49760..e60b3fe 100644 --- a/suixinkan/Features/TravelAlbum/OTG/Storage/TravelAlbumOTGPhotoStore.swift +++ b/suixinkan/Features/TravelAlbum/OTG/Storage/TravelAlbumOTGPhotoStore.swift @@ -71,15 +71,6 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable { self.updatedAt = updatedAt } - /// 是否仍可在页面展示。 - var isDisplayable: Bool { - if status == .uploaded { return true } - if !remoteUrl.isEmpty { return true } - if !localPath.isEmpty, FileManager.default.fileExists(atPath: localPath) { return true } - if !thumbnailPath.isEmpty, FileManager.default.fileExists(atPath: thumbnailPath) { return true } - return false - } - /// 把中断中的传输恢复为待上传,避免重进页面卡在上传中。 func normalizedAfterInterruptedTransfer() -> TravelAlbumOTGPhotoRecord { guard status == .transferring || status == .uploading else { return self } @@ -129,7 +120,7 @@ final class TravelAlbumOTGPhotoRepository: PhotoRepositoryProtocol { let record = TravelAlbumOTGPhotoRecord( id: TravelAlbumOTGPhotoStore.photoId(for: fileURL.lastPathComponent, createdAt: createdAt), fileName: fileURL.lastPathComponent, - localPath: localPath, + localPath: storage.relativePath(for: fileURL, albumId: albumID), capturedAt: TravelAlbumOTGPhotoStore.transferTimeText(createdAt), fileSizeBytes: size, status: .pending, @@ -192,11 +183,10 @@ final class TravelAlbumOTGPhotoStore { let decoded = try? JSONDecoder().decode([TravelAlbumOTGPhotoRecord].self, from: data) else { return [] } - let deletedIds = loadDeletedIds(albumId: albumId) return decoded - .filter { $0.albumId == albumId && $0.userId == context.userId && !deletedIds.contains($0.id) } + .filter { $0.albumId == albumId && $0.userId == context.userId } .map { $0.normalizedAfterInterruptedTransfer() } - .filter(\.isDisplayable) + .filter { isDisplayable($0, albumId: albumId) } .sorted { $0.capturedAt > $1.capturedAt } } @@ -205,6 +195,7 @@ final class TravelAlbumOTGPhotoStore { guard albumId > 0, !context.userId.isEmpty else { return } let normalized = records .filter { $0.albumId == albumId && $0.userId == context.userId } + .map { recordByPersistingRelativePaths($0, albumId: albumId) } .sorted { $0.capturedAt > $1.capturedAt } do { try fileManager.createDirectory(at: albumRoot(albumId: albumId), withIntermediateDirectories: true) @@ -227,7 +218,6 @@ final class TravelAlbumOTGPhotoStore { guard albumId > 0, !photoId.isEmpty else { return } let records = load(albumId: albumId) records.filter { $0.id == photoId }.forEach(deleteFiles) - saveDeletedIds(loadDeletedIds(albumId: albumId).union([photoId]), albumId: albumId) save(records.filter { $0.id != photoId }, albumId: albumId) } @@ -238,15 +228,6 @@ final class TravelAlbumOTGPhotoStore { try? fileManager.removeItem(at: previewRoot(albumId: albumId)) } - /// 返回已删除照片 ID。 - func loadDeletedIds(albumId: Int) -> Set { - guard let data = try? Data(contentsOf: deletedIndexURL(albumId: albumId)), - let decoded = try? JSONDecoder().decode([String].self, from: data) else { - return [] - } - return Set(decoded) - } - /// 原片目录。 func originalsDirectory(albumId: Int) throws -> URL { let url = albumRoot(albumId: albumId).appendingPathComponent("originals", isDirectory: true) @@ -292,6 +273,58 @@ final class TravelAlbumOTGPhotoStore { return url } + /// 返回写入索引用的相对路径。 + func relativePath(for url: URL, albumId: Int) -> String { + let path = url.standardizedFileURL.path + let originalRoot = albumRoot(albumId: albumId) + .appendingPathComponent("originals", isDirectory: true) + .standardizedFileURL + .path + if let relative = pathComponent(path, relativeTo: originalRoot) { + return "originals/\(relative)" + } + + let previewPath = previewRoot(albumId: albumId).standardizedFileURL.path + if let relative = pathComponent(path, relativeTo: previewPath) { + return "previews/\(relative)" + } + + return url.lastPathComponent + } + + /// 把索引中的相对路径解析到当前沙盒绝对 URL。 + func absoluteURL(for relativePath: String, albumId: Int) -> URL? { + let trimmed = relativePath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !trimmed.hasPrefix("/") else { return nil } + if trimmed.hasPrefix("originals/") { + return albumRoot(albumId: albumId).appendingPathComponent(trimmed) + } + if trimmed.hasPrefix("previews/") { + return previewRoot(albumId: albumId) + .deletingLastPathComponent() + .appendingPathComponent(trimmed) + } + return albumRoot(albumId: albumId).appendingPathComponent(trimmed) + } + + /// 判断索引相对路径对应的文件是否存在。 + func fileExists(relativePath: String, albumId: Int) -> Bool { + guard let url = absoluteURL(for: relativePath, albumId: albumId) else { return false } + return fileManager.fileExists(atPath: url.path) + } + + /// 返回可直接用于文件读取的记录副本。 + func recordWithResolvedFilePaths(_ record: TravelAlbumOTGPhotoRecord, albumId: Int) -> TravelAlbumOTGPhotoRecord { + var resolved = record + if let localURL = absoluteURL(for: record.localPath, albumId: albumId) { + resolved.localPath = localURL.path + } + if let thumbnailURL = absoluteURL(for: record.thumbnailPath, albumId: albumId) { + resolved.thumbnailPath = thumbnailURL.path + } + return resolved + } + /// 生成稳定照片 ID。 static func photoId(for fileName: String, createdAt: Date) -> String { let timestamp = Int64(createdAt.timeIntervalSince1970) @@ -339,27 +372,46 @@ final class TravelAlbumOTGPhotoStore { albumRoot(albumId: albumId).appendingPathComponent("records.json") } - private func deletedIndexURL(albumId: Int) -> URL { - albumRoot(albumId: albumId).appendingPathComponent("deleted.json") - } - - private func saveDeletedIds(_ ids: Set, albumId: Int) { - do { - try fileManager.createDirectory(at: albumRoot(albumId: albumId), withIntermediateDirectories: true) - let data = try JSONEncoder().encode(Array(ids)) - try data.write(to: deletedIndexURL(albumId: albumId), options: .atomic) - } catch { - OTGLog.warning(.connection, "save deleted OTG ids failed: \(error.localizedDescription)") - } - } - private func deleteFiles(_ record: TravelAlbumOTGPhotoRecord) { [record.localPath, record.thumbnailPath].forEach { path in guard !path.isEmpty else { return } - try? fileManager.removeItem(atPath: path) + guard let url = absoluteURL(for: path, albumId: record.albumId) else { return } + try? fileManager.removeItem(at: url) } } + private func isDisplayable(_ record: TravelAlbumOTGPhotoRecord, albumId: Int) -> Bool { + if record.status == .uploaded { return true } + if !record.remoteUrl.isEmpty { return true } + if fileExists(relativePath: record.localPath, albumId: albumId) { return true } + if fileExists(relativePath: record.thumbnailPath, albumId: albumId) { return true } + return false + } + + private func recordByPersistingRelativePaths( + _ record: TravelAlbumOTGPhotoRecord, + albumId: Int + ) -> TravelAlbumOTGPhotoRecord { + var normalized = record + if !record.localPath.isEmpty { + normalized.localPath = record.localPath.hasPrefix("/") + ? relativePath(for: URL(fileURLWithPath: record.localPath), albumId: albumId) + : record.localPath + } + if !record.thumbnailPath.isEmpty { + normalized.thumbnailPath = record.thumbnailPath.hasPrefix("/") + ? relativePath(for: URL(fileURLWithPath: record.thumbnailPath), albumId: albumId) + : record.thumbnailPath + } + return normalized + } + + private func pathComponent(_ path: String, relativeTo root: String) -> String? { + let prefix = root.hasSuffix("/") ? root : "\(root)/" + guard path.hasPrefix(prefix) else { return nil } + return String(path.dropFirst(prefix.count)) + } + private func excludeFromBackup(_ url: URL) throws { var resourceValues = URLResourceValues() resourceValues.isExcludedFromBackup = true diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumCameraImportViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumCameraImportViewModel.swift index a5d7496..0d78182 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumCameraImportViewModel.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumCameraImportViewModel.swift @@ -18,7 +18,7 @@ enum TravelAlbumCameraImportState: Equatable { case loading case loaded(sections: [TravelAlbumCameraImportSection]) case importing(current: Int, total: Int) - case finished(importedCount: Int) + case finished(importedCount: Int, failedCount: Int) case failed(message: String) } @@ -193,7 +193,7 @@ final class TravelAlbumCameraImportViewModel { id: object.id, sourceId: object.id, fileName: downloadedURL.lastPathComponent, - localPath: downloadedURL.path, + localPath: storage.relativePath(for: downloadedURL, albumId: albumId), thumbnailPath: "", capturedAt: TravelAlbumOTGPhotoStore.transferTimeText(object.capturedAt), fileSizeBytes: object.fileSize, @@ -212,7 +212,7 @@ final class TravelAlbumCameraImportViewModel { } selectedPhotoIds.subtract(existingPhotoIds) - state = .finished(importedCount: importedCount) + state = .finished(importedCount: importedCount, failedCount: failedPhotoIds.count) } } diff --git a/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift index 5f3f2b2..1097cb6 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift @@ -47,7 +47,6 @@ final class WiredCameraTransferViewModel { private var currentDriver: CameraDriver? private var persistedRecordsById: [String: TravelAlbumOTGPhotoRecord] = [:] - private var deletedPhotoIds: Set = [] private var isUploading = false @MainActor @@ -235,6 +234,11 @@ final class WiredCameraTransferViewModel { return nil } + /// 历史导入期间暂停边拍边传轮询,避免 PTP 轮询与 ImageCaptureCore 下载互相抢占。 + func pauseLiveTransferForHistoricalImport() { + connectionManager.suspendLiveTransfer() + } + /// 选择或取消选择照片。 func toggleTransferPhotoSelection(photoId: String) { guard selectUploadMode, @@ -268,7 +272,6 @@ final class WiredCameraTransferViewModel { /// 删除单张本地照片。 func deletePhoto(photoId: String) { storage.remove(albumId: albumId, photoId: photoId) - deletedPhotoIds.insert(photoId) persistedRecordsById.removeValue(forKey: photoId) selectedPhotoIds.remove(photoId) applyMergedPhotos() @@ -289,7 +292,6 @@ final class WiredCameraTransferViewModel { } private func loadPersistedPhotos() { - deletedPhotoIds = storage.loadDeletedIds(albumId: albumId) let records = storage.load(albumId: albumId) persistedRecordsById = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) }) applyMergedPhotos() @@ -297,8 +299,7 @@ final class WiredCameraTransferViewModel { private func applyMergedPhotos() { let persistedItems = persistedRecordsById.values - .filter { !deletedPhotoIds.contains($0.id) } - .map { $0.toPhotoItem() } + .map { $0.toPhotoItem(storage: storage, albumId: albumId) } photos = persistedItems.sorted { $0.capturedAt > $1.capturedAt } } @@ -347,8 +348,8 @@ final class WiredCameraTransferViewModel { private func localRecordForUpload(id: String) async throws -> TravelAlbumOTGPhotoRecord { if let record = persistedRecordsById[id], !record.localPath.isEmpty, - FileManager.default.fileExists(atPath: record.localPath) { - return record + storage.fileExists(relativePath: record.localPath, albumId: albumId) { + return storage.recordWithResolvedFilePaths(record, albumId: albumId) } throw TravelAlbumOTGUploadError.localFileMissing } @@ -367,7 +368,9 @@ final class WiredCameraTransferViewModel { sourceId: item.sourceId, fileName: item.fileName, localPath: item.localPath, - thumbnailPath: item.thumbnailURL?.isFileURL == true ? item.thumbnailURL?.path ?? "" : "", + thumbnailPath: item.thumbnailURL?.isFileURL == true + ? storage.relativePath(for: item.thumbnailURL ?? URL(fileURLWithPath: ""), albumId: albumId) + : "", capturedAt: item.capturedAt, fileSizeBytes: item.fileSizeBytes, status: status, diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumCameraImportViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumCameraImportViewController.swift index 02d8407..c91358a 100644 --- a/suixinkan/UI/TravelAlbum/TravelAlbumCameraImportViewController.swift +++ b/suixinkan/UI/TravelAlbum/TravelAlbumCameraImportViewController.swift @@ -16,7 +16,8 @@ final class TravelAlbumCameraImportViewController: UIViewController { } private let viewModel: TravelAlbumCameraImportViewModel - var onImportFinished: ((Int) -> Void)? + var onImportFinished: ((Int, Int) -> Void)? + var onImportCancelled: (() -> Void)? private var collectionView: UICollectionView! private var sections: [TravelAlbumCameraImportSection] = [] @@ -75,6 +76,11 @@ final class TravelAlbumCameraImportViewController: UIViewController { collectionView.collectionViewLayout.invalidateLayout() } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + navigationController?.presentationController?.delegate = self + } + private func setupUI() { let layout = UICollectionViewFlowLayout() layout.minimumLineSpacing = Layout.spacing @@ -150,7 +156,6 @@ final class TravelAlbumCameraImportViewController: UIViewController { } viewModel.onSelectionUpdated = { [weak self] count in self?.updateImportButton(count: count) - self?.collectionView.reloadData() } viewModel.onSonyMTPHelpVisibilityChanged = { [weak self] show in self?.updateHelpVisibility(show) @@ -160,6 +165,7 @@ final class TravelAlbumCameraImportViewController: UIViewController { private func render(_ state: TravelAlbumCameraImportState) { switch state { case .idle, .loading: + isModalInPresentation = false collectionView.isHidden = true messageLabel.isHidden = true helpButton.isHidden = true @@ -168,6 +174,7 @@ final class TravelAlbumCameraImportViewController: UIViewController { activityIndicator.startAnimating() case .loaded(let loadedSections): + isModalInPresentation = false activityIndicator.stopAnimating() collectionView.isHidden = false messageLabel.isHidden = true @@ -177,6 +184,7 @@ final class TravelAlbumCameraImportViewController: UIViewController { collectionView.reloadData() case .importing(let current, let total): + isModalInPresentation = true collectionView.isHidden = true messageLabel.isHidden = false messageLabel.text = "正在导入 \(current)/\(total)..." @@ -185,13 +193,15 @@ final class TravelAlbumCameraImportViewController: UIViewController { navigationItem.leftBarButtonItem?.isEnabled = false activityIndicator.startAnimating() - case .finished(let count): + case .finished(let importedCount, let failedCount): + isModalInPresentation = false activityIndicator.stopAnimating() dismiss(animated: true) { [weak self] in - self?.onImportFinished?(count) + self?.onImportFinished?(importedCount, failedCount) } case .failed(let message): + isModalInPresentation = false activityIndicator.stopAnimating() collectionView.isHidden = true messageLabel.isHidden = false @@ -229,7 +239,52 @@ final class TravelAlbumCameraImportViewController: UIViewController { return CGSize(width: width, height: width + 28) } + private func applyHeader(_ header: TravelAlbumCameraImportSectionHeaderView, at sectionIndex: Int) { + let section = sections[sectionIndex] + header.apply( + title: section.title, + count: section.photos.count, + importableCount: viewModel.importablePhotoCount(in: section), + importedCount: viewModel.importedPhotoCount(in: section), + hasImportablePhotos: viewModel.hasImportablePhotos(in: section), + fullySelected: viewModel.isSectionFullySelected(section), + partiallySelected: viewModel.isSectionPartiallySelected(section) + ) + header.onToggle = { [weak self] in + self?.toggleSection(at: sectionIndex) + } + } + + private func refreshVisibleHeader(at sectionIndex: Int) { + let indexPath = IndexPath(item: 0, section: sectionIndex) + guard let header = collectionView.supplementaryView( + forElementKind: UICollectionView.elementKindSectionHeader, + at: indexPath + ) as? TravelAlbumCameraImportSectionHeaderView else { + return + } + applyHeader(header, at: sectionIndex) + } + + private func reloadItemsWithoutAnimation(_ indexPaths: [IndexPath]) { + UIView.performWithoutAnimation { + collectionView.reloadItems(at: indexPaths) + collectionView.layoutIfNeeded() + } + } + + private func toggleSection(at sectionIndex: Int) { + guard sections.indices.contains(sectionIndex) else { return } + viewModel.toggleSection(sections[sectionIndex]) + let indexPaths = sections[sectionIndex].photos.indices.map { + IndexPath(item: $0, section: sectionIndex) + } + reloadItemsWithoutAnimation(indexPaths) + refreshVisibleHeader(at: sectionIndex) + } + @objc private func cancelTapped() { + onImportCancelled?() dismiss(animated: true) } @@ -282,21 +337,7 @@ extension TravelAlbumCameraImportViewController: UICollectionViewDataSource { withReuseIdentifier: TravelAlbumCameraImportSectionHeaderView.reuseIdentifier, for: indexPath ) as! TravelAlbumCameraImportSectionHeaderView - let section = sections[indexPath.section] - header.apply( - title: section.title, - count: section.photos.count, - importableCount: viewModel.importablePhotoCount(in: section), - importedCount: viewModel.importedPhotoCount(in: section), - hasImportablePhotos: viewModel.hasImportablePhotos(in: section), - fullySelected: viewModel.isSectionFullySelected(section), - partiallySelected: viewModel.isSectionPartiallySelected(section) - ) - header.onToggle = { [weak self] in - guard let self else { return } - self.viewModel.toggleSection(section) - self.collectionView.reloadSections(IndexSet(integer: indexPath.section)) - } + applyHeader(header, at: indexPath.section) return header } } @@ -306,8 +347,8 @@ extension TravelAlbumCameraImportViewController: UICollectionViewDelegateFlowLay let photo = sections[indexPath.section].photos[indexPath.item] guard !viewModel.isPhotoAlreadyInAlbum(id: photo.id) else { return } viewModel.togglePhoto(id: photo.id) - collectionView.reloadItems(at: [indexPath]) - collectionView.reloadSections(IndexSet(integer: indexPath.section)) + reloadItemsWithoutAnimation([indexPath]) + refreshVisibleHeader(at: indexPath.section) } func collectionView( @@ -322,6 +363,12 @@ extension TravelAlbumCameraImportViewController: UICollectionViewDelegateFlowLay } } +extension TravelAlbumCameraImportViewController: UIAdaptivePresentationControllerDelegate { + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + onImportCancelled?() + } +} + /// 相机历史照片导入 Cell。 private final class TravelAlbumCameraImportPhotoCell: UICollectionViewCell { static let reuseIdentifier = "TravelAlbumCameraImportPhotoCell" diff --git a/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift b/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift index 68b5e9c..f24524e 100644 --- a/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift +++ b/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift @@ -421,10 +421,20 @@ final class WiredCameraTransferViewController: BaseViewController { @objc private func historyImportTapped() { guard let importViewModel = viewModel.makeCameraImportViewModel() else { return } + viewModel.pauseLiveTransferForHistoricalImport() let importController = TravelAlbumCameraImportViewController(viewModel: importViewModel) - importController.onImportFinished = { [weak self] count in - self?.viewModel.reloadLocalPhotos() - self?.showToast("已导入 \(count) 张照片") + importController.onImportFinished = { [weak self] importedCount, failedCount in + guard let self else { return } + self.viewModel.reloadLocalPhotos() + self.viewModel.start() + if failedCount > 0 { + self.showToast("已导入 \(importedCount) 张,\(failedCount) 张失败") + } else { + self.showToast("已导入 \(importedCount) 张照片") + } + } + importController.onImportCancelled = { [weak self] in + self?.viewModel.start() } let navigationController = UINavigationController(rootViewController: importController) navigationController.modalPresentationStyle = .pageSheet diff --git a/suixinkanTests/TravelAlbumOTGStorageTests.swift b/suixinkanTests/TravelAlbumOTGStorageTests.swift index 4549696..8ba0567 100644 --- a/suixinkanTests/TravelAlbumOTGStorageTests.swift +++ b/suixinkanTests/TravelAlbumOTGStorageTests.swift @@ -56,6 +56,8 @@ final class TravelAlbumOTGStorageTests: XCTestCase { XCTAssertEqual(loaded.first?.status, .pending) XCTAssertEqual(loaded.first?.progress, 0) + XCTAssertEqual(loaded.first?.localPath, "originals/A.JPG") + XCTAssertEqual(store.absoluteURL(for: loaded.first?.localPath ?? "", albumId: 33), file) } func testRemoveAndClearAlbumDeleteFiles() throws { diff --git a/suixinkanTests/TravelAlbumViewModelTests.swift b/suixinkanTests/TravelAlbumViewModelTests.swift index 367a36d..946db25 100644 --- a/suixinkanTests/TravelAlbumViewModelTests.swift +++ b/suixinkanTests/TravelAlbumViewModelTests.swift @@ -208,6 +208,16 @@ final class WiredCameraTransferViewModelTests: XCTestCase { XCTAssertEqual(manager.suspendLiveTransferCallCount, 1) } + + func testHistoricalImportPausesLiveTransferPolling() { + let context = makeOTGTestContext() + let manager = MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])) + let viewModel = makeWiredViewModel(context: context, manager: manager) + + viewModel.pauseLiveTransferForHistoricalImport() + + XCTAssertEqual(manager.suspendLiveTransferCallCount, 1) + } } /// 旅拍相册相机历史导入 ViewModel 测试。 @@ -353,6 +363,12 @@ final class TravelAlbumCameraImportViewModelTests: XCTestCase { XCTAssertEqual(Set(driver.downloadedObjectIds), [failed.id, success.id]) XCTAssertTrue(viewModel.failedPhotoIds.contains(failed.id)) XCTAssertEqual(context.store.load(albumId: 9).first?.sourceId, success.id) + if case .finished(let importedCount, let failedCount) = viewModel.state { + XCTAssertEqual(importedCount, 1) + XCTAssertEqual(failedCount, 1) + } else { + XCTFail("Expected finished state") + } } func testSonyEmptyCatalogShowsMTPHelp() async {