feat: add travel album OTG import flow

This commit is contained in:
2026-07-08 09:24:51 +08:00
parent 00bda390e8
commit 92fcad7ac9
42 changed files with 6826 additions and 89 deletions

View File

@ -73,6 +73,29 @@ final class TravelAlbumAPITests: XCTestCase {
XCTAssertEqual(session.requests[2].url?.path, "/api/yf-handset-app/photog/travel-album/mp-code")
}
func testUploadMaterialBuildsPathAndBody() async throws {
let material = envelopeJSON(#"{"id":6,"user_equity_travel_id":3,"file_name":"A.JPG","file_url":"https://cdn/a.jpg"}"#)
let session = MockURLSession(responses: [material])
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
let response = try await api.uploadMaterial(
TravelAlbumUploadMaterialRequest(
userEquityTravelId: 3,
fileName: "A.JPG",
fileUrl: "https://cdn/a.jpg"
)
)
XCTAssertEqual(response.id, 6)
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/upload-material")
let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(request.httpBody)) as? [String: Any]
XCTAssertEqual(body?["user_equity_travel_id"] as? Int, 3)
XCTAssertEqual(body?["file_name"] as? String, "A.JPG")
XCTAssertEqual(body?["file_url"] as? String, "https://cdn/a.jpg")
}
private func envelopeJSON(_ dataJSON: String) -> Data {
"""
{"code":100000,"msg":"success","data":\(dataJSON)}

View File

@ -0,0 +1,87 @@
//
// TravelAlbumOTGCoreTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
/// OTG Core
final class TravelAlbumOTGCoreTests: XCTestCase {
func testPlatformDetectorPrefersVendorID() {
let sony = CameraDeviceInfo(
name: "Unknown",
manufacturer: nil,
model: nil,
serialNumber: nil,
usbVendorID: 0x054C,
usbProductID: nil
)
let canon = CameraDeviceInfo(
name: "EOS R6",
manufacturer: nil,
model: nil,
serialNumber: nil,
usbVendorID: nil,
usbProductID: nil
)
XCTAssertEqual(PlatformDetector.detect(from: sony), .sony)
XCTAssertEqual(PlatformDetector.detect(from: canon), .canon)
}
func testPTPCommandBuildAndParse() throws {
let data = PTPHelper.buildCommand(code: 0x1001, transactionID: 7, parameters: [1, 2])
let parsed = try PTPHelper.parseContainer(data)
XCTAssertEqual(parsed.length, 20)
XCTAssertEqual(parsed.type, PTPContainerType.command.rawValue)
XCTAssertEqual(parsed.code, 0x1001)
XCTAssertEqual(parsed.transactionID, 7)
XCTAssertEqual(parsed.params, [1, 2])
}
func testPhotoFormatMatcherResolvesJpegRawPeers() {
let jpeg = CameraObject(id: "J", filename: "IMG_0001.JPG", fileSize: 1, capturedAt: Date())
let raw = CameraObject(id: "R", filename: "IMG_0001.ARW", fileSize: 2, capturedAt: Date())
let other = CameraObject(id: "O", filename: "IMG_0002.MOV", fileSize: 3, capturedAt: Date())
XCTAssertEqual(TravelAlbumOTGPhotoFormatMatcher.classify(name: jpeg.filename), .jpeg)
XCTAssertEqual(TravelAlbumOTGPhotoFormatMatcher.classify(name: raw.filename), .raw)
XCTAssertEqual(TravelAlbumOTGPhotoFormatMatcher.classify(name: other.filename, isVideo: true), .other)
let jpgTargets = TravelAlbumOTGPhotoFormatMatcher.resolveUploadTargets(
requested: [raw],
allObjects: [jpeg, raw, other],
option: .jpg
)
let pairTargets = TravelAlbumOTGPhotoFormatMatcher.resolveUploadTargets(
requested: [jpeg],
allObjects: [jpeg, raw, other],
option: .jpegRaw
)
XCTAssertEqual(jpgTargets.map(\.id), ["J"])
XCTAssertEqual(pairTargets.map(\.id), ["J", "R"])
}
func testOSSAlbumObjectKeyUsesAlbumModule() {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
let date = calendar.date(from: DateComponents(year: 2026, month: 7, day: 7))!
let uuid = UUID(uuidString: "11111111-2222-3333-4444-555555555555")!
let key = OSSUploadPolicy.objectKey(
fileName: "A/B.JPG",
scenicId: 9,
moduleType: "album",
date: date,
uuid: uuid,
timeZone: TimeZone(secondsFromGMT: 0)!
)
XCTAssertTrue(key.hasPrefix("album/20260707/9/11111111222233334444555555555555_"))
XCTAssertTrue(key.hasSuffix("A_B.JPG"))
}
}

View File

@ -0,0 +1,109 @@
//
// TravelAlbumOTGStorageTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
/// OTG
final class TravelAlbumOTGStorageTests: XCTestCase {
func testStoragePathContainsAccountScenicStoreAndAlbum() throws {
let root = try makeTempDirectory()
defer { try? FileManager.default.removeItem(at: root) }
let store = makeStore(root: root)
let originals = try store.originalsDirectory(albumId: 33)
let previews = try store.previewsDirectory(albumId: 33)
XCTAssertTrue(originals.path.contains("u1_photog/scenic_11/store_22/album_33/originals"))
XCTAssertTrue(previews.path.contains("u1_photog/scenic_11/store_22/album_33/previews"))
}
func testUniqueOriginalFileURLAvoidsOverwrite() throws {
let root = try makeTempDirectory()
defer { try? FileManager.default.removeItem(at: root) }
let store = makeStore(root: root)
let first = try store.uniqueOriginalFileURL(filename: "IMG/1.JPG", albumId: 33)
try Data([1]).write(to: first)
let second = try store.uniqueOriginalFileURL(filename: "IMG/1.JPG", albumId: 33)
XCTAssertEqual(first.lastPathComponent, "IMG_1.JPG")
XCTAssertEqual(second.lastPathComponent, "IMG_1_1.JPG")
}
func testLoadNormalizesInterruptedTransferToPending() throws {
let root = try makeTempDirectory()
defer { try? FileManager.default.removeItem(at: root) }
let store = makeStore(root: root)
let file = try store.uniqueOriginalFileURL(filename: "A.JPG", albumId: 33)
try Data([1, 2, 3]).write(to: file)
let record = TravelAlbumOTGPhotoRecord(
id: "1",
fileName: "A.JPG",
localPath: file.path,
capturedAt: "2026-07-07 10:00:00",
fileSizeBytes: 3,
status: .uploading,
progress: 50,
albumId: 33,
userId: "u1"
)
store.save([record], albumId: 33)
let loaded = store.load(albumId: 33)
XCTAssertEqual(loaded.first?.status, .pending)
XCTAssertEqual(loaded.first?.progress, 0)
}
func testRemoveAndClearAlbumDeleteFiles() throws {
let root = try makeTempDirectory()
defer { try? FileManager.default.removeItem(at: root) }
let store = makeStore(root: root)
let file = try store.uniqueOriginalFileURL(filename: "A.JPG", albumId: 33)
try Data([1]).write(to: file)
let record = TravelAlbumOTGPhotoRecord(
id: "1",
fileName: "A.JPG",
localPath: file.path,
capturedAt: "2026-07-07 10:00:00",
fileSizeBytes: 1,
status: .pending,
albumId: 33,
userId: "u1"
)
store.save([record], albumId: 33)
store.remove(albumId: 33, photoId: "1")
XCTAssertFalse(FileManager.default.fileExists(atPath: file.path))
XCTAssertTrue(store.load(albumId: 33).isEmpty)
let another = try store.uniqueOriginalFileURL(filename: "B.JPG", albumId: 33)
try Data([2]).write(to: another)
store.clearAlbum(albumId: 33)
XCTAssertFalse(FileManager.default.fileExists(atPath: another.deletingLastPathComponent().deletingLastPathComponent().path))
}
private func makeStore(root: URL) -> TravelAlbumOTGPhotoStore {
TravelAlbumOTGPhotoStore(
context: TravelAlbumOTGStorageContext(
accountCachePrefix: "u1_photog",
userId: "u1",
scenicId: 11,
storeId: 22
),
applicationSupportDirectory: root.appendingPathComponent("ApplicationSupport", isDirectory: true),
cachesDirectory: root.appendingPathComponent("Caches", isDirectory: true)
)
}
private func makeTempDirectory() throws -> URL {
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
}

View File

@ -130,21 +130,400 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
}
/// 线 ViewModel
@MainActor
final class WiredCameraTransferViewModelTests: XCTestCase {
func testDefaultDisconnectedStateDoesNotDependOnBusinessServices() {
func testDefaultDisconnectedStateAndOptions() {
let viewModel = WiredCameraTransferViewModel(albumId: 1, albumTitle: "相册", headerPhone: "13800138000")
var message = ""
viewModel.onShowMessage = { message = $0 }
XCTAssertEqual(viewModel.cameraStatusText, "未连接 USB")
XCTAssertEqual(viewModel.tabCounts, [0, 0, 0])
viewModel.showUIOnlyMessage()
XCTAssertEqual(message, "仅同步 UI暂未接入 OTG 传输")
XCTAssertEqual(viewModel.photoFormatOption, .jpg)
XCTAssertEqual(viewModel.transferModeOption, "边拍边传")
XCTAssertTrue(viewModel.filteredPhotos().isEmpty)
}
func testEnteringPageDoesNotListCameraObjects() async {
let context = makeOTGTestContext()
let object = CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: Date())
let driver = MockCameraDriver(objects: [object])
let manager = MockWiredCameraConnectionManager(driver: driver)
let viewModel = makeWiredViewModel(context: context, manager: manager)
viewModel.start()
try? await Task.sleep(nanoseconds: 80_000_000)
XCTAssertEqual(driver.listCallCount, 0)
XCTAssertTrue(viewModel.filteredPhotos().isEmpty)
}
func testHistoryImportRequiresConnectedCatalog() {
let context = makeOTGTestContext()
let driver = MockCameraDriver(objects: [])
let manager = MockWiredCameraConnectionManager(driver: driver, isContentCatalogReady: false)
let viewModel = makeWiredViewModel(context: context, manager: manager)
var message = ""
viewModel.onShowMessage = { message = $0 }
viewModel.start()
XCTAssertNil(viewModel.makeCameraImportViewModel())
XCTAssertEqual(message, "相机照片目录尚未就绪,请稍候再试")
}
func testHistoryImportCreatesImportViewModelWhenCatalogReady() {
let context = makeOTGTestContext()
let jpeg = CameraObject(id: "JPG|10", filename: "IMG_0001.JPG", fileSize: 10, capturedAt: Date())
let driver = MockCameraDriver(objects: [jpeg])
let manager = MockWiredCameraConnectionManager(driver: driver)
let viewModel = makeWiredViewModel(context: context, manager: manager)
viewModel.start()
XCTAssertNotNil(viewModel.makeCameraImportViewModel())
XCTAssertEqual(driver.listCallCount, 0)
}
func testHistoryImportDoesNotDownloadFromTransferPage() async {
let context = makeOTGTestContext()
let object = CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: Date())
let driver = MockCameraDriver(objects: [object])
let manager = MockWiredCameraConnectionManager(driver: driver)
let viewModel = makeWiredViewModel(context: context, manager: manager)
viewModel.start()
_ = viewModel.makeCameraImportViewModel()
try? await Task.sleep(nanoseconds: 80_000_000)
XCTAssertEqual(driver.downloadedObjectIds, [])
XCTAssertTrue(viewModel.filteredPhotos().isEmpty)
}
}
/// ViewModel
@MainActor
final class TravelAlbumCameraImportViewModelTests: XCTestCase {
func testLoadPhotosListsCameraObjectsAndGroupsByDay() async {
let context = makeOTGTestContext()
let calendar = Calendar(identifier: .gregorian)
let first = calendar.date(from: DateComponents(year: 2026, month: 7, day: 8, hour: 10))!
let second = calendar.date(from: DateComponents(year: 2026, month: 7, day: 7, hour: 10))!
let driver = MockCameraDriver(objects: [
CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: first),
CameraObject(id: "B|10", filename: "B.JPG", fileSize: 10, capturedAt: second),
])
let viewModel = makeImportViewModel(context: context, driver: driver)
viewModel.loadPhotos()
await waitUntil {
if case .loaded(let sections) = viewModel.state {
return sections.count == 2
}
return false
}
XCTAssertEqual(driver.listCallCount, 1)
if case .loaded(let sections) = viewModel.state {
XCTAssertEqual(sections.first?.photos.first?.id, "A|10")
} else {
XCTFail("Expected loaded state")
}
}
func testAlreadyImportedPhotoCannotBeSelected() async throws {
let context = makeOTGTestContext()
let object = CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: Date())
let localFile = try context.store.uniqueOriginalFileURL(filename: "A.JPG", albumId: 9)
try Data([1]).write(to: localFile)
context.store.save([
TravelAlbumOTGPhotoRecord(
id: "local-id",
sourceId: object.id,
fileName: "A.JPG",
localPath: localFile.path,
capturedAt: TravelAlbumOTGPhotoStore.transferTimeText(object.capturedAt),
fileSizeBytes: 1,
status: .pending,
albumId: 9,
userId: "u1"
)
], albumId: 9)
let viewModel = makeImportViewModel(context: context, driver: MockCameraDriver(objects: [object]))
viewModel.loadPhotos()
await waitUntil {
if case .loaded = viewModel.state { return true }
return false
}
viewModel.togglePhoto(id: object.id)
XCTAssertTrue(viewModel.isPhotoAlreadyInAlbum(id: object.id))
XCTAssertTrue(viewModel.selectedPhotoIds.isEmpty)
}
func testSelectionAndSectionToggleStates() async {
let context = makeOTGTestContext()
let photos = [
CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: Date()),
CameraObject(id: "B|10", filename: "B.JPG", fileSize: 10, capturedAt: Date()),
]
let viewModel = makeImportViewModel(context: context, driver: MockCameraDriver(objects: photos))
viewModel.loadPhotos()
await waitUntil {
if case .loaded = viewModel.state { return true }
return false
}
guard case .loaded(let sections) = viewModel.state, let section = sections.first else {
return XCTFail("Expected loaded state")
}
viewModel.togglePhoto(id: photos[0].id)
XCTAssertTrue(viewModel.isSectionPartiallySelected(section))
XCTAssertFalse(viewModel.isSectionFullySelected(section))
viewModel.toggleSection(section)
XCTAssertTrue(viewModel.isSectionFullySelected(section))
viewModel.toggleSection(section)
XCTAssertTrue(viewModel.selectedPhotoIds.isEmpty)
}
func testImportSelectedDownloadsOnlySelectedAndWritesPendingRecords() async {
let context = makeOTGTestContext()
let selected = CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: Date())
let unselected = CameraObject(id: "B|10", filename: "B.JPG", fileSize: 10, capturedAt: Date())
let driver = MockCameraDriver(objects: [selected, unselected])
let viewModel = makeImportViewModel(context: context, driver: driver)
viewModel.loadPhotos()
await waitUntil {
if case .loaded = viewModel.state { return true }
return false
}
viewModel.togglePhoto(id: selected.id)
viewModel.importSelected()
await waitUntil {
if case .finished = viewModel.state { return true }
return false
}
XCTAssertEqual(driver.downloadedObjectIds, [selected.id])
let records = context.store.load(albumId: 9)
XCTAssertEqual(records.count, 1)
XCTAssertEqual(records.first?.sourceId, selected.id)
XCTAssertEqual(records.first?.status, .pending)
}
func testImportSelectedContinuesAfterSingleFailure() async {
let context = makeOTGTestContext()
let failed = CameraObject(id: "F|10", filename: "F.JPG", fileSize: 10, capturedAt: Date())
let success = CameraObject(id: "S|10", filename: "S.JPG", fileSize: 10, capturedAt: Date())
let driver = MockCameraDriver(objects: [failed, success], failingObjectIds: [failed.id])
let viewModel = makeImportViewModel(context: context, driver: driver)
viewModel.loadPhotos()
await waitUntil {
if case .loaded(let sections) = viewModel.state {
sections.first?.photos.count == 2
} else {
false
}
}
guard case .loaded(let sections) = viewModel.state, let section = sections.first else {
return XCTFail("Expected loaded state")
}
viewModel.toggleSection(section)
viewModel.importSelected()
await waitUntil {
if case .finished = viewModel.state { return true }
return false
}
XCTAssertEqual(Set(driver.downloadedObjectIds), [failed.id, success.id])
XCTAssertTrue(viewModel.failedPhotoIds.contains(failed.id))
XCTAssertEqual(context.store.load(albumId: 9).first?.sourceId, success.id)
}
func testSonyEmptyCatalogShowsMTPHelp() async {
let context = makeOTGTestContext()
let driver = MockCameraDriver(platform: .sony, objects: [])
let viewModel = makeImportViewModel(context: context, driver: driver)
viewModel.loadPhotos()
await waitUntil {
if case .failed = viewModel.state { return true }
return false
}
XCTAssertTrue(viewModel.shouldShowSonyMTPHelp)
}
private func makeImportViewModel(
context: OTGTestContext,
driver: MockCameraDriver
) -> TravelAlbumCameraImportViewModel {
prepareAppStoreForOTGTests()
return TravelAlbumCameraImportViewModel(
albumId: 9,
albumTitle: "相册",
driver: driver,
storage: context.store
)
}
}
/// OTG
private struct OTGTestContext {
let root: URL
let store: TravelAlbumOTGPhotoStore
}
@MainActor
private func makeWiredViewModel(
context: OTGTestContext,
manager: MockWiredCameraConnectionManager,
uploader: MockTravelAlbumOTGUploader? = nil
) -> WiredCameraTransferViewModel {
prepareAppStoreForOTGTests()
return WiredCameraTransferViewModel(
albumId: 9,
albumTitle: "相册",
headerPhone: "13800138000",
connectionManager: manager,
storage: context.store,
uploader: uploader ?? MockTravelAlbumOTGUploader()
)
}
@MainActor
private func makeOTGTestContext() -> OTGTestContext {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let store = TravelAlbumOTGPhotoStore(
context: TravelAlbumOTGStorageContext(
accountCachePrefix: "u1_photog",
userId: "u1",
scenicId: 11,
storeId: 22
),
applicationSupportDirectory: root.appendingPathComponent("ApplicationSupport", isDirectory: true),
cachesDirectory: root.appendingPathComponent("Caches", isDirectory: true)
)
return OTGTestContext(root: root, store: store)
}
@MainActor
private func prepareAppStoreForOTGTests() {
AppStore.shared.userId = "u1"
AppStore.shared.accountType = "photog"
AppStore.shared.currentScenicId = 11
AppStore.shared.currentStoreId = 22
}
private func waitUntil(
timeout: TimeInterval = 1.0,
condition: @escaping @MainActor () -> Bool
) async {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if await condition() { return }
try? await Task.sleep(nanoseconds: 20_000_000)
}
}
/// 线
@MainActor
private final class MockWiredCameraConnectionManager: WiredCameraConnectionManaging {
weak var delegate: ConnectionManagerDelegate?
var state: ConnectionState
var currentDriver: CameraDriver?
var isContentCatalogReady: Bool
init(
state: ConnectionState = .connected(platform: .canon, deviceName: "Canon"),
driver: CameraDriver?,
isContentCatalogReady: Bool = true
) {
self.state = state
self.currentDriver = driver
self.isContentCatalogReady = isContentCatalogReady
}
func configureLiveTransfer(albumID: Int?) {}
func start() {}
func unbindDelegate() {}
func disconnect() {
state = .disconnected
}
}
/// Driver
private final class MockCameraDriver: CameraDriver {
let platform: CameraPlatform
let deviceInfo: CameraDeviceInfo
private let objects: [CameraObject]
private let failingObjectIds: Set<String>
private(set) var listCallCount = 0
private(set) var downloadedObjectIds: [String] = []
init(
platform: CameraPlatform = .canon,
objects: [CameraObject],
failingObjectIds: Set<String> = []
) {
self.platform = platform
self.deviceInfo = CameraDeviceInfo(
name: "Canon",
manufacturer: "Canon",
model: "Mock",
serialNumber: nil,
usbVendorID: 0x04A9,
usbProductID: 1
)
self.objects = objects
self.failingObjectIds = failingObjectIds
}
func connect() async throws {}
func disconnect() {}
func listObjects() async throws -> [CameraObject] {
listCallCount += 1
return objects
}
func requestThumbnailData(for object: CameraObject, maxPixelSize: Int) async -> Data? {
nil
}
func downloadObject(_ object: CameraObject, to directory: URL) async throws -> URL {
downloadedObjectIds.append(object.id)
if failingObjectIds.contains(object.id) {
throw CameraError.connectionFailed("下载失败")
}
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = directory.appendingPathComponent(object.filename)
try Data([1, 2, 3]).write(to: url, options: .atomic)
return url
}
}
/// OTG
@MainActor
private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading {
private(set) var uploadCallCount = 0
func upload(
record: TravelAlbumOTGPhotoRecord,
scenicId: Int,
progress: @escaping (Int) -> Void
) async throws -> TravelAlbumMaterial {
uploadCallCount += 1
return TravelAlbumMaterial(id: 1, fileName: record.fileName, fileUrl: "https://cdn.example.com/\(record.fileName)")
}
}
/// API
@MainActor
final class TravelAlbumMockAPI: TravelAlbumServing {
struct MaterialRequest: Equatable {
let userEquityTravelId: Int
@ -159,12 +538,14 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
var createResponse = TravelAlbumCreateResponse(id: 0)
var infoResponse = TravelAlbum()
var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = []
var uploadMaterialResponse = TravelAlbumMaterial()
var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "")
var createError: Error?
private(set) var availableOrdersCallCount = 0
private(set) var createRequests: [TravelAlbumCreateRequest] = []
private(set) var materialRequests: [MaterialRequest] = []
private(set) var uploadMaterialRequests: [TravelAlbumUploadMaterialRequest] = []
private(set) var deletedAlbumIds: [Int] = []
private(set) var deletedMaterialIds: [Int] = []
@ -207,6 +588,11 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
return materialListResponses.removeFirst()
}
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial {
uploadMaterialRequests.append(request)
return uploadMaterialResponse
}
func deleteAlbum(id: Int) async throws {
deletedAlbumIds.append(id)
}