恢复 USB 相机连接、边拍边传、三品牌驱动与 SwiftUI 历史导入页,通过适配层对接现有本地上传与 OSS 管道。 Co-authored-by: Cursor <cursoragent@cursor.com>
225 lines
7.1 KiB
Swift
225 lines
7.1 KiB
Swift
//
|
|
// 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
|
|
}
|
|
}
|