Update OTG transfer workflow
This commit is contained in:
@ -133,7 +133,12 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
|
||||
@MainActor
|
||||
final class WiredCameraTransferViewModelTests: XCTestCase {
|
||||
func testDefaultDisconnectedStateAndOptions() {
|
||||
let viewModel = WiredCameraTransferViewModel(albumId: 1, albumTitle: "相册", headerPhone: "13800138000")
|
||||
let viewModel = WiredCameraTransferViewModel(
|
||||
albumId: 1,
|
||||
albumTitle: "相册",
|
||||
headerPhone: "13800138000",
|
||||
userDefaults: makeOTGTestDefaults()
|
||||
)
|
||||
|
||||
XCTAssertEqual(viewModel.cameraStatusText, "未连接 USB")
|
||||
XCTAssertEqual(viewModel.tabCounts, [0, 0, 0])
|
||||
@ -142,6 +147,46 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
||||
XCTAssertTrue(viewModel.filteredPhotos().isEmpty)
|
||||
}
|
||||
|
||||
func testTransferModePersistsLocallyWithoutAlbumScope() {
|
||||
let defaults = makeOTGTestDefaults()
|
||||
let firstContext = makeOTGTestContext()
|
||||
let secondContext = makeOTGTestContext()
|
||||
let firstViewModel = makeWiredViewModel(
|
||||
albumId: 9,
|
||||
context: firstContext,
|
||||
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
|
||||
userDefaults: defaults
|
||||
)
|
||||
|
||||
firstViewModel.selectTransferMode("拍后传输")
|
||||
|
||||
let secondViewModel = makeWiredViewModel(
|
||||
albumId: 18,
|
||||
context: secondContext,
|
||||
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
|
||||
userDefaults: defaults
|
||||
)
|
||||
|
||||
XCTAssertEqual(secondViewModel.transferModeOption, "拍后传输")
|
||||
}
|
||||
|
||||
func testConnectedStatusIncludesCameraModelName() {
|
||||
let context = makeOTGTestContext()
|
||||
let viewModel = makeWiredViewModel(
|
||||
context: context,
|
||||
manager: MockWiredCameraConnectionManager(
|
||||
state: .connected(platform: .canon, deviceName: "Canon R8"),
|
||||
driver: MockCameraDriver(objects: [])
|
||||
)
|
||||
)
|
||||
|
||||
viewModel.start()
|
||||
|
||||
XCTAssertEqual(viewModel.cameraStatusText, "Canon R8已连接")
|
||||
XCTAssertFalse(viewModel.deviceModelName.isEmpty)
|
||||
XCTAssertTrue(viewModel.availableStorageText.hasSuffix("GB 可用"))
|
||||
}
|
||||
|
||||
func testTransferPhotoSectionsAndSidebarUseDescendingHalfHourSlots() {
|
||||
let photos = [
|
||||
makeOTGPhotoItem(id: "a", capturedAt: "2026-07-08 12:05:00"),
|
||||
@ -183,6 +228,34 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(viewModel.selectedTimeSlotId, "2026-07-08T12:30")
|
||||
}
|
||||
|
||||
func testSyncVisibleTimeSlotDoesNotNotifyStateChange() throws {
|
||||
let context = makeOTGTestContext()
|
||||
context.store.save([
|
||||
try makePersistedOTGRecord(context: context, id: "first", capturedAt: "2026-07-08 12:35:00", status: .pending),
|
||||
try makePersistedOTGRecord(context: context, id: "second", capturedAt: "2026-07-08 12:05:00", status: .pending),
|
||||
], albumId: 9)
|
||||
let viewModel = makeWiredViewModel(
|
||||
context: context,
|
||||
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: []))
|
||||
)
|
||||
|
||||
viewModel.reloadLocalPhotos()
|
||||
var stateChangeCount = 0
|
||||
viewModel.onStateChange = {
|
||||
stateChangeCount += 1
|
||||
}
|
||||
|
||||
viewModel.syncVisibleTimeSlot(id: "2026-07-08T12:00")
|
||||
|
||||
XCTAssertEqual(viewModel.selectedTimeSlotId, "2026-07-08T12:00")
|
||||
XCTAssertEqual(stateChangeCount, 0)
|
||||
|
||||
viewModel.selectTimeSlot(id: "2026-07-08T12:30")
|
||||
|
||||
XCTAssertEqual(viewModel.selectedTimeSlotId, "2026-07-08T12:30")
|
||||
XCTAssertEqual(stateChangeCount, 1)
|
||||
}
|
||||
|
||||
func testSelectionModeAllowsPendingAndFailedOnly() throws {
|
||||
let context = makeOTGTestContext()
|
||||
context.store.save([
|
||||
@ -280,6 +353,137 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
||||
|
||||
XCTAssertEqual(manager.suspendLiveTransferCallCount, 1)
|
||||
}
|
||||
|
||||
func testPostTransferModeKeepsNewImportsPending() throws {
|
||||
let context = makeOTGTestContext()
|
||||
let uploader = MockTravelAlbumOTGUploader()
|
||||
let viewModel = makeWiredViewModel(
|
||||
context: context,
|
||||
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
|
||||
uploader: uploader
|
||||
)
|
||||
let records = [
|
||||
try makePersistedOTGRecord(context: context, id: "a", capturedAt: "2026-07-08 12:05:00", status: .pending),
|
||||
try makePersistedOTGRecord(context: context, id: "b", capturedAt: "2026-07-08 12:06:00", status: .pending),
|
||||
]
|
||||
context.store.save(records, albumId: 9)
|
||||
|
||||
viewModel.selectTransferMode("拍后传输")
|
||||
viewModel.handleNewlyImportedPhotoIds(records.map(\.id))
|
||||
|
||||
XCTAssertEqual(uploader.uploadCallCount, 0)
|
||||
XCTAssertEqual(Set(context.store.load(albumId: 9).map(\.status)), [.pending])
|
||||
}
|
||||
|
||||
func testSpecifyUploadTodayCapturedOnlyUploadsTodayNotUploadedPhotos() async throws {
|
||||
let context = makeOTGTestContext()
|
||||
let uploader = MockTravelAlbumOTGUploader()
|
||||
let viewModel = makeWiredViewModel(
|
||||
context: context,
|
||||
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
|
||||
uploader: uploader
|
||||
)
|
||||
let today = TravelAlbumOTGPhotoStore.transferTimeText(Date())
|
||||
let yesterday = TravelAlbumOTGPhotoStore.transferTimeText(Date().addingTimeInterval(-86_400))
|
||||
let records = [
|
||||
try makePersistedOTGRecord(context: context, id: "today-pending", capturedAt: today, status: .pending),
|
||||
try makePersistedOTGRecord(context: context, id: "today-failed", capturedAt: today, status: .failed),
|
||||
try makePersistedOTGRecord(context: context, id: "today-uploaded", capturedAt: today, status: .uploaded),
|
||||
try makePersistedOTGRecord(context: context, id: "yesterday-pending", capturedAt: yesterday, status: .pending),
|
||||
]
|
||||
context.store.save(records, albumId: 9)
|
||||
|
||||
viewModel.reloadLocalPhotos()
|
||||
viewModel.onSpecifyUploadOptionSelected(.todayCaptured)
|
||||
await waitUntil { uploader.uploadCallCount == 2 }
|
||||
|
||||
XCTAssertEqual(uploader.uploadedRecordIds.sorted(), ["today-failed", "today-pending"])
|
||||
let recordsById = Dictionary(uniqueKeysWithValues: context.store.load(albumId: 9).map { ($0.id, $0) })
|
||||
XCTAssertEqual(recordsById["today-pending"]?.status, .uploaded)
|
||||
XCTAssertEqual(recordsById["today-failed"]?.status, .uploaded)
|
||||
XCTAssertEqual(recordsById["today-uploaded"]?.status, .uploaded)
|
||||
XCTAssertEqual(recordsById["yesterday-pending"]?.status, .pending)
|
||||
}
|
||||
|
||||
func testSpecifyUploadAllPendingUploadsPendingAndFailedPhotos() async throws {
|
||||
let context = makeOTGTestContext()
|
||||
let uploader = MockTravelAlbumOTGUploader()
|
||||
let viewModel = makeWiredViewModel(
|
||||
context: context,
|
||||
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
|
||||
uploader: uploader
|
||||
)
|
||||
let records = [
|
||||
try makePersistedOTGRecord(context: context, id: "pending", capturedAt: "2026-07-08 12:05:00", status: .pending),
|
||||
try makePersistedOTGRecord(context: context, id: "failed", capturedAt: "2026-07-08 12:06:00", status: .failed),
|
||||
try makePersistedOTGRecord(context: context, id: "uploaded", capturedAt: "2026-07-08 12:07:00", status: .uploaded),
|
||||
]
|
||||
context.store.save(records, albumId: 9)
|
||||
|
||||
viewModel.reloadLocalPhotos()
|
||||
viewModel.onSpecifyUploadOptionSelected(.allPending)
|
||||
await waitUntil { uploader.uploadCallCount == 2 }
|
||||
|
||||
XCTAssertEqual(uploader.uploadedRecordIds.sorted(), ["failed", "pending"])
|
||||
}
|
||||
|
||||
func testSwitchingToLiveUploadOnlyUploadsNewImports() async throws {
|
||||
let context = makeOTGTestContext()
|
||||
let uploader = MockTravelAlbumOTGUploader()
|
||||
let viewModel = makeWiredViewModel(
|
||||
context: context,
|
||||
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
|
||||
uploader: uploader
|
||||
)
|
||||
let oldRecords = [
|
||||
try makePersistedOTGRecord(context: context, id: "old-a", capturedAt: "2026-07-08 12:05:00", status: .pending),
|
||||
try makePersistedOTGRecord(context: context, id: "old-b", capturedAt: "2026-07-08 12:06:00", status: .pending),
|
||||
]
|
||||
context.store.save(oldRecords, albumId: 9)
|
||||
|
||||
viewModel.selectTransferMode("拍后传输")
|
||||
viewModel.handleNewlyImportedPhotoIds(oldRecords.map(\.id))
|
||||
|
||||
let newRecords = [
|
||||
try makePersistedOTGRecord(context: context, id: "new-c", capturedAt: "2026-07-08 12:10:00", status: .pending),
|
||||
try makePersistedOTGRecord(context: context, id: "new-d", capturedAt: "2026-07-08 12:11:00", status: .pending),
|
||||
]
|
||||
context.store.save(oldRecords + newRecords, albumId: 9)
|
||||
|
||||
viewModel.selectTransferMode("边拍边传")
|
||||
viewModel.handleNewlyImportedPhotoIds(newRecords.map(\.id))
|
||||
await waitUntil { uploader.uploadCallCount == 2 }
|
||||
|
||||
let recordsById = Dictionary(uniqueKeysWithValues: context.store.load(albumId: 9).map { ($0.id, $0) })
|
||||
XCTAssertEqual(uploader.uploadedRecordIds.sorted(), ["new-c", "new-d"])
|
||||
XCTAssertEqual(recordsById["old-a"]?.status, .pending)
|
||||
XCTAssertEqual(recordsById["old-b"]?.status, .pending)
|
||||
XCTAssertEqual(recordsById["new-c"]?.status, .uploaded)
|
||||
XCTAssertEqual(recordsById["new-d"]?.status, .uploaded)
|
||||
}
|
||||
|
||||
func testPhoneAlbumImportWritesLocalRecordsAndAutoUploadsInLiveMode() async {
|
||||
let context = makeOTGTestContext()
|
||||
let uploader = MockTravelAlbumOTGUploader()
|
||||
let viewModel = makeWiredViewModel(
|
||||
context: context,
|
||||
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
|
||||
uploader: uploader
|
||||
)
|
||||
|
||||
let result = viewModel.importPhoneAlbumImages([
|
||||
TravelAlbumPhoneAlbumImportItem(data: Data([1, 2, 3]), fileName: "album.jpg"),
|
||||
TravelAlbumPhoneAlbumImportItem(data: Data([4, 5, 6]), fileName: "album.jpg"),
|
||||
])
|
||||
await waitUntil { uploader.uploadCallCount == 2 }
|
||||
|
||||
let records = context.store.load(albumId: 9)
|
||||
XCTAssertEqual(result.importedCount, 2)
|
||||
XCTAssertEqual(result.failedCount, 0)
|
||||
XCTAssertEqual(Set(result.importedPhotoIds), Set(records.map(\.id)))
|
||||
XCTAssertEqual(Set(records.map(\.status)), [.uploaded])
|
||||
XCTAssertEqual(Set(records.map(\.fileName)).count, 2)
|
||||
}
|
||||
}
|
||||
|
||||
/// 旅拍相册相机历史导入 ViewModel 测试。
|
||||
@ -395,6 +599,11 @@ final class TravelAlbumCameraImportViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(records.count, 1)
|
||||
XCTAssertEqual(records.first?.sourceId, selected.id)
|
||||
XCTAssertEqual(records.first?.status, .pending)
|
||||
if case .finished(_, _, let importedPhotoIds) = viewModel.state {
|
||||
XCTAssertEqual(importedPhotoIds, records.map(\.id))
|
||||
} else {
|
||||
XCTFail("Expected finished state")
|
||||
}
|
||||
}
|
||||
|
||||
func testImportSelectedContinuesAfterSingleFailure() async {
|
||||
@ -425,9 +634,10 @@ 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 {
|
||||
if case .finished(let importedCount, let failedCount, let importedPhotoIds) = viewModel.state {
|
||||
XCTAssertEqual(importedCount, 1)
|
||||
XCTAssertEqual(failedCount, 1)
|
||||
XCTAssertEqual(importedPhotoIds, context.store.load(albumId: 9).map(\.id))
|
||||
} else {
|
||||
XCTFail("Expected finished state")
|
||||
}
|
||||
@ -469,18 +679,21 @@ private struct OTGTestContext {
|
||||
|
||||
@MainActor
|
||||
private func makeWiredViewModel(
|
||||
albumId: Int = 9,
|
||||
context: OTGTestContext,
|
||||
manager: MockWiredCameraConnectionManager,
|
||||
uploader: MockTravelAlbumOTGUploader? = nil
|
||||
uploader: MockTravelAlbumOTGUploader? = nil,
|
||||
userDefaults: UserDefaults = makeOTGTestDefaults()
|
||||
) -> WiredCameraTransferViewModel {
|
||||
prepareAppStoreForOTGTests()
|
||||
return WiredCameraTransferViewModel(
|
||||
albumId: 9,
|
||||
albumId: albumId,
|
||||
albumTitle: "相册",
|
||||
headerPhone: "13800138000",
|
||||
connectionManager: manager,
|
||||
storage: context.store,
|
||||
uploader: uploader ?? MockTravelAlbumOTGUploader()
|
||||
uploader: uploader ?? MockTravelAlbumOTGUploader(),
|
||||
userDefaults: userDefaults
|
||||
)
|
||||
}
|
||||
|
||||
@ -560,6 +773,13 @@ private func makeOTGTestContext() -> OTGTestContext {
|
||||
return OTGTestContext(root: root, store: store)
|
||||
}
|
||||
|
||||
private func makeOTGTestDefaults() -> UserDefaults {
|
||||
let suiteName = "TravelAlbumOTGTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
return defaults
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func prepareAppStoreForOTGTests() {
|
||||
AppStore.shared.userId = "u1"
|
||||
@ -664,6 +884,7 @@ private final class MockCameraDriver: CameraDriver {
|
||||
@MainActor
|
||||
private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading {
|
||||
private(set) var uploadCallCount = 0
|
||||
private(set) var uploadedRecordIds: [String] = []
|
||||
|
||||
func upload(
|
||||
record: TravelAlbumOTGPhotoRecord,
|
||||
@ -671,6 +892,7 @@ private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading {
|
||||
progress: @escaping (Int) -> Void
|
||||
) async throws -> TravelAlbumMaterial {
|
||||
uploadCallCount += 1
|
||||
uploadedRecordIds.append(record.id)
|
||||
return TravelAlbumMaterial(id: 1, fileName: record.fileName, fileUrl: "https://cdn.example.com/\(record.fileName)")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user