移除 Core 层 OTG 相机实现,保留旅拍有线传图 UI 与本地照片上传能力。

将本地存储与上传编排下沉到 Features/TravelAlbum,删除 USB/PTP 相关代码与测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 10:55:40 +08:00
parent a0549e068a
commit d7fec35715
42 changed files with 187 additions and 5278 deletions

View File

@ -1,19 +0,0 @@
//
// CameraBrandDetectionTests.swift
// suixinkanTests
//
import ImageCaptureCore
import XCTest
@testable import suixinkan
/// USB Vendor ID
final class CameraBrandDetectionTests: XCTestCase {
func testSonyVendorID() {
XCTAssertEqual(CameraUSBVendorID.sony, 0x054C)
}
func testCanonVendorID() {
XCTAssertEqual(CameraUSBVendorID.canon, 0x04A9)
}
}

View File

@ -1,84 +0,0 @@
//
// CameraDownloadStorageTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
/// CameraDownloadStorage
final class CameraDownloadStorageTests: XCTestCase {
/// scoped
func testRelativePathUsesScopedOriginalsPrefix() {
let scope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603)
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
let fileURL = CameraDownloadStorage.originalsDirectory(for: scope).appendingPathComponent("1730_test.JPG")
XCTAssertEqual(
CameraDownloadStorage.relativePath(for: fileURL),
"CameraDownloads/user_101/6603/originals/1730_test.JPG"
)
}
///
func testResolveLocalURLFromRelativePath() {
let stored = "CameraDownloads/user_101/6603/originals/1730_test.JPG"
let resolved = CameraDownloadStorage.resolveLocalURL(from: stored)
let expected = CameraDownloadStorage.documentsDirectory.appendingPathComponent(stored)
XCTAssertEqual(resolved, expected)
}
///
func testScopedDirectoriesDoNotCollideAcrossAccountOrAlbum() {
let accountScope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603)
let otherAccountScope = CameraDownloadStorage.Scope(accountKey: "user_102", albumID: 6603)
let otherAlbumScope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6604)
defer {
CameraDownloadStorage.removeScopeDirectory(for: accountScope)
CameraDownloadStorage.removeScopeDirectory(for: otherAccountScope)
CameraDownloadStorage.removeScopeDirectory(for: otherAlbumScope)
}
XCTAssertNotEqual(
CameraDownloadStorage.originalsDirectory(for: accountScope).path,
CameraDownloadStorage.originalsDirectory(for: otherAccountScope).path
)
XCTAssertNotEqual(
CameraDownloadStorage.originalsDirectory(for: accountScope).path,
CameraDownloadStorage.originalsDirectory(for: otherAlbumScope).path
)
}
/// scoped
func testRemoveScopeDirectoryOnlyDeletesTargetScope() throws {
let scope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603)
let otherScope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6604)
defer {
CameraDownloadStorage.removeScopeDirectory(for: scope)
CameraDownloadStorage.removeScopeDirectory(for: otherScope)
}
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "target.jpg", scope: scope)
let otherFileURL = CameraDownloadStorage.uniqueLocalURL(for: "other.jpg", scope: otherScope)
try Data("target".utf8).write(to: fileURL)
try Data("other".utf8).write(to: otherFileURL)
CameraDownloadStorage.removeScopeDirectory(for: scope)
XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: otherFileURL.path))
}
/// URL
func testPreviewURLStringFromRelativePath() throws {
let scope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603)
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "preview.jpg", scope: scope)
try Data("jpeg".utf8).write(to: fileURL)
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
let stored = CameraDownloadStorage.relativePath(for: fileURL)
let preview = CameraDownloadStorage.previewURLString(from: stored)
XCTAssertFalse(preview.isEmpty)
XCTAssertTrue(preview.hasPrefix("file://"))
}
}

View File

@ -1,34 +0,0 @@
//
// CameraServiceFactoryTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
/// CameraServiceFactory
@MainActor
final class CameraServiceFactoryTests: XCTestCase {
func testMakeSonyReturnsSonyService() {
let service = CameraServiceFactory.make(brand: .sony)
XCTAssertTrue(service is SonyCameraService)
XCTAssertEqual(service.brand, .sony)
}
func testMakeCanonReturnsCanonService() {
let service = CameraServiceFactory.make(brand: .canon)
XCTAssertTrue(service is CanonCameraService)
XCTAssertEqual(service.brand, .canon)
}
func testMakeAutoDetectReturnsAutoDetectService() {
let service = CameraServiceFactory.makeAutoDetect()
XCTAssertTrue(service is AutoDetectCameraService)
}
func testMakeAutoDetectReturnsSharedInstance() {
let first = CameraServiceFactory.makeAutoDetect()
let second = CameraServiceFactory.makeAutoDetect()
XCTAssertTrue(first === second)
}
}

View File

@ -1,24 +0,0 @@
//
// CameraTetheringSessionTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
/// CameraTetheringSession detach/shutdown
@MainActor
final class CameraTetheringSessionTests: XCTestCase {
func testSharedServiceIsAutoDetectInstance() {
XCTAssertTrue(CameraTetheringSession.sharedService is AutoDetectCameraService)
}
func testDetachFromUIClearsCallbacksWithoutChangingConnectionState() {
let service = AutoDetectCameraService()
service.onConnectionStateChange = { _ in }
service.onNewAsset = { _ in }
service.detachFromUI()
XCTAssertNil(service.onConnectionStateChange)
XCTAssertNil(service.onNewAsset)
}
}

View File

@ -1,85 +0,0 @@
//
// CanonPTPCommandsTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
/// Canon PTP
final class CanonPTPCommandsTests: XCTestCase {
/// SetRemoteMode opcode
func testBuildSetRemoteModeCommand() {
let data = PTPHelper.buildCommand(
code: CanonPTPCommand.eosSetRemoteMode,
transactionID: 1,
parameters: [1]
)
XCTAssertEqual(data.count, 16)
XCTAssertEqual(data[6], 0x14)
XCTAssertEqual(data[7], 0x91)
}
/// ObjectAddedEx (0xC181)
func testParseObjectAddedExEvent() {
var blob = Data()
let recordSize: UInt32 = 8 + 40
blob.append(contentsOf: withUnsafeBytes(of: recordSize.littleEndian) { Data($0) })
blob.append(contentsOf: withUnsafeBytes(of: CanonPTPCommand.eosObjectAddedEx.littleEndian) { Data($0) })
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0x1234).littleEndian) { Data($0) }) // handle
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0x00010001).littleEndian) { Data($0) }) // storage
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0x3801).littleEndian) { Data($0) }) // ofc
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) }) // flags
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) }) // parent
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) }) // reserved
blob.append(contentsOf: withUnsafeBytes(of: UInt32(1024).littleEndian) { Data($0) }) // size
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) }) // reserved
let filename = "IMG_0001.JPG"
blob.append(filename.data(using: .ascii)!)
blob.append(0)
let events = CanonEosEventParser.parse(blob)
XCTAssertEqual(events.count, 1)
guard case .objectAdded(let added) = events[0] else {
return XCTFail("expected objectAdded")
}
XCTAssertEqual(added.objectHandle, 0x1234)
XCTAssertEqual(added.filename, "IMG_0001.JPG")
XCTAssertEqual(added.size, 1024)
}
/// ObjectAddedEx2 (0xC1A7) size
func testParseObjectAddedEx2Event() {
var blob = Data()
let recordSize: UInt32 = 8 + 48
blob.append(contentsOf: withUnsafeBytes(of: recordSize.littleEndian) { Data($0) })
blob.append(contentsOf: withUnsafeBytes(of: CanonPTPCommand.eosObjectAddedEx2.littleEndian) { Data($0) })
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0x5678).littleEndian) { Data($0) })
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0x00010001).littleEndian) { Data($0) })
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0x3801).littleEndian) { Data($0) })
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) })
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) })
blob.append(contentsOf: withUnsafeBytes(of: UInt32(512).littleEndian) { Data($0) }) // sizeLow
blob.append(contentsOf: withUnsafeBytes(of: UInt32(1).littleEndian) { Data($0) }) // sizeHigh
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) })
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) })
blob.append("LARGE.JPG".data(using: .ascii)!)
blob.append(0)
let events = CanonEosEventParser.parse(blob)
XCTAssertEqual(events.count, 1)
guard case .objectAdded(let added) = events[0] else {
return XCTFail("expected objectAdded")
}
XCTAssertEqual(added.objectHandle, 0x5678)
XCTAssertEqual(added.size, 512 | (1 << 32))
}
///
func testSupportedFilenameExtensions() {
XCTAssertTrue(CanonPTPHelper.isSupportedFilename("photo.JPG"))
XCTAssertTrue(CanonPTPHelper.isSupportedFilename("photo.cr2"))
XCTAssertFalse(CanonPTPHelper.isSupportedFilename("photo.tif"))
}
}

View File

@ -1,25 +0,0 @@
//
// USBDeviceBrowserControllerTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
/// USB teardown
final class USBDeviceBrowserControllerTests: XCTestCase {
/// stop stopped 便
func testStopResetsAuthorizationAndStoppedFlag() {
let controller = USBDeviceBrowserController()
controller.stop()
XCTAssertTrue(controller.isStoppedForTesting)
XCTAssertFalse(controller.isAuthorizationGrantedForTesting)
}
/// re-enumerate
func testReconnectHintMarksAndExpires() {
USBCameraReconnectHint.markDisconnected()
XCTAssertTrue(USBCameraReconnectHint.shouldReEnumerate)
}
}

View File

@ -1,374 +0,0 @@
//
// CameraTransferPipelineTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/29.
//
import XCTest
@testable import suixinkan
@MainActor
/// 使 Mock Sink
final class CameraTransferPipelineTests: XCTestCase {
/// Sink
func testNewAssetTriggersUploadSink() async {
let camera = MockCameraService()
let sink = MockUploadSink()
let pipeline = CameraTransferPipeline(cameraService: camera)
let scope = makeScope("new_asset")
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope)
var latestTasks: [CameraTransferTask] = []
attachPipeline(pipeline, onTasksUpdated: { latestTasks = $0 })
let asset = CameraAsset(id: "ptp_1", filename: "DSC_001.JPG", fileSize: 1024)
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("DSC_001.JPG")
try? Data(repeating: 0xFF, count: 8).write(to: tempURL)
camera.onNewAsset?(asset)
try? await Task.sleep(nanoseconds: 300_000_000)
XCTAssertEqual(sink.uploadCount, 1)
let savedTask = latestTasks.first { $0.assetID == asset.id }
XCTAssertEqual(
savedTask?.localPath?.hasPrefix("CameraDownloads/test_account/\(scope.albumID)/originals/"),
true
)
XCTAssertEqual(savedTask?.localPath?.hasPrefix("/"), false)
if let localURL = savedTask?.localURL {
try? FileManager.default.removeItem(at: localURL)
}
try? FileManager.default.removeItem(at: tempURL)
}
///
func testRetryUploadRehydratesFromRelativePath() async throws {
let camera = MockCameraService()
let sink = MockUploadSink()
let pipeline = CameraTransferPipeline(cameraService: camera)
let scope = makeScope("retry")
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
pipeline.configure(uploadSink: sink, uploadEnabled: false, downloadScope: scope)
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "retry_pipeline.JPG", scope: scope)
try Data(repeating: 0xAB, count: 16).write(to: fileURL)
defer { try? FileManager.default.removeItem(at: fileURL) }
let relativePath = CameraDownloadStorage.relativePath(for: fileURL)
await pipeline.retryUpload(
assetID: "asset_retry_001",
filename: "retry_pipeline.JPG",
localPath: relativePath
)
XCTAssertEqual(sink.uploadCount, 1)
XCTAssertEqual(pipeline.task(forAssetID: "asset_retry_001")?.status, .uploaded)
}
///
func testManualUploadAssetsUsesConcurrentLimit() async throws {
let camera = MockCameraService()
let sink = MockSlowUploadSink()
let pipeline = CameraTransferPipeline(cameraService: camera)
let scope = makeScope("manual")
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope)
camera.listedAssets = (1 ... 6).map { index in
CameraAsset(id: "manual_\(index)", filename: "MANUAL_\(index).JPG", fileSize: 1024)
}
await pipeline.syncExistingPhotos()
await pipeline.uploadAssets(withIDs: camera.listedAssets.map(\.id))
XCTAssertEqual(sink.uploadCount, 6)
XCTAssertGreaterThan(sink.maxActiveUploads, 1)
XCTAssertLessThanOrEqual(sink.maxActiveUploads, 3)
}
///
func testSwitchingToLiveCaptureDoesNotUploadPostShootDownloads() async throws {
let camera = MockCameraService()
let sink = MockUploadSink()
let pipeline = CameraTransferPipeline(cameraService: camera)
let scope = makeScope("mode_switch")
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
pipeline.configure(uploadSink: sink, uploadEnabled: false, downloadScope: scope)
attachPipeline(pipeline)
camera.onNewAsset?(CameraAsset(id: "post_001", filename: "POST_001.JPG", fileSize: 1024))
try await Task.sleep(nanoseconds: 400_000_000)
XCTAssertEqual(sink.uploadedFileNames, [])
XCTAssertEqual(pipeline.task(forAssetID: "post_001")?.status, .downloaded)
pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope)
camera.onNewAsset?(CameraAsset(id: "live_001", filename: "LIVE_001.JPG", fileSize: 1024))
try await Task.sleep(nanoseconds: 500_000_000)
XCTAssertEqual(sink.uploadedFileNames, ["LIVE_001.JPG"])
XCTAssertEqual(pipeline.task(forAssetID: "post_001")?.status, .downloaded)
XCTAssertEqual(pipeline.task(forAssetID: "live_001")?.status, .uploaded)
}
///
func testSyncExistingPhotosDoesNotAutoUploadInLiveCaptureMode() async throws {
let camera = MockCameraService()
let sink = MockUploadSink()
let pipeline = CameraTransferPipeline(cameraService: camera)
let scope = makeScope("sync_existing")
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope)
camera.listedAssets = [
CameraAsset(id: "history_001", filename: "HISTORY_001.JPG", fileSize: 1024)
]
await pipeline.syncExistingPhotos()
try await Task.sleep(nanoseconds: 200_000_000)
XCTAssertEqual(sink.uploadedFileNames, [])
XCTAssertEqual(pipeline.task(forAssetID: "history_001")?.status, .downloaded)
}
///
func testBurstCaptureUploadsAllAssetsWhileFirstUploadInProgress() async throws {
let camera = MockBurstCameraService()
let sink = MockSlowUploadSink()
let pipeline = CameraTransferPipeline(cameraService: camera)
let scope = makeScope("burst")
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope)
attachPipeline(pipeline)
for index in 1 ... 10 {
let filename = "DSC_\(index).JPG"
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
try Data(repeating: UInt8(index), count: 8).write(to: tempURL)
camera.downloadURLs[filename] = tempURL
camera.onNewAsset?(
CameraAsset(id: "ptp_\(index)", filename: filename, fileSize: 1024)
)
}
try await Task.sleep(nanoseconds: 2_000_000_000)
XCTAssertEqual(sink.uploadCount, 10)
XCTAssertGreaterThan(sink.maxActiveUploads, 1)
XCTAssertLessThanOrEqual(sink.maxActiveUploads, 3)
for index in 1 ... 10 {
XCTAssertEqual(pipeline.task(forAssetID: "ptp_\(index)")?.status, .uploaded)
}
}
/// SwiftUI
func testUploadProgressNotificationsAreThrottled() async throws {
let camera = MockCameraService()
let sink = MockChattyUploadSink()
let pipeline = CameraTransferPipeline(cameraService: camera)
let scope = makeScope("chatty")
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope)
var updateCount = 0
attachPipeline(pipeline, onTasksUpdated: { _ in updateCount += 1 })
camera.onNewAsset?(CameraAsset(id: "ptp_chatty", filename: "chatty.JPG", fileSize: 1024))
try await Task.sleep(nanoseconds: 800_000_000)
XCTAssertEqual(pipeline.task(forAssetID: "ptp_chatty")?.status, .uploaded)
XCTAssertLessThan(updateCount, 18)
}
/// shutdown
func testShutdownCallsCameraService() async {
let camera = DisconnectTrackingMockCameraService()
let pipeline = CameraTransferPipeline(cameraService: camera)
await pipeline.shutdown()
XCTAssertEqual(camera.shutdownCount, 1)
}
/// detachFromUI shutdown
func testDetachFromUIDoesNotShutdownCameraService() async {
let camera = DisconnectTrackingMockCameraService()
let pipeline = CameraTransferPipeline(cameraService: camera)
attachPipeline(pipeline)
pipeline.detachFromUI()
XCTAssertEqual(camera.detachCount, 1)
XCTAssertEqual(camera.shutdownCount, 0)
}
/// notify asset
func testRemovedAssetStaysExcludedWhenNewAssetArrives() async throws {
let camera = MockCameraService()
let pipeline = CameraTransferPipeline(cameraService: camera)
let scope = makeScope("removed_asset")
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
pipeline.configure(uploadSink: nil, uploadEnabled: false, downloadScope: scope)
attachPipeline(pipeline)
let oldURL = FileManager.default.temporaryDirectory.appendingPathComponent("OLD.JPG")
let newURL = FileManager.default.temporaryDirectory.appendingPathComponent("NEW.JPG")
try Data(repeating: 0x01, count: 8).write(to: oldURL)
try Data(repeating: 0x02, count: 8).write(to: newURL)
defer {
try? FileManager.default.removeItem(at: oldURL)
try? FileManager.default.removeItem(at: newURL)
}
camera.onNewAsset?(CameraAsset(id: "ptp_old", filename: "OLD.JPG", fileSize: 8))
try await Task.sleep(nanoseconds: 300_000_000)
pipeline.removeAsset(assetID: "ptp_old")
XCTAssertNil(pipeline.task(forAssetID: "ptp_old"))
camera.onNewAsset?(CameraAsset(id: "ptp_new", filename: "NEW.JPG", fileSize: 8))
try await Task.sleep(nanoseconds: 300_000_000)
XCTAssertNil(pipeline.task(forAssetID: "ptp_old"))
XCTAssertNotNil(pipeline.task(forAssetID: "ptp_new"))
XCTAssertTrue(pipeline.excludedAssetIDs.contains("ptp_old"))
}
private func attachPipeline(
_ pipeline: CameraTransferPipeline,
onTasksUpdated: @escaping ([CameraTransferTask]) -> Void = { _ in }
) {
pipeline.attachToUI(onConnectionStateChange: { _ in }, onTasksUpdated: onTasksUpdated)
}
private func makeScope(_ albumName: String) -> CameraDownloadStorage.Scope {
let albumID = albumName.unicodeScalars.reduce(0) { partial, scalar in
(partial + Int(scalar.value)) % 10_000
}
return CameraDownloadStorage.Scope(accountKey: "test_account", albumID: albumID)
}
}
@MainActor
private final class DisconnectTrackingMockCameraService: CameraServiceProtocol {
let brand: CameraBrand = .sony
var connectionState: CameraConnectionState = .disconnected
var onConnectionStateChange: ((CameraConnectionState) -> Void)?
var onNewAsset: ((CameraAsset) -> Void)?
private(set) var detachCount = 0
private(set) var shutdownCount = 0
func connect() async {}
func detachFromUI() { detachCount += 1 }
func shutdown() async { shutdownCount += 1 }
func listAssets() async throws -> [CameraAsset] { [] }
func downloadAsset(_ asset: CameraAsset) async throws -> URL {
FileManager.default.temporaryDirectory.appendingPathComponent(asset.filename)
}
}
@MainActor
private final class MockCameraService: CameraServiceProtocol {
let brand: CameraBrand = .sony
var connectionState: CameraConnectionState = .connected(deviceName: "Mock")
var onConnectionStateChange: ((CameraConnectionState) -> Void)?
var onNewAsset: ((CameraAsset) -> Void)?
var listedAssets: [CameraAsset] = []
func connect() async {}
func detachFromUI() {}
func shutdown() async {}
func listAssets() async throws -> [CameraAsset] {
listedAssets
}
func downloadAsset(_ asset: CameraAsset) async throws -> URL {
let url = FileManager.default.temporaryDirectory.appendingPathComponent(asset.filename)
if !FileManager.default.fileExists(atPath: url.path) {
try Data(repeating: 0xFF, count: 8).write(to: url)
}
return url
}
}
@MainActor
private final class MockBurstCameraService: CameraServiceProtocol {
let brand: CameraBrand = .sony
var connectionState: CameraConnectionState = .connected(deviceName: "Mock")
var onConnectionStateChange: ((CameraConnectionState) -> Void)?
var onNewAsset: ((CameraAsset) -> Void)?
var downloadURLs: [String: URL] = [:]
func connect() async {}
func detachFromUI() {}
func shutdown() async {}
func listAssets() async throws -> [CameraAsset] { [] }
func downloadAsset(_ asset: CameraAsset) async throws -> URL {
try await Task.sleep(nanoseconds: 30_000_000)
if let url = downloadURLs[asset.filename] {
return url
}
let url = FileManager.default.temporaryDirectory.appendingPathComponent(asset.filename)
if !FileManager.default.fileExists(atPath: url.path) {
try Data(repeating: 0xFF, count: 8).write(to: url)
}
return url
}
}
@MainActor
private final class MockSlowUploadSink: CameraAssetUploadSink {
var uploadCount = 0
var activeUploads = 0
var maxActiveUploads = 0
func upload(
localURL: URL,
fileName: String,
fileType: Int,
progress: @escaping @Sendable (Int) -> Void
) async throws -> String {
uploadCount += 1
activeUploads += 1
maxActiveUploads = max(maxActiveUploads, activeUploads)
try await Task.sleep(nanoseconds: 150_000_000)
activeUploads -= 1
progress(100)
return "https://cdn/mock/\(fileName)"
}
}
@MainActor
private final class MockUploadSink: CameraAssetUploadSink {
var uploadCount = 0
var uploadedFileNames: [String] = []
func upload(
localURL: URL,
fileName: String,
fileType: Int,
progress: @escaping @Sendable (Int) -> Void
) async throws -> String {
uploadCount += 1
uploadedFileNames.append(fileName)
progress(100)
return "https://cdn/mock/\(fileName)"
}
}
@MainActor
private final class MockChattyUploadSink: CameraAssetUploadSink {
func upload(
localURL: URL,
fileName: String,
fileType: Int,
progress: @escaping @Sendable (Int) -> Void
) async throws -> String {
for value in 0 ... 100 {
progress(value)
}
return "https://cdn/mock/\(fileName)"
}
}

View File

@ -1,63 +0,0 @@
//
// SonyPTPCommandsTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/29.
//
import XCTest
@testable import suixinkan
/// Sony PTP
final class SonyPTPCommandsTests: XCTestCase {
/// OpenSession opcode
func testBuildOpenSessionCommand() {
let data = SonyPTPHelper.buildCommand(
code: SonyPTPCommand.openSession,
transactionID: 1,
parameters: [1]
)
XCTAssertEqual(data.count, 16)
XCTAssertEqual(data[6], 0x02)
XCTAssertEqual(data[7], 0x10)
}
/// SDIO Connect opcode
func testBuildSonyConnectCommand() {
let data = SonyPTPHelper.buildCommand(
code: SonyPTPCommand.sdioConnect,
transactionID: 42,
parameters: [1, 0, 0]
)
XCTAssertEqual(data.count, 24)
XCTAssertEqual(data[6], 0x01)
XCTAssertEqual(data[7], 0x92)
}
/// GetObject
func testParseObjectCompressedSize() {
var data = Data(repeating: 0, count: 52)
data[8] = 0x00
data[9] = 0x10
data[10] = 0x00
data[11] = 0x00
XCTAssertEqual(SonyPTPHelper.parseObjectCompressedSize(data), 0x1000)
}
/// SDIE ObjectAdded
func testParseObjectAddedEvent() throws {
var event = Data()
event.append(contentsOf: [0x10, 0x00, 0x00, 0x00])
event.append(contentsOf: [0x04, 0x00])
event.append(contentsOf: [0x01, 0xC2])
event.append(contentsOf: [0xFF, 0xFF, 0xFF, 0xFF])
event.append(contentsOf: [0x01, 0xC0, 0xFF, 0xFF])
let container = try SonyPTPHelper.parseContainer(event)
XCTAssertEqual(container.code, SonyPTPCommand.sdieObjectAdded)
XCTAssertEqual(container.params.first, SonyPTPCommand.shotObjectHandle)
}
}

View File

@ -0,0 +1,76 @@
//
// CameraDownloadStorageTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
/// CameraDownloadStorage
final class CameraDownloadStorageTests: XCTestCase {
/// scoped
func testRelativePathUsesScopedOriginalsPrefix() {
let scope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603)
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
let fileURL = CameraDownloadStorage.originalsDirectory(for: scope).appendingPathComponent("1730_test.JPG")
XCTAssertEqual(
CameraDownloadStorage.relativePath(for: fileURL),
"CameraDownloads/user_101/6603/originals/1730_test.JPG"
)
}
///
func testResolveLocalURLFromRelativePath() {
let stored = "CameraDownloads/user_101/6603/originals/1730_test.JPG"
let resolved = CameraDownloadStorage.resolveLocalURL(from: stored)
let expected = CameraDownloadStorage.documentsDirectory.appendingPathComponent(stored)
XCTAssertEqual(resolved, expected)
}
/// scoped
func testScopeDirectoryPath() {
let scope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603)
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
let directory = CameraDownloadStorage.scopeDirectory(for: scope)
XCTAssertTrue(directory.path.hasSuffix("CameraDownloads/user_101/6603"))
}
/// URL
func testUniqueLocalURLUsesScopedOriginalsDirectory() {
let scope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603)
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
let url = CameraDownloadStorage.uniqueLocalURL(for: "DSC.JPG", scope: scope)
XCTAssertTrue(url.path.contains("/CameraDownloads/user_101/6603/originals/"))
XCTAssertTrue(url.lastPathComponent.hasSuffix("_DSC.JPG"))
}
/// scoped
func testRemoveScopeDirectory() throws {
let scope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603)
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "remove_test.JPG", scope: scope)
try Data(repeating: 0xFF, count: 8).write(to: fileURL)
let directory = CameraDownloadStorage.scopeDirectory(for: scope)
XCTAssertTrue(FileManager.default.fileExists(atPath: directory.path))
CameraDownloadStorage.removeScopeDirectory(for: scope)
XCTAssertFalse(FileManager.default.fileExists(atPath: directory.path))
}
/// URL
func testPreviewURLStringFromExistingFile() throws {
let scope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603)
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "preview.JPG", scope: scope)
try Data(repeating: 0xFF, count: 8).write(to: fileURL)
let stored = CameraDownloadStorage.relativePath(for: fileURL)
let preview = CameraDownloadStorage.previewURLString(from: stored)
XCTAssertFalse(preview.isEmpty)
XCTAssertTrue(preview.hasPrefix("file://"))
}
}

View File

@ -12,102 +12,15 @@ import UIKit
@MainActor
/// 线 ViewModel
final class WiredCameraTransferViewModelTests: XCTestCase {
///
func testFirstNewPipelineTaskAppearsInPhotos() async throws {
let camera = MockWiredCameraService()
/// start detach UI
func testStartCancellationDetachesFromUI() async {
let context = WiredTransferContext(
albumId: 6603,
albumName: "测试相册",
phone: "",
orderNumber: ""
)
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera)
let account = AccountContext()
account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试"))
await viewModel.start(
api: MockTravelAlbumAPIForWiredTransfer(),
ossService: WiredTransferMockOSSUploadService(),
scenicID: 1,
accountContext: account
)
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("DSC_NEW.JPG")
try Data(repeating: 0xFF, count: 16).write(to: tempURL)
defer { try? FileManager.default.removeItem(at: tempURL) }
let asset = CameraAsset(id: "ptp_new_001", filename: "DSC_NEW.JPG", fileSize: 16)
camera.downloadURL = tempURL
camera.onNewAsset?(asset)
try await Task.sleep(nanoseconds: 500_000_000)
XCTAssertEqual(viewModel.photos.count, 1)
XCTAssertEqual(viewModel.photos.first?.id, "ptp_new_001")
XCTAssertEqual(viewModel.photos.first?.fileName, "DSC_NEW.JPG")
}
///
func testDeletedPendingPhotoDoesNotReappearAfterLiveCapture() async throws {
let camera = MockWiredCameraService()
let context = WiredTransferContext(
albumId: 7701,
albumName: "删除回归测试",
phone: "",
orderNumber: ""
)
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera)
let account = AccountContext()
account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试"))
await viewModel.start(
api: MockTravelAlbumAPIForWiredTransfer(),
ossService: WiredTransferMockOSSUploadService(),
scenicID: 1,
accountContext: account
)
let oldURL = FileManager.default.temporaryDirectory.appendingPathComponent("OLD_DELETE.JPG")
try Data(repeating: 0x11, count: 16).write(to: oldURL)
defer { try? FileManager.default.removeItem(at: oldURL) }
camera.downloadURL = oldURL
camera.onNewAsset?(CameraAsset(id: "ptp_delete_me", filename: "OLD_DELETE.JPG", fileSize: 16))
try await Task.sleep(nanoseconds: 500_000_000)
XCTAssertEqual(viewModel.photos.count, 1)
viewModel.deletePhoto(id: "ptp_delete_me")
XCTAssertTrue(viewModel.photos.isEmpty)
viewModel.selectTransferMode(
WiredCameraTransferViewModel.modeLiveCapture,
api: MockTravelAlbumAPIForWiredTransfer(),
ossService: WiredTransferMockOSSUploadService(),
scenicID: 1
)
let newURL = FileManager.default.temporaryDirectory.appendingPathComponent("NEW_LIVE.JPG")
try Data(repeating: 0x22, count: 16).write(to: newURL)
defer { try? FileManager.default.removeItem(at: newURL) }
camera.downloadURL = newURL
camera.onNewAsset?(CameraAsset(id: "ptp_live_new", filename: "NEW_LIVE.JPG", fileSize: 16))
try await Task.sleep(nanoseconds: 500_000_000)
XCTAssertEqual(viewModel.photos.count, 1)
XCTAssertEqual(viewModel.photos.first?.id, "ptp_live_new")
XCTAssertFalse(viewModel.photos.contains { $0.id == "ptp_delete_me" })
}
/// start detach UI shutdown
func testStartCancellationDetachesWithoutShutdown() async {
let camera = DisconnectTrackingWiredCameraService()
let context = WiredTransferContext(
albumId: 6603,
albumName: "测试相册",
phone: "",
orderNumber: ""
)
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera)
let viewModel = WiredCameraTransferViewModel(context: context)
let account = AccountContext()
account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试"))
@ -120,54 +33,11 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
)
}
for _ in 0 ..< 100 {
if camera.connectCount > 0 { break }
try? await Task.sleep(nanoseconds: 10_000_000)
}
try? await Task.sleep(nanoseconds: 100_000_000)
startTask.cancel()
try? await Task.sleep(nanoseconds: 300_000_000)
try? await Task.sleep(nanoseconds: 200_000_000)
XCTAssertGreaterThanOrEqual(camera.detachCount, 1)
XCTAssertEqual(camera.shutdownCount, 0)
}
///
func testPhotoBoundToOtherAlbumIsFilteredOut() async throws {
let bindingStore = WiredTransferPhotoStore(
accountPrefixProvider: { "user_101" },
userIDProvider: { "101" }
)
bindingStore.bindPhotoToAlbum(photoID: "ptp_other_album", albumID: 99)
defer { bindingStore.bindPhotoToAlbum(photoID: "ptp_other_album", albumID: 1) }
let camera = MockWiredCameraService()
let context = WiredTransferContext(
albumId: 1,
albumName: "当前相册",
phone: "",
orderNumber: ""
)
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera)
let account = AccountContext()
account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试"), accountType: "user")
await viewModel.start(
api: MockTravelAlbumAPIForWiredTransfer(),
ossService: WiredTransferMockOSSUploadService(),
scenicID: 1,
accountContext: account
)
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("DSC_OTHER.JPG")
try Data(repeating: 0xAA, count: 8).write(to: tempURL)
defer { try? FileManager.default.removeItem(at: tempURL) }
camera.downloadURL = tempURL
camera.onNewAsset?(CameraAsset(id: "ptp_other_album", filename: "DSC_OTHER.JPG", fileSize: 8))
try await Task.sleep(nanoseconds: 500_000_000)
XCTAssertTrue(viewModel.photos.isEmpty)
XCTAssertEqual(viewModel.connectionState, .disconnected)
}
///
@ -209,8 +79,7 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
let today = formatter.string(from: Date())
let viewModel = WiredCameraTransferViewModel(
context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: ""),
cameraService: MockWiredCameraService()
context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: "")
)
viewModel.replacePhotos([
makePendingPhoto(id: "today", capturedAt: today),
@ -226,111 +95,8 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
XCTAssertNil(viewModel.errorMessage)
}
/// capturedAt
func testPipelineProgressUpdatePreservesCapturedAt() async throws {
let camera = MockWiredCameraService()
let context = WiredTransferContext(
albumId: 1,
albumName: "测试相册",
phone: "",
orderNumber: ""
)
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera)
let account = AccountContext()
account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试"))
await viewModel.start(
api: MockTravelAlbumAPIForWiredTransfer(),
ossService: WiredTransferMockOSSUploadService(),
scenicID: 1,
accountContext: account
)
let originalCapturedAt = "2026-05-20 12:05:18"
viewModel.replacePhotos([
WiredTransferPhotoItem(
id: "ptp_progress",
fileName: "DSC_PROGRESS.JPG",
thumbnailURL: "",
previewURL: "",
capturedAt: originalCapturedAt,
fileSizeText: "2 MB",
status: .uploading,
progress: 10
),
])
let scope = downloadScope(account: account, albumID: context.albumId)
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "DSC_PROGRESS.JPG", scope: scope)
try Data(repeating: 0xAB, count: 16).write(to: fileURL)
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
camera.downloadURL = fileURL
camera.onNewAsset?(CameraAsset(id: "ptp_progress", filename: "DSC_PROGRESS.JPG", fileSize: 16))
try await Task.sleep(nanoseconds: 300_000_000)
XCTAssertEqual(viewModel.photos.first?.capturedAt, originalCapturedAt)
}
///
func testNewerCapturedPhotoAppearsFirst() async throws {
let camera = MockWiredCameraService()
let context = WiredTransferContext(
albumId: 6604,
albumName: "测试相册",
phone: "",
orderNumber: ""
)
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera)
let account = AccountContext()
account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试"))
await viewModel.start(
api: MockTravelAlbumAPIForWiredTransfer(),
ossService: WiredTransferMockOSSUploadService(),
scenicID: 1,
accountContext: account
)
let fileURL = FileManager.default.temporaryDirectory
let olderURL = fileURL.appendingPathComponent("OLDER.JPG")
let newerURL = fileURL.appendingPathComponent("NEWER.JPG")
try Data(repeating: 0xAA, count: 8).write(to: olderURL)
try Data(repeating: 0xBB, count: 8).write(to: newerURL)
defer {
try? FileManager.default.removeItem(at: olderURL)
try? FileManager.default.removeItem(at: newerURL)
}
camera.downloadURL = olderURL
camera.onNewAsset?(
CameraAsset(
id: "ptp_old",
filename: "OLDER.JPG",
fileSize: 8,
creationDate: Date(timeIntervalSince1970: 1_700_000_000)
)
)
try await Task.sleep(nanoseconds: 400_000_000)
camera.downloadURL = newerURL
camera.onNewAsset?(
CameraAsset(
id: "ptp_new",
filename: "NEWER.JPG",
fileSize: 8,
creationDate: Date(timeIntervalSince1970: 1_800_000_000)
)
)
try await Task.sleep(nanoseconds: 400_000_000)
XCTAssertEqual(viewModel.photos.count, 2)
XCTAssertEqual(viewModel.photos[0].id, "ptp_new")
XCTAssertEqual(viewModel.photos[1].id, "ptp_old")
}
///
func testRetryPhotoInPostShootModeUploadsPersistedFile() async throws {
let camera = MockWiredCameraService()
let context = WiredTransferContext(
albumId: 1,
albumName: "测试相册",
@ -368,7 +134,7 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
)
store.save(albumID: 1, records: [record])
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera)
let viewModel = WiredCameraTransferViewModel(context: context)
viewModel.transferModeOption = WiredCameraTransferViewModel.modePostShootTransfer
let api = MockTravelAlbumAPIForWiredTransfer()
@ -418,8 +184,7 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
store.save(albumID: albumID, records: records)
let viewModel = WiredCameraTransferViewModel(
context: WiredTransferContext(albumId: albumID, albumName: "批量上传", phone: "", orderNumber: ""),
cameraService: MockWiredCameraService()
context: WiredTransferContext(albumId: albumID, albumName: "批量上传", phone: "", orderNumber: "")
)
let oss = WiredTransferMockOSSUploadService()
oss.uploadDelayNanoseconds = 150_000_000
@ -447,32 +212,36 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
///
func testSelectTransferModePersistsOption() async {
let account = makeTransferModeTestAccount()
let context = WiredTransferContext(albumId: 7701, albumName: "模式缓存", phone: "", orderNumber: "")
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: MockWiredCameraService())
let api = MockTravelAlbumAPIForWiredTransfer()
let oss = WiredTransferMockOSSUploadService()
let store = makeTransferModeTestStore(account: account)
let viewModel = WiredCameraTransferViewModel(
context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: "")
)
await viewModel.start(
api: MockTravelAlbumAPIForWiredTransfer(),
ossService: WiredTransferMockOSSUploadService(),
scenicID: 1,
accountContext: account
)
await viewModel.start(api: api, ossService: oss, scenicID: 1, accountContext: account)
viewModel.selectTransferMode(
WiredCameraTransferViewModel.modePostShootTransfer,
api: api,
ossService: oss,
api: MockTravelAlbumAPIForWiredTransfer(),
ossService: WiredTransferMockOSSUploadService(),
scenicID: 1
)
let store = makeTransferModeTestStore(account: account)
XCTAssertEqual(store.loadTransferModeOption(), WiredCameraTransferViewModel.modePostShootTransfer)
}
/// 线
///
func testStartRestoresCachedTransferModeOption() async {
let account = makeTransferModeTestAccount()
let store = makeTransferModeTestStore(account: account)
store.saveTransferModeOption(WiredCameraTransferViewModel.modePostShootTransfer)
let context = WiredTransferContext(albumId: 7702, albumName: "模式恢复", phone: "", orderNumber: "")
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: MockWiredCameraService())
let viewModel = WiredCameraTransferViewModel(
context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: "")
)
await viewModel.start(
api: MockTravelAlbumAPIForWiredTransfer(),
ossService: WiredTransferMockOSSUploadService(),
@ -483,37 +252,6 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.transferModeOption, WiredCameraTransferViewModel.modePostShootTransfer)
}
///
func testDownloadedPhotoGeneratesThumbnailForList() async throws {
let camera = MockWiredCameraService()
let albumID = 6601
let context = WiredTransferContext(albumId: albumID, albumName: "缩略图相册", phone: "", orderNumber: "")
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera)
let account = AccountContext()
account.applyLogin(profile: AccountProfile(userId: "thumb-user", displayName: "测试"))
await viewModel.start(
api: MockTravelAlbumAPIForWiredTransfer(),
ossService: WiredTransferMockOSSUploadService(),
scenicID: 1,
accountContext: account
)
let scope = downloadScope(account: account, albumID: albumID)
let imageURL = CameraDownloadStorage.uniqueLocalURL(for: "thumb_source.JPG", scope: scope)
try makeJPEGData().write(to: imageURL)
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
camera.downloadURL = imageURL
camera.onNewAsset?(CameraAsset(id: "thumb_asset_001", filename: "thumb_source.JPG", fileSize: 128))
try await Task.sleep(nanoseconds: 900_000_000)
let photo = try XCTUnwrap(viewModel.photos.first { $0.id == "thumb_asset_001" })
XCTAssertTrue(photo.thumbnailURL.contains("/CameraDownloads/\(scope.accountDirectoryName)/\(albumID)/thumbnails/"))
XCTAssertTrue(photo.previewURL.contains("/CameraDownloads/"))
XCTAssertFalse(photo.previewURL.contains("/thumbnails/"))
}
///
func testLegacyRecordWithoutThumbnailKeepsPreviewURL() async throws {
let albumID = 6602
@ -547,8 +285,7 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
store.save(albumID: albumID, records: [record])
let viewModel = WiredCameraTransferViewModel(
context: WiredTransferContext(albumId: albumID, albumName: "旧记录", phone: "", orderNumber: ""),
cameraService: MockWiredCameraService()
context: WiredTransferContext(albumId: albumID, albumName: "旧记录", phone: "", orderNumber: "")
)
await viewModel.start(
api: MockTravelAlbumAPIForWiredTransfer(),
@ -578,10 +315,10 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
CameraDownloadStorage.removeScopeDirectory(for: otherScope)
}
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "clear_target.JPG", scope: scope)
let otherFileURL = CameraDownloadStorage.uniqueLocalURL(for: "clear_other.JPG", scope: otherScope)
try Data(repeating: 0x01, count: 8).write(to: fileURL)
try Data(repeating: 0x02, count: 8).write(to: otherFileURL)
let storeURL = CameraDownloadStorage.uniqueLocalURL(for: "store_photo.JPG", scope: scope)
let otherURL = CameraDownloadStorage.uniqueLocalURL(for: "other_photo.JPG", scope: otherScope)
try makeJPEGData().write(to: storeURL)
try makeJPEGData().write(to: otherURL)
let store = WiredTransferPhotoStore(
accountPrefixProvider: { account.accountCachePrefix ?? "guest_" },
@ -589,40 +326,29 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
)
store.save(albumID: albumID, records: [
makePhotoRecord(
id: "clear_target",
fileName: "clear_target.JPG",
localPath: CameraDownloadStorage.relativePath(for: fileURL),
id: "store_photo",
fileName: "store_photo.JPG",
localPath: CameraDownloadStorage.relativePath(for: storeURL),
albumID: albumID,
userID: userID
)
])
store.save(albumID: otherAlbumID, records: [
makePhotoRecord(
id: "clear_other",
fileName: "clear_other.JPG",
localPath: CameraDownloadStorage.relativePath(for: otherFileURL),
albumID: otherAlbumID,
userID: userID
)
])
store.clearAlbum(albumID: albumID)
let deletedIDs = store.clearAlbum(albumID: albumID)
XCTAssertEqual(deletedIDs, Set(["clear_target"]))
XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: scopeDirectoryPath))
XCTAssertTrue(FileManager.default.fileExists(atPath: otherFileURL.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: otherScopeDirectoryPath))
}
/// ID 线
///
func testSameUserIDDifferentAccountTypesAreIsolated() throws {
let userID = "multi-account-user"
let albumID = 8803
let userID = "same-user-id"
let albumID = 9901
let storeAccount = AccountContext()
storeAccount.applyLogin(profile: AccountProfile(userId: userID, displayName: "门店"), accountType: "store")
let photographerAccount = AccountContext()
photographerAccount.applyLogin(profile: AccountProfile(userId: userID, displayName: "摄影师"), accountType: "photographer")
let storeScope = downloadScope(account: storeAccount, albumID: albumID)
let photographerScope = downloadScope(account: photographerAccount, albumID: albumID)
defer {
@ -632,10 +358,10 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
let storeURL = CameraDownloadStorage.uniqueLocalURL(for: "store_photo.JPG", scope: storeScope)
let photographerURL = CameraDownloadStorage.uniqueLocalURL(for: "photographer_photo.JPG", scope: photographerScope)
try Data(repeating: 0x03, count: 8).write(to: storeURL)
try Data(repeating: 0x04, count: 8).write(to: photographerURL)
try makeJPEGData().write(to: storeURL)
try makeJPEGData().write(to: photographerURL)
let store = WiredTransferPhotoStore(
let storeStore = WiredTransferPhotoStore(
accountPrefixProvider: { storeAccount.accountCachePrefix ?? "guest_" },
userIDProvider: { userID }
)
@ -643,7 +369,7 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
accountPrefixProvider: { photographerAccount.accountCachePrefix ?? "guest_" },
userIDProvider: { userID }
)
store.save(albumID: albumID, records: [
storeStore.save(albumID: albumID, records: [
makePhotoRecord(
id: "store_photo",
fileName: "store_photo.JPG",
@ -662,7 +388,7 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
)
])
XCTAssertEqual(store.load(albumID: albumID).map(\.id), ["store_photo"])
XCTAssertEqual(storeStore.load(albumID: albumID).map(\.id), ["store_photo"])
XCTAssertEqual(photographerStore.load(albumID: albumID).map(\.id), ["photographer_photo"])
XCTAssertNotEqual(
CameraDownloadStorage.scopeDirectory(for: storeScope).path,
@ -672,8 +398,7 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
private func makeViewModelWithPendingPhotos() -> WiredCameraTransferViewModel {
let viewModel = WiredCameraTransferViewModel(
context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: ""),
cameraService: MockWiredCameraService()
context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: "")
)
viewModel.replacePhotos([
makePendingPhoto(id: "pending-1", capturedAt: "2026-05-20 12:05:18"),
@ -741,63 +466,6 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
}
}
@MainActor
private final class DisconnectTrackingWiredCameraService: CameraServiceProtocol {
let brand: CameraBrand = .sony
var connectionState: CameraConnectionState = .disconnected
var onConnectionStateChange: ((CameraConnectionState) -> Void)?
var onNewAsset: ((CameraAsset) -> Void)?
private(set) var connectCount = 0
private(set) var detachCount = 0
private(set) var shutdownCount = 0
func connect() async {
connectCount += 1
connectionState = .connected(deviceName: "Mock")
onConnectionStateChange?(.connected(deviceName: "Mock"))
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 50_000_000)
}
}
func detachFromUI() { detachCount += 1 }
func shutdown() async { shutdownCount += 1 }
func listAssets() async throws -> [CameraAsset] { [] }
func downloadAsset(_ asset: CameraAsset) async throws -> URL {
FileManager.default.temporaryDirectory.appendingPathComponent(asset.filename)
}
}
@MainActor
private final class MockWiredCameraService: CameraServiceProtocol {
let brand: CameraBrand = .sony
var connectionState: CameraConnectionState = .connected(deviceName: "Mock")
var onConnectionStateChange: ((CameraConnectionState) -> Void)?
var onNewAsset: ((CameraAsset) -> Void)?
var downloadURL: URL?
func connect() async {}
func detachFromUI() {}
func shutdown() async {}
func listAssets() async throws -> [CameraAsset] {
[]
}
func downloadAsset(_ asset: CameraAsset) async throws -> URL {
if let downloadURL {
return downloadURL
}
let url = FileManager.default.temporaryDirectory.appendingPathComponent(asset.filename)
if !FileManager.default.fileExists(atPath: url.path) {
try Data(repeating: 0xFF, count: 8).write(to: url)
}
return url
}
}
@MainActor
private final class MockTravelAlbumAPIForWiredTransfer: TravelAlbumServing {
func availableOrders() async throws -> [TravelAlbumAvailableOrder] { [] }