feat: add travel album OTG import flow
This commit is contained in:
@ -130,21 +130,400 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
|
||||
}
|
||||
|
||||
/// 有线传输 ViewModel 测试。
|
||||
@MainActor
|
||||
final class WiredCameraTransferViewModelTests: XCTestCase {
|
||||
func testDefaultDisconnectedStateDoesNotDependOnBusinessServices() {
|
||||
func testDefaultDisconnectedStateAndOptions() {
|
||||
let viewModel = WiredCameraTransferViewModel(albumId: 1, albumTitle: "相册", headerPhone: "13800138000")
|
||||
var message = ""
|
||||
viewModel.onShowMessage = { message = $0 }
|
||||
|
||||
XCTAssertEqual(viewModel.cameraStatusText, "未连接 USB")
|
||||
XCTAssertEqual(viewModel.tabCounts, [0, 0, 0])
|
||||
viewModel.showUIOnlyMessage()
|
||||
XCTAssertEqual(message, "仅同步 UI,暂未接入 OTG 传输")
|
||||
XCTAssertEqual(viewModel.photoFormatOption, .jpg)
|
||||
XCTAssertEqual(viewModel.transferModeOption, "边拍边传")
|
||||
XCTAssertTrue(viewModel.filteredPhotos().isEmpty)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 旅拍相册相机历史导入 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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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(
|
||||
context: OTGTestContext,
|
||||
manager: MockWiredCameraConnectionManager,
|
||||
uploader: MockTravelAlbumOTGUploader? = nil
|
||||
) -> WiredCameraTransferViewModel {
|
||||
prepareAppStoreForOTGTests()
|
||||
return WiredCameraTransferViewModel(
|
||||
albumId: 9,
|
||||
albumTitle: "相册",
|
||||
headerPhone: "13800138000",
|
||||
connectionManager: manager,
|
||||
storage: context.store,
|
||||
uploader: uploader ?? MockTravelAlbumOTGUploader()
|
||||
)
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
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 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
|
||||
|
||||
func upload(
|
||||
record: TravelAlbumOTGPhotoRecord,
|
||||
scenicId: Int,
|
||||
progress: @escaping (Int) -> Void
|
||||
) async throws -> TravelAlbumMaterial {
|
||||
uploadCallCount += 1
|
||||
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
|
||||
@ -159,12 +538,14 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
||||
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] = []
|
||||
|
||||
@ -207,6 +588,11 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
||||
return materialListResponses.removeFirst()
|
||||
}
|
||||
|
||||
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial {
|
||||
uploadMaterialRequests.append(request)
|
||||
return uploadMaterialResponse
|
||||
}
|
||||
|
||||
func deleteAlbum(id: Int) async throws {
|
||||
deletedAlbumIds.append(id)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user