Update OTG transfer workflow

This commit is contained in:
2026-07-10 11:02:00 +08:00
parent 9d446b4bc3
commit 98dc810455
11 changed files with 1047 additions and 121 deletions

View File

@ -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)")
}
}