feat: 新增相册自动修图与OTG状态预览

支持相册修图配置、模板选择和传输模式选择;上传后自动提交修图并展示状态角标及精修预览。补充接口文档与相关测试。
This commit is contained in:
2026-08-27 16:03:40 +08:00
parent 9fce6ef713
commit d04641b623
17 changed files with 2523 additions and 26 deletions
+720 -2
View File
@@ -3,6 +3,7 @@
// suixinkanTests
//
import UIKit
import XCTest
@testable import suixinkan
@@ -451,9 +452,72 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
}
}
/// 自动修图配置 ViewModel 测试。
@MainActor
final class TravelAlbumAutoRetouchSettingViewModelTests: XCTestCase {
func testLoadsOnlyRefinedTemplatesAndKeepsSingleSelection() async {
let api = TravelAlbumMockAPI()
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
refinedTemplates: [
TravelAlbumAIRetouchTemplate(id: 11, name: "清透", previewURL: "https://cdn/11.jpg"),
TravelAlbumAIRetouchTemplate(id: 12, name: "质感", previewURL: "https://cdn/12.jpg"),
],
atmosphereTemplates: [
TravelAlbumAIRetouchTemplate(id: 21, name: "暖阳", previewURL: "https://cdn/21.jpg"),
],
coverTemplates: []
)
let viewModel = TravelAlbumAutoRetouchSettingViewModel(
scenicId: 18,
configuration: .disabled,
startsWithModeSelection: true
)
viewModel.selectMode(enabled: true)
await viewModel.loadTemplates(api: api)
viewModel.selectTemplate(id: 11)
viewModel.selectTemplate(id: 12)
XCTAssertEqual(viewModel.templates.map(\.id), [11, 12])
XCTAssertEqual(viewModel.selectedTemplateId, 12)
XCTAssertEqual(viewModel.pendingConfiguration?.refinedTemplateId, 12)
XCTAssertEqual(api.aiRetouchTemplateScenicIds, [18])
}
func testTemplateLoadFailureKeepsConfigurationInvalidForRetry() async {
let api = TravelAlbumMockAPI()
api.aiRetouchTemplatesError = APIError.httpStatus(500, "模板服务不可用")
let viewModel = TravelAlbumAutoRetouchSettingViewModel(
scenicId: 18,
configuration: TravelAlbumAutoRetouchConfiguration(enabled: true, refinedTemplateId: 12),
startsWithModeSelection: false
)
await viewModel.loadTemplates(api: api)
XCTAssertTrue(viewModel.templates.isEmpty)
XCTAssertNil(viewModel.pendingConfiguration)
XCTAssertFalse(viewModel.errorMessage?.isEmpty ?? true)
}
}
/// 有线传输 ViewModel 测试。
@MainActor
final class WiredCameraTransferViewModelTests: XCTestCase {
func testLegacyOTGRecordDefaultsAutomaticRetouchMetadata() throws {
let data = Data(
#"{"id":"legacy","sourceId":"legacy","clientPhotoId":"client-id","fileName":"A.JPG","localPath":"originals/A.JPG","thumbnailPath":"","capturedAt":"2026-08-26 12:00:00","fileSizeBytes":1024,"status":"UPLOADED","progress":100,"albumId":9,"userId":"u1","remoteUrl":"https://cdn/A.JPG","updatedAt":1}"#.utf8
)
let record = try JSONDecoder().decode(TravelAlbumOTGPhotoRecord.self, from: data)
XCTAssertEqual(record.serverMaterialId, 0)
XCTAssertEqual(record.autoRetouchState, .none)
XCTAssertNil(record.autoRetouchTemplateId)
XCTAssertTrue(record.autoRetouchClientRequestId.isEmpty)
XCTAssertEqual(record.autoRetouchBatchId, 0)
}
func testDefaultDisconnectedStateAndOptions() {
let viewModel = WiredCameraTransferViewModel(
albumId: 1,
@@ -837,6 +901,635 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
XCTAssertEqual(api.materialClientPhotoIdsCallCount, 1)
}
func testOriginalUploadDoesNotSubmitAIRetouchWhenConfigurationIsDisabled() async throws {
let context = makeOTGTestContext()
let uploader = MockTravelAlbumOTGUploader()
let api = TravelAlbumMockAPI()
let record = try makePersistedOTGRecord(
context: context,
id: "without-retouch",
capturedAt: "2026-08-26 12:00:00",
status: .pending
)
context.store.save([record], albumId: 9)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader,
api: api,
initialAutoRetouchConfiguration: .disabled
)
viewModel.reloadLocalPhotos()
viewModel.retryPhoto(photoId: record.id)
await waitUntil { context.store.load(albumId: 9).first?.status == .uploaded }
XCTAssertEqual(uploader.uploadCallCount, 1)
XCTAssertTrue(api.aiRetouchRequests.isEmpty)
XCTAssertEqual(context.store.load(albumId: 9).first?.autoRetouchState, TravelAlbumAutoRetouchState.none)
}
func testEnabledConfigurationSubmitsOneIdempotentAIRetouchAfterOriginalUpload() async throws {
let context = makeOTGTestContext()
let uploader = MockTravelAlbumOTGUploader()
uploader.materialIds = [77]
let api = TravelAlbumMockAPI()
api.aiJobSubmission = TravelAlbumAIJobSubmission(
aiRetouchBatchId: 901,
userEquityTravelId: 9,
status: .queued,
progress: TravelAlbumAIJobProgress(
total: 1,
queued: 1,
processing: 0,
succeeded: 0,
failed: 0,
canceled: 0
),
createdAt: "2026-08-26T12:00:00Z"
)
let record = try makePersistedOTGRecord(
context: context,
id: "automatic-retouch",
capturedAt: "2026-08-26 12:00:00",
status: .pending
)
context.store.save([record], albumId: 9)
let configuration = TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 12
)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader,
api: api,
initialAutoRetouchConfiguration: configuration
)
viewModel.reloadLocalPhotos()
viewModel.retryPhoto(photoId: record.id)
await waitUntil { api.aiRetouchRequests.count == 1 }
let request = try XCTUnwrap(api.aiRetouchRequests.first)
XCTAssertEqual(uploader.uploadCallCount, 1)
XCTAssertEqual(request.materialIds, [77])
XCTAssertEqual(request.refinedTemplateId, 12)
XCTAssertEqual(request.clientRequestId, "auto-9-77-tpl12-a0")
let persisted = try XCTUnwrap(context.store.load(albumId: 9).first)
XCTAssertEqual(persisted.status, .uploaded)
XCTAssertEqual(persisted.progress, 100)
XCTAssertEqual(persisted.serverMaterialId, 77)
XCTAssertEqual(persisted.autoRetouchBatchId, 901)
XCTAssertEqual(persisted.autoRetouchState, .processing)
}
func testAIRetouchSubmissionFailureKeepsUploadAndRetryDoesNotUploadOriginalAgain() async throws {
let context = makeOTGTestContext()
let uploader = MockTravelAlbumOTGUploader()
uploader.materialIds = [88]
let api = TravelAlbumMockAPI()
api.submitAIRetouchError = APIError.serverCode(190001, "AI 修图额度不足")
let record = try makePersistedOTGRecord(
context: context,
id: "retry-retouch",
capturedAt: "2026-08-26 12:00:00",
status: .pending
)
context.store.save([record], albumId: 9)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader,
api: api,
initialAutoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 19
)
)
viewModel.reloadLocalPhotos()
viewModel.retryPhoto(photoId: record.id)
await waitUntil { context.store.load(albumId: 9).first?.autoRetouchState == .failed }
let firstRequestId = api.aiRetouchRequests.first?.clientRequestId
XCTAssertEqual(context.store.load(albumId: 9).first?.status, .uploaded)
XCTAssertEqual(context.store.load(albumId: 9).first?.progress, 100)
api.submitAIRetouchError = nil
viewModel.retryAutoRetouch(photoId: record.id)
await waitUntil { api.aiRetouchRequests.count == 2 }
XCTAssertEqual(uploader.uploadCallCount, 1)
XCTAssertEqual(api.aiRetouchRequests.last?.clientRequestId, firstRequestId)
XCTAssertEqual(context.store.load(albumId: 9).first?.status, .uploaded)
XCTAssertEqual(context.store.load(albumId: 9).first?.progress, 100)
}
func testAmbiguousNetworkFailureStaysPendingAndForegroundResumeReusesRequestId() async throws {
let context = makeOTGTestContext()
let uploader = MockTravelAlbumOTGUploader()
uploader.materialIds = [89]
let api = TravelAlbumMockAPI()
api.submitAIRetouchError = APIError.networkFailed("连接中断")
let record = try makePersistedOTGRecord(
context: context,
id: "ambiguous-retouch",
capturedAt: "2026-08-26 12:00:00",
status: .pending
)
context.store.save([record], albumId: 9)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader,
api: api,
initialAutoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 20
)
)
viewModel.reloadLocalPhotos()
viewModel.retryPhoto(photoId: record.id)
await waitUntil { context.store.load(albumId: 9).first?.autoRetouchState == .pendingSubmission }
let firstRequestId = try XCTUnwrap(api.aiRetouchRequests.first?.clientRequestId)
api.submitAIRetouchError = nil
viewModel.applicationDidBecomeActive()
await waitUntil { api.aiRetouchRequests.count == 2 }
XCTAssertEqual(uploader.uploadCallCount, 1)
XCTAssertEqual(api.aiRetouchRequests.last?.clientRequestId, firstRequestId)
XCTAssertEqual(context.store.load(albumId: 9).first?.autoRetouchState, .processing)
}
func testTerminalTaskFailureRetryCreatesNewAttemptIdentifierWithoutReupload() async throws {
let context = makeOTGTestContext()
let uploader = MockTravelAlbumOTGUploader()
uploader.materialIds = [90]
let api = TravelAlbumMockAPI()
api.aiJobSubmission = TravelAlbumAIJobSubmission(
aiRetouchBatchId: 902,
userEquityTravelId: 9,
status: .failed,
progress: TravelAlbumAIJobProgress(
total: 1,
queued: 0,
processing: 0,
succeeded: 0,
failed: 1,
canceled: 0
),
createdAt: "2026-08-26T12:00:00Z"
)
let record = try makePersistedOTGRecord(
context: context,
id: "terminal-retouch",
capturedAt: "2026-08-26 12:00:00",
status: .pending
)
context.store.save([record], albumId: 9)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader,
api: api,
initialAutoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 22
)
)
viewModel.reloadLocalPhotos()
viewModel.retryPhoto(photoId: record.id)
await waitUntil { context.store.load(albumId: 9).first?.autoRetouchState == .failed }
let firstRequestId = api.aiRetouchRequests.first?.clientRequestId
api.aiJobSubmission = TravelAlbumAIJobSubmission(
aiRetouchBatchId: 903,
userEquityTravelId: 9,
status: .queued,
progress: TravelAlbumAIJobProgress(
total: 1,
queued: 1,
processing: 0,
succeeded: 0,
failed: 0,
canceled: 0
),
createdAt: "2026-08-26T12:01:00Z"
)
viewModel.retryAutoRetouch(photoId: record.id)
await waitUntil { api.aiRetouchRequests.count == 2 }
XCTAssertEqual(uploader.uploadCallCount, 1)
XCTAssertNotEqual(api.aiRetouchRequests.last?.clientRequestId, firstRequestId)
XCTAssertEqual(api.aiRetouchRequests.last?.clientRequestId, "auto-9-90-tpl22-a1")
}
func testConfigurationChangeOnlyAffectsPhotosWhoseUploadHasNotStarted() async throws {
let context = makeOTGTestContext()
let uploader = MockTravelAlbumOTGUploader()
uploader.materialIds = [101, 102]
let api = TravelAlbumMockAPI()
let records = [
try makePersistedOTGRecord(
context: context,
id: "first-snapshot",
capturedAt: "2026-08-26 12:00:00",
status: .pending
),
try makePersistedOTGRecord(
context: context,
id: "second-snapshot",
capturedAt: "2026-08-26 12:01:00",
status: .pending
),
]
context.store.save(records, albumId: 9)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader,
api: api,
initialAutoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 11
)
)
viewModel.reloadLocalPhotos()
viewModel.retryPhoto(photoId: records[0].id)
await waitUntil { api.aiRetouchRequests.count == 1 }
api.autoRetouchConfigurationResponse = TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 12
)
await viewModel.updateAutoRetouchConfiguration(
TravelAlbumAutoRetouchConfiguration(enabled: true, refinedTemplateId: 12)
)
viewModel.retryPhoto(photoId: records[1].id)
await waitUntil { api.aiRetouchRequests.count == 2 }
XCTAssertEqual(api.aiRetouchRequests.map(\.refinedTemplateId), [11, 12])
XCTAssertEqual(api.aiRetouchRequests.map(\.clientRequestId), [
"auto-9-101-tpl11-a0",
"auto-9-102-tpl12-a0",
])
}
func testPendingAIRetouchResumesWithPersistedIdempotencyIdentifier() async {
let context = makeOTGTestContext()
let api = TravelAlbumMockAPI()
let persisted = TravelAlbumOTGPhotoRecord(
id: "resume-retouch",
fileName: "resume.JPG",
localPath: "originals/resume.JPG",
capturedAt: "2026-08-26 12:00:00",
fileSizeBytes: 1024,
status: .uploaded,
progress: 100,
albumId: 9,
userId: "u1",
remoteUrl: "https://cdn.example.com/resume.JPG",
serverMaterialId: 99,
autoRetouchState: .pendingSubmission,
autoRetouchTemplateId: 21,
autoRetouchClientRequestId: "persisted-request-id"
)
context.store.save([persisted], albumId: 9)
let uploader = MockTravelAlbumOTGUploader()
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader,
api: api
)
viewModel.reloadLocalPhotos()
viewModel.applicationDidBecomeActive()
await waitUntil { api.aiRetouchRequests.count == 1 }
XCTAssertEqual(uploader.uploadCallCount, 0)
XCTAssertEqual(api.aiRetouchRequests.first?.clientRequestId, "persisted-request-id")
XCTAssertEqual(api.aiRetouchRequests.first?.materialIds, [99])
}
func testConfigurationUpdateUsesServerNormalizedResponse() async {
let context = makeOTGTestContext()
let api = TravelAlbumMockAPI()
let serverConfiguration = TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 31
)
api.autoRetouchConfigurationResponse = serverConfiguration
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
api: api,
initialAutoRetouchConfiguration: .disabled
)
var message = ""
viewModel.onShowMessage = { message = $0 }
await viewModel.updateAutoRetouchConfiguration(
TravelAlbumAutoRetouchConfiguration(enabled: true, refinedTemplateId: 22)
)
XCTAssertEqual(api.autoRetouchConfigurationRequests.first?.refinedTemplateId, 22)
XCTAssertEqual(viewModel.autoRetouchConfiguration, serverConfiguration)
XCTAssertEqual(message, "已开启 AI 自动修图")
}
}
/// OTG 修图任务入口、只读预览路由和失败恢复测试。
@MainActor
final class WiredCameraTransferPreviewTests: XCTestCase {
func testTaskButtonUsesCompactWhiteIconAndTitleWithoutSharedBackground() throws {
let fixture = try makeFixture()
let controller = WiredCameraTransferViewController(viewModel: fixture.viewModel)
controller.setupNavigationBar()
let album = TravelAlbumDetailViewController(albumId: 9, api: fixture.api)
album.setupNavigationBar()
let expected = try XCTUnwrap(album.navigationItem.rightBarButtonItems?.first { $0.title == "修图任务" })
let item = try XCTUnwrap(controller.navigationItem.rightBarButtonItem)
let button = try XCTUnwrap(item.customView as? UIButton)
let fittingSize = button.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
XCTAssertEqual(button.configuration?.title, expected.title)
XCTAssertNotNil(button.configuration?.image)
XCTAssertEqual(button.configuration?.image?.renderingMode, .alwaysOriginal)
XCTAssertEqual(button.buttonType, .custom)
XCTAssertEqual(button.accessibilityLabel, expected.accessibilityLabel)
XCTAssertEqual(button.configuration?.baseForegroundColor, .white)
XCTAssertEqual(button.tintColor, .white)
XCTAssertEqual(button.configuration?.background.backgroundColor, .clear)
XCTAssertEqual(button.accessibilityIdentifier, "wiredTransfer.aiRetouchTasksButton")
XCTAssertEqual(fittingSize.height, 44, accuracy: 0.1)
XCTAssertGreaterThanOrEqual(fittingSize.width, 44)
XCTAssertLessThanOrEqual(fittingSize.width, 90)
if #available(iOS 26.0, *) {
XCTAssertTrue(item.hidesSharedBackground)
}
XCTAssertTrue(button.isEnabled)
}
func testTaskButtonOpensExistingJobListWithInjectedAPIAndReturnsToTransferState() async throws {
let fixture = try makeFixture()
fixture.viewModel.selectTransferMode("拍后传输")
fixture.viewModel.selectTab(.uploaded)
let controller = WiredCameraTransferViewController(viewModel: fixture.viewModel)
let navigation = UINavigationController(rootViewController: controller)
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
window.rootViewController = navigation
window.makeKeyAndVisible()
defer {
window.isHidden = true
window.rootViewController = nil
fixture.viewModel.stop()
}
controller.loadViewIfNeeded()
let button = try XCTUnwrap(controller.navigationItem.rightBarButtonItem?.customView as? UIButton)
button.sendActions(for: .touchUpInside)
let jobList = try XCTUnwrap(navigation.topViewController as? TravelAlbumAIJobListViewController)
jobList.loadViewIfNeeded()
await waitUntil { !fixture.api.aiJobListRequests.isEmpty && navigation.transitionCoordinator == nil }
XCTAssertEqual(fixture.api.aiJobListRequests.first?.statusGroup, .all)
button.sendActions(for: .touchUpInside)
XCTAssertEqual(navigation.viewControllers.count, 2)
XCTAssertTrue(navigation.topViewController === jobList)
navigation.popViewController(animated: false)
XCTAssertTrue(navigation.topViewController === controller)
XCTAssertEqual(fixture.viewModel.transferMode, .postTransfer)
XCTAssertEqual(fixture.viewModel.selectedTab, .uploaded)
XCTAssertEqual(fixture.viewModel.photos.first?.status, .uploaded)
XCTAssertEqual(fixture.viewModel.photos.first?.progress, 100)
XCTAssertEqual((controller.navigationItem.rightBarButtonItem?.customView as? UIButton)?.configuration?.title, "修图任务")
XCTAssertEqual(navigation.navigationBar.tintColor, .white)
}
func testPreviewLoadsLatestOriginalAndRefinedResultWithoutChangingUploadState() async throws {
let fixture = try makeFixture()
let project = try await fixture.viewModel.loadAutoRetouchPreviewProject(photoId: "preview")
XCTAssertEqual(fixture.api.materialInfoRequests, [.init(userEquityTravelId: 9, materialId: 77)])
XCTAssertEqual(project.originalMaterialId, 77)
XCTAssertEqual(project.aiRetouchBatchId, 901)
XCTAssertEqual(project.orderedAssets.map(\.kind), [.original, .retouched])
XCTAssertEqual(project.asset(for: .retouched)?.displayURL, fixture.api.materialInfoResponse.aiRefinedURL)
XCTAssertNotNil(project.comparisonContent(for: .retouched))
XCTAssertEqual(fixture.viewModel.photos.first?.status, .uploaded)
XCTAssertEqual(fixture.viewModel.photos.first?.progress, 100)
XCTAssertEqual(fixture.viewModel.photos.first?.autoRetouchState, .completed)
XCTAssertTrue(fixture.api.aiRetouchRequests.isEmpty)
XCTAssertTrue(fixture.api.uploadMaterialRequests.isEmpty)
}
func testPreviewRejectsMissingMaterialIdAndUnfinishedRetouchWithoutRequest() async throws {
for (state, materialId) in [(TravelAlbumAutoRetouchState.completed, 0), (.processing, 77), (.none, 77)] {
let fixture = try makeFixture(state: state, materialId: materialId)
do {
_ = try await fixture.viewModel.loadAutoRetouchPreviewProject(photoId: "preview")
XCTFail("Invalid preview should fail")
} catch {
XCTAssertEqual(error as? TravelAlbumOTGPreviewError, .materialUnavailable)
}
XCTAssertTrue(fixture.api.materialInfoRequests.isEmpty)
}
}
func testPreviewRejectsMissingRefinedResultAndPropagatesRequestFailure() async throws {
let fixture = try makeFixture()
fixture.api.materialInfoResponse = TravelAlbumMaterial(id: 77, fileUrl: "file:///original.jpg", aiRefinedURL: " ")
do {
_ = try await fixture.viewModel.loadAutoRetouchPreviewProject(photoId: "preview")
XCTFail("Missing refined result should fail")
} catch {
XCTAssertEqual(error as? TravelAlbumOTGPreviewError, .resultNotReady)
}
let failure = APIError.networkFailed("网络不可用")
fixture.api.materialInfoError = failure
do {
_ = try await fixture.viewModel.loadAutoRetouchPreviewProject(photoId: "preview")
XCTFail("Request failure should propagate")
} catch {
XCTAssertEqual(error.localizedDescription, failure.localizedDescription)
}
XCTAssertEqual(fixture.viewModel.photos.first?.status, .uploaded)
XCTAssertEqual(fixture.viewModel.photos.first?.progress, 100)
XCTAssertEqual(fixture.viewModel.photos.first?.autoRetouchState, .completed)
}
func testCompletedPhotoOpensReadOnlyRefinedPreviewAndIgnoresDuplicateTaps() async throws {
let fixture = try makeFixture()
fixture.api.materialInfoDelayNanoseconds = 100_000_000
let page = try await showPage(viewModel: fixture.viewModel)
defer { closePage(page, viewModel: fixture.viewModel) }
tapPhoto(in: page.collection)
tapPhoto(in: page.collection)
await waitUntil { fixture.api.materialInfoRequests.count == 1 }
XCTAssertTrue(GlobalLoadingManager.shared.isShowing)
await waitUntil { page.controller.presentedViewController is TravelAlbumPhotoPreviewViewController }
let preview = try XCTUnwrap(page.controller.presentedViewController as? TravelAlbumPhotoPreviewViewController)
preview.loadViewIfNeeded()
let variants = try XCTUnwrap(findView(in: preview.view, identifier: "travelAlbum.previewVariantSegmentedControl") as? UISegmentedControl)
let actions = try XCTUnwrap(findView(in: preview.view, identifier: "travelAlbum.previewActionStack"))
let comparison = try XCTUnwrap(findView(in: preview.view, identifier: "travelAlbum.previewComparisonButton"))
XCTAssertEqual(variants.numberOfSegments, 2)
XCTAssertEqual(variants.titleForSegment(at: variants.selectedSegmentIndex), "精修后")
XCTAssertTrue(actions.isHidden)
XCTAssertFalse(comparison.isHidden)
XCTAssertEqual(fixture.api.materialInfoRequests.count, 1)
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
}
func testUnfinishedFailedAndUnretouchedPhotosKeepGenericPreview() async throws {
for state in [TravelAlbumAutoRetouchState.none, .processing, .failed] {
let fixture = try makeFixture(state: state)
let page = try await showPage(viewModel: fixture.viewModel)
defer { closePage(page, viewModel: fixture.viewModel) }
tapPhoto(in: page.collection)
await waitUntil { page.controller.presentedViewController != nil }
XCTAssertTrue(page.controller.presentedViewController is MediaPreviewViewController)
XCTAssertTrue(fixture.api.materialInfoRequests.isEmpty)
}
}
func testPreviewFailuresStayOnTransferPageAndStopLoading() async throws {
for scenario in 0..<3 {
let fixture = try makeFixture(materialId: scenario == 0 ? 0 : 77)
let expectedMessage: String
switch scenario {
case 0:
expectedMessage = TravelAlbumOTGPreviewError.materialUnavailable.localizedDescription
case 1:
let error = APIError.networkFailed("预览网络不可用")
fixture.api.materialInfoError = error
expectedMessage = error.localizedDescription
default:
fixture.api.materialInfoResponse = TravelAlbumMaterial(id: 77, fileUrl: "file:///original.jpg")
expectedMessage = TravelAlbumOTGPreviewError.resultNotReady.localizedDescription
}
let page = try await showPage(viewModel: fixture.viewModel)
defer { closePage(page, viewModel: fixture.viewModel) }
tapPhoto(in: page.collection)
await waitUntil { self.containsLabel(in: page.controller.view, text: expectedMessage) }
XCTAssertNil(page.controller.presentedViewController)
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
XCTAssertTrue(containsLabel(in: page.controller.view, text: expectedMessage))
}
}
func testSelectionModeDoesNotOpenPreview() async throws {
let fixture = try makeFixture()
let page = try await showPage(viewModel: fixture.viewModel)
defer { closePage(page, viewModel: fixture.viewModel) }
fixture.viewModel.onBatchUploadButtonClick()
tapPhoto(in: page.collection)
XCTAssertNil(page.controller.presentedViewController)
XCTAssertTrue(fixture.api.materialInfoRequests.isEmpty)
}
func testLeavingTransferPageCancelsPreviewLoading() async throws {
let fixture = try makeFixture()
fixture.api.materialInfoDelayNanoseconds = 500_000_000
let page = try await showPage(viewModel: fixture.viewModel)
defer { closePage(page, viewModel: fixture.viewModel) }
tapPhoto(in: page.collection)
await waitUntil { fixture.api.materialInfoRequests.count == 1 }
page.controller.viewWillDisappear(false)
await waitUntil { !GlobalLoadingManager.shared.isShowing }
XCTAssertNil(page.controller.presentedViewController)
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
}
private func makeFixture(
state: TravelAlbumAutoRetouchState = .completed,
materialId: Int = 77
) throws -> (viewModel: WiredCameraTransferViewModel, api: TravelAlbumMockAPI) {
let context = makeOTGTestContext()
var record = try makePersistedOTGRecord(context: context, id: "preview", capturedAt: "2026-08-27 12:00:00", status: .uploaded)
record.clientPhotoId = "preview-client"
record.progress = 100
record.serverMaterialId = materialId
record.autoRetouchState = state
record.autoRetouchBatchId = 901
context.store.save([record], albumId: 9)
let api = TravelAlbumMockAPI()
api.materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: [record.clientPhotoId])
let imageURL = try XCTUnwrap(context.store.absoluteURL(for: record.localPath, albumId: 9)).absoluteString
api.materialInfoResponse = TravelAlbumMaterial(
id: 77, userEquityTravelId: 9, fileName: "preview.JPG", fileUrl: imageURL,
aiRetouchBatchId: 901, aiRefinedURL: imageURL, aiAtmosphereURL: imageURL
)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
api: api
)
viewModel.reloadLocalPhotos()
return (viewModel, api)
}
/// 测试页面与其窗口,保持预览展示期间的 UIKit 生命周期。
private struct Page {
let window: UIWindow
let controller: WiredCameraTransferViewController
let collection: UICollectionView
}
private func showPage(viewModel: WiredCameraTransferViewModel) async throws -> Page {
let controller = WiredCameraTransferViewController(viewModel: viewModel)
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
window.rootViewController = controller
window.makeKeyAndVisible()
controller.loadViewIfNeeded()
let collection = try XCTUnwrap(findView(in: controller.view, identifier: "wiredTransfer.photoCollectionView") as? UICollectionView)
await waitUntil { collection.numberOfSections > 0 && collection.numberOfItems(inSection: 0) > 0 }
controller.view.layoutIfNeeded()
return Page(window: window, controller: controller, collection: collection)
}
private func closePage(_ page: Page, viewModel: WiredCameraTransferViewModel) {
page.controller.dismiss(animated: false)
page.window.isHidden = true
page.window.rootViewController = nil
viewModel.stop()
}
private func tapPhoto(in collection: UICollectionView) {
collection.delegate?.collectionView?(collection, didSelectItemAt: IndexPath(item: 0, section: 0))
}
private func findView(in view: UIView, identifier: String) -> UIView? {
if view.accessibilityIdentifier == identifier { return view }
for subview in view.subviews {
if let match = findView(in: subview, identifier: identifier) { return match }
}
return nil
}
private func containsLabel(in view: UIView, text: String) -> Bool {
if let label = view as? UILabel, label.text == text { return true }
return view.subviews.contains { containsLabel(in: $0, text: text) }
}
}
/// 旅拍相册相机历史导入 ViewModel 测试。
@@ -1037,13 +1730,15 @@ private func makeWiredViewModel(
manager: MockWiredCameraConnectionManager,
uploader: MockTravelAlbumOTGUploader? = nil,
api: TravelAlbumMockAPI? = nil,
userDefaults: UserDefaults = makeOTGTestDefaults()
userDefaults: UserDefaults = makeOTGTestDefaults(),
initialAutoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled
) -> WiredCameraTransferViewModel {
prepareAppStoreForOTGTests()
return WiredCameraTransferViewModel(
albumId: albumId,
albumTitle: "相册",
headerPhone: "13800138000",
initialAutoRetouchConfiguration: initialAutoRetouchConfiguration,
connectionManager: manager,
storage: context.store,
uploader: uploader ?? MockTravelAlbumOTGUploader(),
@@ -1248,6 +1943,8 @@ private final class MockCameraDriver: CameraDriver {
private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading {
private(set) var uploadCallCount = 0
private(set) var uploadedRecordIds: [String] = []
var materialIds: [Int] = []
var error: Error?
func upload(
record: TravelAlbumOTGPhotoRecord,
@@ -1256,7 +1953,13 @@ private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading {
) async throws -> TravelAlbumMaterial {
uploadCallCount += 1
uploadedRecordIds.append(record.id)
return TravelAlbumMaterial(id: 1, fileName: record.fileName, fileUrl: "https://cdn.example.com/\(record.fileName)")
if let error { throw error }
let materialId = materialIds.isEmpty ? uploadCallCount : materialIds.removeFirst()
return TravelAlbumMaterial(
id: materialId,
fileName: record.fileName,
fileUrl: "https://cdn.example.com/\(record.fileName)"
)
}
}
@@ -1290,10 +1993,13 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
var materialListFailingCallIndexes: Set<Int> = []
var materialInfoResponse = TravelAlbumMaterial()
var materialInfoError: Error?
var materialInfoDelayNanoseconds: UInt64 = 0
var uploadMaterialResponse = TravelAlbumMaterial()
var materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: [])
var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "")
var aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse()
var autoRetouchConfigurationResponse: TravelAlbumAutoRetouchConfiguration = .disabled
var updateAutoRetouchConfigurationError: Error?
var createError: Error?
var aiRetouchTemplatesError: Error?
var submitAIRetouchError: Error?
@@ -1324,6 +2030,7 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
private(set) var deletedMaterialIDBatches: [[Int]] = []
private(set) var aiRetouchTemplateScenicIds: [Int] = []
private(set) var aiRetouchRequests: [TravelAlbumAIRetouchRequest] = []
private(set) var autoRetouchConfigurationRequests: [TravelAlbumAutoRetouchConfigurationRequest] = []
private(set) var aiReretouchRequests: [TravelAlbumAIReretouchRequest] = []
private(set) var aiJobListRequests: [AIJobListRequest] = []
@@ -1375,6 +2082,9 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
materialInfoRequests.append(
MaterialInfoRequest(userEquityTravelId: userEquityTravelId, materialId: materialId)
)
if materialInfoDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: materialInfoDelayNanoseconds)
}
if let materialInfoError { throw materialInfoError }
return materialInfoResponse
}
@@ -1411,6 +2121,14 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
return aiRetouchTemplatesResponse
}
func updateAutoRetouchConfiguration(
_ request: TravelAlbumAutoRetouchConfigurationRequest
) async throws -> TravelAlbumAutoRetouchConfiguration {
autoRetouchConfigurationRequests.append(request)
if let updateAutoRetouchConfigurationError { throw updateAutoRetouchConfigurationError }
return autoRetouchConfigurationResponse
}
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission {
aiRetouchRequests.append(request)
if submitAIRetouchDelayNanoseconds > 0 {