Files
suixinkan_uikit/suixinkanTests/TravelAlbumViewModelTests.swift
2026-07-10 11:02:00 +08:00

983 lines
38 KiB
Swift

//
// 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
@MainActor
final class WiredCameraTransferViewModelTests: XCTestCase {
func testDefaultDisconnectedStateAndOptions() {
let viewModel = WiredCameraTransferViewModel(
albumId: 1,
albumTitle: "相册",
headerPhone: "13800138000",
userDefaults: makeOTGTestDefaults()
)
XCTAssertEqual(viewModel.cameraStatusText, "未连接 USB")
XCTAssertEqual(viewModel.tabCounts, [0, 0, 0])
XCTAssertEqual(viewModel.photoFormatOption, .jpg)
XCTAssertEqual(viewModel.transferModeOption, "边拍边传")
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"),
makeOTGPhotoItem(id: "b", capturedAt: "2026-07-08 12:29:59"),
makeOTGPhotoItem(id: "c", capturedAt: "2026-07-08 12:30:00"),
makeOTGPhotoItem(id: "d", capturedAt: "2026-07-07 18:10:00"),
]
let sections = photos.buildPhotoSections()
let groups = photos.buildSidebarGroups()
XCTAssertEqual(sections.map(\.slotId), [
"2026-07-08T12:30",
"2026-07-08T12:00",
"2026-07-07T18:00",
])
XCTAssertEqual(sections[0].headerTitle, "07月08日 12:30 - 13:00")
XCTAssertEqual(sections[1].photos.map(\.id), ["b", "a"])
XCTAssertEqual(groups.map(\.dateLabel), ["07月08日", "07月07日"])
XCTAssertEqual(groups.first?.slots.map(\.slotStartLabel), ["12:30", "12:00"])
XCTAssertEqual(groups.first?.slots.last?.photoCount, 2)
}
func testSelectedTimeSlotFallsBackWhenTabChanges() throws {
let context = makeOTGTestContext()
context.store.save([
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:30:00", status: .failed),
], albumId: 9)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: []))
)
viewModel.reloadLocalPhotos()
viewModel.selectTimeSlot(id: "2026-07-08T12:00")
viewModel.selectTab(.failed)
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([
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:10:00", status: .failed),
try makePersistedOTGRecord(context: context, id: "uploaded", capturedAt: "2026-07-08 12:15:00", status: .uploaded),
], albumId: 9)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: []))
)
viewModel.reloadLocalPhotos()
viewModel.onBatchUploadButtonClick()
viewModel.toggleTransferPhotoSelection(photoId: "pending")
viewModel.toggleTransferPhotoSelection(photoId: "failed")
viewModel.toggleTransferPhotoSelection(photoId: "uploaded")
XCTAssertEqual(viewModel.selectedPhotoIds, ["pending", "failed"])
}
func testEnteringPageDoesNotListCameraObjects() async {
let context = makeOTGTestContext()
let object = CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: Date())
let driver = MockCameraDriver(objects: [object])
let manager = MockWiredCameraConnectionManager(driver: driver)
let viewModel = makeWiredViewModel(context: context, manager: manager)
viewModel.start()
try? await Task.sleep(nanoseconds: 80_000_000)
XCTAssertEqual(driver.listCallCount, 0)
XCTAssertTrue(viewModel.filteredPhotos().isEmpty)
}
func testHistoryImportRequiresConnectedCatalog() {
let context = makeOTGTestContext()
let driver = MockCameraDriver(objects: [])
let manager = MockWiredCameraConnectionManager(driver: driver, isContentCatalogReady: false)
let viewModel = makeWiredViewModel(context: context, manager: manager)
var message = ""
viewModel.onShowMessage = { message = $0 }
viewModel.start()
XCTAssertNil(viewModel.makeCameraImportViewModel())
XCTAssertEqual(message, "相机照片目录尚未就绪,请稍候再试")
}
func testHistoryImportCreatesImportViewModelWhenCatalogReady() {
let context = makeOTGTestContext()
let jpeg = CameraObject(id: "JPG|10", filename: "IMG_0001.JPG", fileSize: 10, capturedAt: Date())
let driver = MockCameraDriver(objects: [jpeg])
let manager = MockWiredCameraConnectionManager(driver: driver)
let viewModel = makeWiredViewModel(context: context, manager: manager)
viewModel.start()
XCTAssertNotNil(viewModel.makeCameraImportViewModel())
XCTAssertEqual(driver.listCallCount, 0)
}
func testHistoryImportDoesNotDownloadFromTransferPage() async {
let context = makeOTGTestContext()
let object = CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: Date())
let driver = MockCameraDriver(objects: [object])
let manager = MockWiredCameraConnectionManager(driver: driver)
let viewModel = makeWiredViewModel(context: context, manager: manager)
viewModel.start()
_ = viewModel.makeCameraImportViewModel()
try? await Task.sleep(nanoseconds: 80_000_000)
XCTAssertEqual(driver.downloadedObjectIds, [])
XCTAssertTrue(viewModel.filteredPhotos().isEmpty)
}
func testStopSuspendsLiveTransferPolling() {
let context = makeOTGTestContext()
let manager = MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: []))
let viewModel = makeWiredViewModel(context: context, manager: manager)
viewModel.start()
viewModel.stop()
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)
}
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
@MainActor
final class TravelAlbumCameraImportViewModelTests: XCTestCase {
func testLoadPhotosListsCameraObjectsAndGroupsByDay() async {
let context = makeOTGTestContext()
let calendar = Calendar(identifier: .gregorian)
let first = calendar.date(from: DateComponents(year: 2026, month: 7, day: 8, hour: 10))!
let second = calendar.date(from: DateComponents(year: 2026, month: 7, day: 7, hour: 10))!
let driver = MockCameraDriver(objects: [
CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: first),
CameraObject(id: "B|10", filename: "B.JPG", fileSize: 10, capturedAt: second),
])
let viewModel = makeImportViewModel(context: context, driver: driver)
viewModel.loadPhotos()
await waitUntil {
if case .loaded(let sections) = viewModel.state {
return sections.count == 2
}
return false
}
XCTAssertEqual(driver.listCallCount, 1)
if case .loaded(let sections) = viewModel.state {
XCTAssertEqual(sections.first?.photos.first?.id, "A|10")
} else {
XCTFail("Expected loaded state")
}
}
func testAlreadyImportedPhotoCannotBeSelected() async throws {
let context = makeOTGTestContext()
let object = CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: Date())
let localFile = try context.store.uniqueOriginalFileURL(filename: "A.JPG", albumId: 9)
try Data([1]).write(to: localFile)
context.store.save([
TravelAlbumOTGPhotoRecord(
id: "local-id",
sourceId: object.id,
fileName: "A.JPG",
localPath: localFile.path,
capturedAt: TravelAlbumOTGPhotoStore.transferTimeText(object.capturedAt),
fileSizeBytes: 1,
status: .pending,
albumId: 9,
userId: "u1"
)
], albumId: 9)
let viewModel = makeImportViewModel(context: context, driver: MockCameraDriver(objects: [object]))
viewModel.loadPhotos()
await waitUntil {
if case .loaded = viewModel.state { return true }
return false
}
viewModel.togglePhoto(id: object.id)
XCTAssertTrue(viewModel.isPhotoAlreadyInAlbum(id: object.id))
XCTAssertTrue(viewModel.selectedPhotoIds.isEmpty)
}
func testSelectionAndSectionToggleStates() async {
let context = makeOTGTestContext()
let photos = [
CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: Date()),
CameraObject(id: "B|10", filename: "B.JPG", fileSize: 10, capturedAt: Date()),
]
let viewModel = makeImportViewModel(context: context, driver: MockCameraDriver(objects: photos))
viewModel.loadPhotos()
await waitUntil {
if case .loaded = viewModel.state { return true }
return false
}
guard case .loaded(let sections) = viewModel.state, let section = sections.first else {
return XCTFail("Expected loaded state")
}
viewModel.togglePhoto(id: photos[0].id)
XCTAssertTrue(viewModel.isSectionPartiallySelected(section))
XCTAssertFalse(viewModel.isSectionFullySelected(section))
viewModel.toggleSection(section)
XCTAssertTrue(viewModel.isSectionFullySelected(section))
viewModel.toggleSection(section)
XCTAssertTrue(viewModel.selectedPhotoIds.isEmpty)
}
func testImportSelectedDownloadsOnlySelectedAndWritesPendingRecords() async {
let context = makeOTGTestContext()
let selected = CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: Date())
let unselected = CameraObject(id: "B|10", filename: "B.JPG", fileSize: 10, capturedAt: Date())
let driver = MockCameraDriver(objects: [selected, unselected])
let viewModel = makeImportViewModel(context: context, driver: driver)
viewModel.loadPhotos()
await waitUntil {
if case .loaded = viewModel.state { return true }
return false
}
viewModel.togglePhoto(id: selected.id)
viewModel.importSelected()
await waitUntil {
if case .finished = viewModel.state { return true }
return false
}
XCTAssertEqual(driver.downloadedObjectIds, [selected.id])
let records = context.store.load(albumId: 9)
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 {
let context = makeOTGTestContext()
let failed = CameraObject(id: "F|10", filename: "F.JPG", fileSize: 10, capturedAt: Date())
let success = CameraObject(id: "S|10", filename: "S.JPG", fileSize: 10, capturedAt: Date())
let driver = MockCameraDriver(objects: [failed, success], failingObjectIds: [failed.id])
let viewModel = makeImportViewModel(context: context, driver: driver)
viewModel.loadPhotos()
await waitUntil {
if case .loaded(let sections) = viewModel.state {
sections.first?.photos.count == 2
} else {
false
}
}
guard case .loaded(let sections) = viewModel.state, let section = sections.first else {
return XCTFail("Expected loaded state")
}
viewModel.toggleSection(section)
viewModel.importSelected()
await waitUntil {
if case .finished = viewModel.state { return true }
return false
}
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, let importedPhotoIds) = viewModel.state {
XCTAssertEqual(importedCount, 1)
XCTAssertEqual(failedCount, 1)
XCTAssertEqual(importedPhotoIds, context.store.load(albumId: 9).map(\.id))
} else {
XCTFail("Expected finished state")
}
}
func testSonyEmptyCatalogShowsMTPHelp() async {
let context = makeOTGTestContext()
let driver = MockCameraDriver(platform: .sony, objects: [])
let viewModel = makeImportViewModel(context: context, driver: driver)
viewModel.loadPhotos()
await waitUntil {
if case .failed = viewModel.state { return true }
return false
}
XCTAssertTrue(viewModel.shouldShowSonyMTPHelp)
}
private func makeImportViewModel(
context: OTGTestContext,
driver: MockCameraDriver
) -> TravelAlbumCameraImportViewModel {
prepareAppStoreForOTGTests()
return TravelAlbumCameraImportViewModel(
albumId: 9,
albumTitle: "相册",
driver: driver,
storage: context.store
)
}
}
/// OTG
private struct OTGTestContext {
let root: URL
let store: TravelAlbumOTGPhotoStore
}
@MainActor
private func makeWiredViewModel(
albumId: Int = 9,
context: OTGTestContext,
manager: MockWiredCameraConnectionManager,
uploader: MockTravelAlbumOTGUploader? = nil,
userDefaults: UserDefaults = makeOTGTestDefaults()
) -> WiredCameraTransferViewModel {
prepareAppStoreForOTGTests()
return WiredCameraTransferViewModel(
albumId: albumId,
albumTitle: "相册",
headerPhone: "13800138000",
connectionManager: manager,
storage: context.store,
uploader: uploader ?? MockTravelAlbumOTGUploader(),
userDefaults: userDefaults
)
}
private func makeOTGPhotoItem(
id: String,
capturedAt: String,
status: TravelAlbumOTGUploadStatus = .pending
) -> TravelAlbumOTGPhotoItem {
TravelAlbumOTGPhotoItem(
id: id,
sourceId: id,
fileName: "\(id).JPG",
thumbnailURL: nil,
capturedAt: capturedAt,
fileSizeText: "1 KB",
fileSizeBytes: 1024,
status: status,
progress: 0,
errorMessage: nil,
localPath: "originals/\(id).JPG",
remoteUrl: ""
)
}
private func makeOTGRecord(
id: String,
capturedAt: String,
status: TravelAlbumOTGUploadStatus
) -> TravelAlbumOTGPhotoRecord {
TravelAlbumOTGPhotoRecord(
id: id,
fileName: "\(id).JPG",
localPath: "originals/\(id).JPG",
capturedAt: capturedAt,
fileSizeBytes: 1024,
status: status,
albumId: 9,
userId: "u1"
)
}
private func makePersistedOTGRecord(
context: OTGTestContext,
id: String,
capturedAt: String,
status: TravelAlbumOTGUploadStatus
) throws -> TravelAlbumOTGPhotoRecord {
let relativePath = "originals/\(id).JPG"
let directory = try context.store.originalsDirectory(albumId: 9)
let url = directory.appendingPathComponent("\(id).JPG")
try Data([0xFF, 0xD8, 0xFF]).write(to: url)
return TravelAlbumOTGPhotoRecord(
id: id,
fileName: "\(id).JPG",
localPath: relativePath,
capturedAt: capturedAt,
fileSizeBytes: 3,
status: status,
albumId: 9,
userId: "u1"
)
}
@MainActor
private func makeOTGTestContext() -> OTGTestContext {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let store = TravelAlbumOTGPhotoStore(
context: TravelAlbumOTGStorageContext(
accountCachePrefix: "u1_photog",
userId: "u1",
scenicId: 11,
storeId: 22
),
applicationSupportDirectory: root.appendingPathComponent("ApplicationSupport", isDirectory: true),
cachesDirectory: root.appendingPathComponent("Caches", isDirectory: true)
)
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"
AppStore.shared.accountType = "photog"
AppStore.shared.currentScenicId = 11
AppStore.shared.currentStoreId = 22
}
private func waitUntil(
timeout: TimeInterval = 1.0,
condition: @escaping @MainActor () -> Bool
) async {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if await condition() { return }
try? await Task.sleep(nanoseconds: 20_000_000)
}
}
/// 线
@MainActor
private final class MockWiredCameraConnectionManager: WiredCameraConnectionManaging {
weak var delegate: ConnectionManagerDelegate?
var state: ConnectionState
var currentDriver: CameraDriver?
var isContentCatalogReady: Bool
private(set) var suspendLiveTransferCallCount = 0
init(
state: ConnectionState = .connected(platform: .canon, deviceName: "Canon"),
driver: CameraDriver?,
isContentCatalogReady: Bool = true
) {
self.state = state
self.currentDriver = driver
self.isContentCatalogReady = isContentCatalogReady
}
func configureLiveTransfer(albumID: Int?) {}
func start() {}
func unbindDelegate() {}
func suspendLiveTransfer() {
suspendLiveTransferCallCount += 1
}
func disconnect() {
state = .disconnected
}
}
/// Driver
private final class MockCameraDriver: CameraDriver {
let platform: CameraPlatform
let deviceInfo: CameraDeviceInfo
private let objects: [CameraObject]
private let failingObjectIds: Set<String>
private(set) var listCallCount = 0
private(set) var downloadedObjectIds: [String] = []
init(
platform: CameraPlatform = .canon,
objects: [CameraObject],
failingObjectIds: Set<String> = []
) {
self.platform = platform
self.deviceInfo = CameraDeviceInfo(
name: "Canon",
manufacturer: "Canon",
model: "Mock",
serialNumber: nil,
usbVendorID: 0x04A9,
usbProductID: 1
)
self.objects = objects
self.failingObjectIds = failingObjectIds
}
func connect() async throws {}
func disconnect() {}
func listObjects() async throws -> [CameraObject] {
listCallCount += 1
return objects
}
func requestThumbnailData(for object: CameraObject, maxPixelSize: Int) async -> Data? {
nil
}
func downloadObject(_ object: CameraObject, to directory: URL) async throws -> URL {
downloadedObjectIds.append(object.id)
if failingObjectIds.contains(object.id) {
throw CameraError.connectionFailed("下载失败")
}
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = directory.appendingPathComponent(object.filename)
try Data([1, 2, 3]).write(to: url, options: .atomic)
return url
}
}
/// OTG
@MainActor
private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading {
private(set) var uploadCallCount = 0
private(set) var uploadedRecordIds: [String] = []
func upload(
record: TravelAlbumOTGPhotoRecord,
scenicId: Int,
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)")
}
}
/// API
@MainActor
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<TravelAlbum>()
var createResponse = TravelAlbumCreateResponse(id: 0)
var infoResponse = TravelAlbum()
var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = []
var uploadMaterialResponse = TravelAlbumMaterial()
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 uploadMaterialRequests: [TravelAlbumUploadMaterialRequest] = []
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<TravelAlbum> {
listResponse
}
func info(id: Int) async throws -> TravelAlbum {
infoResponse
}
func materialList(
userEquityTravelId: Int,
page: Int,
pageSize: Int,
orderBy: Int,
isPurchased: Int?
) async throws -> TravelAlbumListResponse<TravelAlbumMaterial> {
materialRequests.append(
MaterialRequest(
userEquityTravelId: userEquityTravelId,
page: page,
pageSize: pageSize,
orderBy: orderBy,
isPurchased: isPurchased
)
)
if materialListResponses.isEmpty { return TravelAlbumListResponse() }
return materialListResponses.removeFirst()
}
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial {
uploadMaterialRequests.append(request)
return uploadMaterialResponse
}
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
}
}