将 otg_swift OTG 引擎迁入 Core/CameraOTG,并接入旅拍有线传图与历史导入。
恢复 USB 相机连接、边拍边传、三品牌驱动与 SwiftUI 历史导入页,通过适配层对接现有本地上传与 OSS 管道。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -0,0 +1,224 @@
|
||||
//
|
||||
// CameraHistoryImportViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 相机历史导入 ViewModel 测试。
|
||||
final class CameraHistoryImportViewModelTests: XCTestCase {
|
||||
/// 测试按日分组与今天/昨天标题。
|
||||
func testLoadPhotosGroupsByDay() async throws {
|
||||
let calendar = Calendar.current
|
||||
let today = calendar.startOfDay(for: Date())
|
||||
let yesterday = calendar.date(byAdding: .day, value: -1, to: today)!
|
||||
|
||||
let driver = MockHistoryImportCameraDriver()
|
||||
driver.objects = [
|
||||
makeObject(id: "today-1", filename: "today1.JPG", day: today, hour: 10),
|
||||
makeObject(id: "today-2", filename: "today2.JPG", day: today, hour: 9),
|
||||
makeObject(id: "yesterday-1", filename: "yesterday1.JPG", day: yesterday, hour: 15),
|
||||
]
|
||||
|
||||
let sink = MockHistoryImportPhotoSink()
|
||||
let viewModel = CameraHistoryImportViewModel(
|
||||
albumId: 1,
|
||||
accountKey: "test",
|
||||
driver: driver,
|
||||
photoSink: sink,
|
||||
onImported: {}
|
||||
)
|
||||
|
||||
viewModel.loadPhotos()
|
||||
try await waitUntilLoaded(viewModel)
|
||||
|
||||
guard case .loaded = viewModel.state else {
|
||||
return XCTFail("expected loaded state, got \(viewModel.state)")
|
||||
}
|
||||
XCTAssertEqual(viewModel.sections.count, 2)
|
||||
XCTAssertEqual(viewModel.sections[0].title, "今天")
|
||||
XCTAssertEqual(viewModel.sections[0].photos.map(\.id), ["today-1", "today-2"])
|
||||
XCTAssertEqual(viewModel.sections[1].title, "昨天")
|
||||
}
|
||||
|
||||
/// 测试已导入照片不可选。
|
||||
func testAlreadyImportedPhotoIsNotSelectable() async throws {
|
||||
let driver = MockHistoryImportCameraDriver()
|
||||
driver.objects = [makeObject(id: "dup|1024", filename: "dup.JPG", day: Date(), hour: 12)]
|
||||
|
||||
let sink = MockHistoryImportPhotoSink()
|
||||
sink.existingIDs = ["dup|1024"]
|
||||
|
||||
let viewModel = CameraHistoryImportViewModel(
|
||||
albumId: 1,
|
||||
accountKey: "test",
|
||||
driver: driver,
|
||||
photoSink: sink,
|
||||
onImported: {}
|
||||
)
|
||||
|
||||
viewModel.loadPhotos()
|
||||
try await waitUntilLoaded(viewModel)
|
||||
|
||||
XCTAssertTrue(viewModel.isPhotoAlreadyInAlbum(id: "dup|1024"))
|
||||
viewModel.togglePhoto(id: "dup|1024")
|
||||
XCTAssertTrue(viewModel.selectedPhotoIDs.isEmpty)
|
||||
}
|
||||
|
||||
/// 测试分组全选与取消全选。
|
||||
func testToggleSectionSelectsImportablePhotosOnly() async throws {
|
||||
let day = Calendar.current.startOfDay(for: Date())
|
||||
let driver = MockHistoryImportCameraDriver()
|
||||
driver.objects = [
|
||||
makeObject(id: "a|100", filename: "a.JPG", day: day, hour: 10),
|
||||
makeObject(id: "b|200", filename: "b.JPG", day: day, hour: 9),
|
||||
]
|
||||
|
||||
let sink = MockHistoryImportPhotoSink()
|
||||
sink.existingIDs = ["b|200"]
|
||||
|
||||
let viewModel = CameraHistoryImportViewModel(
|
||||
albumId: 1,
|
||||
accountKey: "test",
|
||||
driver: driver,
|
||||
photoSink: sink,
|
||||
onImported: {}
|
||||
)
|
||||
|
||||
viewModel.loadPhotos()
|
||||
try await waitUntilLoaded(viewModel)
|
||||
|
||||
let section = try XCTUnwrap(viewModel.sections.first)
|
||||
viewModel.toggleSection(section)
|
||||
XCTAssertEqual(viewModel.selectedPhotoIDs, ["a|100"])
|
||||
|
||||
viewModel.toggleSection(section)
|
||||
XCTAssertTrue(viewModel.selectedPhotoIDs.isEmpty)
|
||||
}
|
||||
|
||||
/// 测试 Sony catalog 为空时显示 MTP 帮助入口。
|
||||
func testSonyEmptyCatalogShowsMTPHelp() async throws {
|
||||
let driver = MockHistoryImportCameraDriver()
|
||||
driver.platform = .sony
|
||||
driver.objects = []
|
||||
|
||||
let viewModel = CameraHistoryImportViewModel(
|
||||
albumId: 1,
|
||||
accountKey: "test",
|
||||
driver: driver,
|
||||
photoSink: MockHistoryImportPhotoSink(),
|
||||
onImported: {}
|
||||
)
|
||||
|
||||
viewModel.loadPhotos()
|
||||
try await waitForFailedState(viewModel)
|
||||
|
||||
XCTAssertTrue(viewModel.shouldShowSonyMTPHelp)
|
||||
}
|
||||
|
||||
private func makeObject(id: String, filename: String, day: Date, hour: Int) -> CameraObject {
|
||||
let capturedAt = Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day
|
||||
return CameraObject(id: id, filename: filename, fileSize: 1024, capturedAt: capturedAt)
|
||||
}
|
||||
|
||||
private func waitUntilLoaded(
|
||||
_ viewModel: CameraHistoryImportViewModel,
|
||||
timeout: TimeInterval = 2
|
||||
) async throws {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if case .loaded = viewModel.state { return }
|
||||
if case .failed = viewModel.state {
|
||||
XCTFail("unexpected failed: \(viewModel.state)")
|
||||
return
|
||||
}
|
||||
try await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
XCTFail("timed out waiting for loaded state")
|
||||
}
|
||||
|
||||
private func waitForFailedState(
|
||||
_ viewModel: CameraHistoryImportViewModel,
|
||||
timeout: TimeInterval = 2
|
||||
) async throws {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if case .failed = viewModel.state { return }
|
||||
try await Task.sleep(nanoseconds: 50_000_000)
|
||||
}
|
||||
XCTFail("timed out waiting for failed state")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MockHistoryImportCameraDriver: NSObject, CameraDriver {
|
||||
var platform: CameraPlatform = .canon
|
||||
var deviceInfo = CameraDeviceInfo(
|
||||
name: "Mock Camera",
|
||||
manufacturer: nil,
|
||||
model: nil,
|
||||
serialNumber: nil,
|
||||
usbVendorID: nil,
|
||||
usbProductID: nil
|
||||
)
|
||||
var objects: [CameraObject] = []
|
||||
|
||||
func connect() async throws {}
|
||||
func disconnect() {}
|
||||
|
||||
func listObjects() async throws -> [CameraObject] {
|
||||
objects
|
||||
}
|
||||
|
||||
func requestThumbnailData(for object: CameraObject, maxPixelSize: Int) async -> Data? {
|
||||
nil
|
||||
}
|
||||
|
||||
func downloadObject(_ object: CameraObject, to directory: URL) async throws -> URL {
|
||||
let url = directory.appendingPathComponent(object.filename)
|
||||
try Data(repeating: 0x01, count: 16).write(to: url)
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MockHistoryImportPhotoSink: OTGPhotoSink {
|
||||
var existingIDs: Set<String> = []
|
||||
private(set) var savedCount = 0
|
||||
|
||||
func saveLiveShot(
|
||||
data: Data,
|
||||
filename: String,
|
||||
capturedAt: Date,
|
||||
cameraObjectID: String
|
||||
) async throws -> SavedOTGPhoto {
|
||||
savedCount += 1
|
||||
return SavedOTGPhoto(
|
||||
assetID: cameraObjectID,
|
||||
relativeLocalPath: "mock/\(filename)",
|
||||
filename: filename,
|
||||
capturedAt: capturedAt
|
||||
)
|
||||
}
|
||||
|
||||
func saveDownloadedFile(
|
||||
at url: URL,
|
||||
filename: String,
|
||||
capturedAt: Date,
|
||||
cameraObjectID: String
|
||||
) async throws -> SavedOTGPhoto {
|
||||
savedCount += 1
|
||||
return SavedOTGPhoto(
|
||||
assetID: cameraObjectID,
|
||||
relativeLocalPath: "mock/\(filename)",
|
||||
filename: filename,
|
||||
capturedAt: capturedAt
|
||||
)
|
||||
}
|
||||
|
||||
func existingCameraObjectIDs() async -> Set<String> {
|
||||
existingIDs
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user