将 otg_swift OTG 引擎迁入 Core/CameraOTG,并接入旅拍有线传图与历史导入。

恢复 USB 相机连接、边拍边传、三品牌驱动与 SwiftUI 历史导入页,通过适配层对接现有本地上传与 OSS 管道。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 11:22:07 +08:00
parent d7fec35715
commit be9c844bd3
50 changed files with 5721 additions and 20 deletions

View File

@ -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
}
}