完善 OTG 相机有线传图:支持 Sony/Canon 自动识别,页面 detach 保持会话以便再次进入复连。
引入进程级 CameraTetheringSession 与共享 USB Browser;离开传图页仅取消 UI 回调,App 终止时再完整 shutdown 释放 PTP 与 Browser 资源。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -0,0 +1,19 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
85
suixinkanTests/CameraTethering/CanonPTPCommandsTests.swift
Normal file
85
suixinkanTests/CameraTethering/CanonPTPCommandsTests.swift
Normal file
@ -0,0 +1,85 @@
|
||||
//
|
||||
// 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"))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@ -21,7 +21,7 @@ final class CameraTransferPipelineTests: XCTestCase {
|
||||
pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope)
|
||||
|
||||
var latestTasks: [CameraTransferTask] = []
|
||||
pipeline.onTasksUpdated = { latestTasks = $0 }
|
||||
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")
|
||||
@ -95,6 +95,7 @@ final class CameraTransferPipelineTests: XCTestCase {
|
||||
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)
|
||||
@ -138,6 +139,7 @@ final class CameraTransferPipelineTests: XCTestCase {
|
||||
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"
|
||||
@ -169,7 +171,7 @@ final class CameraTransferPipelineTests: XCTestCase {
|
||||
pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope)
|
||||
|
||||
var updateCount = 0
|
||||
pipeline.onTasksUpdated = { _ in updateCount += 1 }
|
||||
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)
|
||||
@ -178,6 +180,35 @@ final class CameraTransferPipelineTests: XCTestCase {
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
@ -186,6 +217,24 @@ final class CameraTransferPipelineTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
@ -195,7 +244,8 @@ private final class MockCameraService: CameraServiceProtocol {
|
||||
var listedAssets: [CameraAsset] = []
|
||||
|
||||
func connect() async {}
|
||||
func disconnect() async {}
|
||||
func detachFromUI() {}
|
||||
func shutdown() async {}
|
||||
|
||||
func listAssets() async throws -> [CameraAsset] {
|
||||
listedAssets
|
||||
@ -219,7 +269,8 @@ private final class MockBurstCameraService: CameraServiceProtocol {
|
||||
var downloadURLs: [String: URL] = [:]
|
||||
|
||||
func connect() async {}
|
||||
func disconnect() async {}
|
||||
func detachFromUI() {}
|
||||
func shutdown() async {}
|
||||
|
||||
func listAssets() async throws -> [CameraAsset] { [] }
|
||||
|
||||
|
||||
@ -46,6 +46,40 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(viewModel.photos.first?.fileName, "DSC_NEW.JPG")
|
||||
}
|
||||
|
||||
/// 测试 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 account = AccountContext()
|
||||
account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试"))
|
||||
|
||||
let startTask = Task {
|
||||
await viewModel.start(
|
||||
api: MockTravelAlbumAPIForWiredTransfer(),
|
||||
ossService: WiredTransferMockOSSUploadService(),
|
||||
scenicID: 1,
|
||||
accountContext: account
|
||||
)
|
||||
}
|
||||
|
||||
for _ in 0 ..< 100 {
|
||||
if camera.connectCount > 0 { break }
|
||||
try? await Task.sleep(nanoseconds: 10_000_000)
|
||||
}
|
||||
|
||||
startTask.cancel()
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
|
||||
XCTAssertGreaterThanOrEqual(camera.detachCount, 1)
|
||||
XCTAssertEqual(camera.shutdownCount, 0)
|
||||
}
|
||||
|
||||
/// 测试绑定到其他相册的照片不会出现在当前相册列表。
|
||||
func testPhotoBoundToOtherAlbumIsFilteredOut() async throws {
|
||||
let bindingStore = WiredTransferPhotoStore(
|
||||
@ -655,6 +689,35 @@ 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
|
||||
@ -664,7 +727,8 @@ private final class MockWiredCameraService: CameraServiceProtocol {
|
||||
var downloadURL: URL?
|
||||
|
||||
func connect() async {}
|
||||
func disconnect() async {}
|
||||
func detachFromUI() {}
|
||||
func shutdown() async {}
|
||||
|
||||
func listAssets() async throws -> [CameraAsset] {
|
||||
[]
|
||||
|
||||
Reference in New Issue
Block a user