feat: 新增相册自动修图与OTG状态预览
支持相册修图配置、模板选择和传输模式选择;上传后自动提交修图并展示状态角标及精修预览。补充接口文档与相关测试。
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user