feat: 新增相册自动修图与OTG状态预览
支持相册修图配置、模板选择和传输模式选择;上传后自动提交修图并展示状态角标及精修预览。补充接口文档与相关测试。
This commit is contained in:
@@ -36,7 +36,11 @@ final class TravelAlbumAPITests: XCTestCase {
|
||||
materialNum: 2,
|
||||
materialPrice: 10.5,
|
||||
materialPackagePrice: 88,
|
||||
photoPrice: 0
|
||||
photoPrice: 0,
|
||||
autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration(
|
||||
enabled: true,
|
||||
refinedTemplateId: 12
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -48,6 +52,60 @@ final class TravelAlbumAPITests: XCTestCase {
|
||||
XCTAssertEqual(body?["type"] as? Int, 1)
|
||||
XCTAssertEqual(body?["material_num"] as? Int, 2)
|
||||
XCTAssertEqual(body?["material_price"] as? Double, 10.5)
|
||||
let retouchConfiguration = body?["auto_retouch_config"] as? [String: Any]
|
||||
XCTAssertEqual(retouchConfiguration?["enabled"] as? Bool, true)
|
||||
XCTAssertEqual(retouchConfiguration?["refined_template_id"] as? Int, 12)
|
||||
XCTAssertNil(retouchConfiguration?["version"])
|
||||
XCTAssertNil(retouchConfiguration?["updated_at"])
|
||||
}
|
||||
|
||||
func testInfoDecodesNewConfigurationAndDefaultsOldAlbumToDisabled() async throws {
|
||||
let configuredAlbum = envelopeJSON(
|
||||
#"{"id":8,"store_user_id":1,"name":"配置相册","type":1,"order_number":"","material_num":1,"material_price":10,"material_package_price":0,"photo_price":0,"cover_url":"","user_id":2,"status":1,"created_at":"","updated_at":"","auto_retouch_config":{"enabled":true,"refined_template_id":12}}"#
|
||||
)
|
||||
let legacyAlbum = envelopeJSON(
|
||||
#"{"id":9,"store_user_id":1,"name":"旧相册","type":1,"order_number":"","material_num":1,"material_price":10,"material_package_price":0,"photo_price":0,"cover_url":"","user_id":2,"status":1,"created_at":"","updated_at":""}"#
|
||||
)
|
||||
let session = MockURLSession(responses: [configuredAlbum, legacyAlbum])
|
||||
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||
|
||||
let configured = try await api.info(id: 8)
|
||||
let legacy = try await api.info(id: 9)
|
||||
|
||||
XCTAssertEqual(
|
||||
configured.autoRetouchConfiguration,
|
||||
TravelAlbumAutoRetouchConfiguration(
|
||||
enabled: true,
|
||||
refinedTemplateId: 12
|
||||
)
|
||||
)
|
||||
XCTAssertEqual(legacy.autoRetouchConfiguration, .disabled)
|
||||
}
|
||||
|
||||
func testUpdateAutoRetouchConfigurationEncodesBodyAndDecodesNormalizedResponse() async throws {
|
||||
let data = envelopeJSON(
|
||||
#"{"enabled":true,"refined_template_id":18}"#
|
||||
)
|
||||
let session = MockURLSession(responses: [data])
|
||||
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||
|
||||
let response = try await api.updateAutoRetouchConfiguration(
|
||||
TravelAlbumAutoRetouchConfigurationRequest(
|
||||
userEquityTravelId: 6,
|
||||
enabled: true,
|
||||
refinedTemplateId: 18
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertEqual(response.refinedTemplateId, 18)
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.httpMethod, "POST")
|
||||
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/auto-retouch-config")
|
||||
let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(request.httpBody)) as? [String: Any]
|
||||
XCTAssertEqual(body?["user_equity_travel_id"] as? Int, 6)
|
||||
XCTAssertEqual(body?["enabled"] as? Bool, true)
|
||||
XCTAssertEqual(body?["refined_template_id"] as? Int, 18)
|
||||
XCTAssertNil(body?["expected_version"])
|
||||
}
|
||||
|
||||
func testMaterialListAndDeleteAndMpCode() async throws {
|
||||
@@ -192,7 +250,8 @@ final class TravelAlbumAPITests: XCTestCase {
|
||||
materialIds: [11, 12, 13, 14],
|
||||
refinedTemplateId: 21,
|
||||
atmosphereTemplateId: 22,
|
||||
coverTemplateId: 31
|
||||
coverTemplateId: 31,
|
||||
clientRequestId: "auto-6-11-tpl21-a0"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -205,12 +264,14 @@ final class TravelAlbumAPITests: XCTestCase {
|
||||
XCTAssertEqual(body?["refined_template_id"] as? Int, 21)
|
||||
XCTAssertEqual(body?["atmosphere_template_id"] as? Int, 22)
|
||||
XCTAssertEqual(body?["cover_template_id"] as? Int, 31)
|
||||
XCTAssertEqual(body?["client_request_id"] as? String, "auto-6-11-tpl21-a0")
|
||||
XCTAssertEqual(Set(body?.keys.map { $0 } ?? []), [
|
||||
"user_equity_travel_id",
|
||||
"material_ids",
|
||||
"refined_template_id",
|
||||
"atmosphere_template_id",
|
||||
"cover_template_id",
|
||||
"client_request_id",
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -260,7 +260,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
||||
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
|
||||
}
|
||||
|
||||
func testAIJobEntryOnlyAppearsOnAlbumManagementNavigationBar() {
|
||||
func testAlbumManagementShowsAIJobEntryButAlbumEntryDoesNot() {
|
||||
let detail = TravelAlbumDetailViewController(albumId: 2, api: TravelAlbumMockAPI())
|
||||
detail.setupNavigationBar()
|
||||
let entry = TravelAlbumEntryViewController(api: TravelAlbumMockAPI())
|
||||
@@ -1394,6 +1394,111 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
||||
XCTAssertFalse(hiddenCell.accessibilityLabel?.contains("修图状态:") == true)
|
||||
}
|
||||
|
||||
func testWiredTransferProgressUsesOnlyUploadStatusAndRealProgress() throws {
|
||||
let cell = WiredTransferPhotoCell(frame: CGRect(x: 0, y: 0, width: 300, height: 66))
|
||||
let progressView = try XCTUnwrap(cell.findSubview {
|
||||
$0.accessibilityIdentifier == "wiredTransfer.uploadProgress"
|
||||
} as? UIProgressView)
|
||||
let uploadStates: [TravelAlbumOTGUploadStatus] = [.pending, .transferring, .uploading, .uploaded, .failed]
|
||||
let retouchStates: [TravelAlbumAutoRetouchState] = [.none, .pendingSubmission, .submitting, .processing, .completed, .failed]
|
||||
|
||||
for uploadState in uploadStates {
|
||||
for retouchState in retouchStates {
|
||||
var item = makeWiredTransferPhotoItem(state: retouchState)
|
||||
item.status = uploadState
|
||||
item.progress = uploadState == .uploaded ? 100 : 37
|
||||
|
||||
cell.apply(item: item, selectionMode: false, selected: false)
|
||||
|
||||
XCTAssertEqual(progressView.isHidden, uploadState != .transferring && uploadState != .uploading)
|
||||
XCTAssertEqual(progressView.progress, Float(item.progress) / 100, accuracy: 0.001)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testWiredTransferRetouchBadgeShowsStateColorsAndKeepsCircularThumbnailLayout() throws {
|
||||
let cases: [(TravelAlbumAutoRetouchState, String, UIColor)] = [
|
||||
(.pendingSubmission, "修图中", UIColor(hex: 0x7C3AED)),
|
||||
(.submitting, "修图中", UIColor(hex: 0x7C3AED)),
|
||||
(.processing, "修图中", UIColor(hex: 0x7C3AED)),
|
||||
(.completed, "修图成功", UIColor(hex: 0x047857)),
|
||||
(.failed, "修图失败", AppColor.danger),
|
||||
]
|
||||
let cell = WiredTransferPhotoCell(frame: CGRect(x: 0, y: 0, width: 300, height: 66))
|
||||
let badge = try XCTUnwrap(cell.findSubview {
|
||||
$0.accessibilityIdentifier == "wiredTransfer.retouchBadge"
|
||||
} as? UILabel)
|
||||
let thumbnail = try XCTUnwrap(cell.findSubview {
|
||||
$0.accessibilityIdentifier == "wiredTransfer.thumbnail"
|
||||
})
|
||||
|
||||
for (state, expectedStatus, expectedColor) in cases {
|
||||
cell.apply(item: makeWiredTransferPhotoItem(state: state), selectionMode: false, selected: false)
|
||||
cell.layoutIfNeeded()
|
||||
|
||||
XCTAssertFalse(badge.isHidden)
|
||||
XCTAssertEqual(badge.text, "修")
|
||||
XCTAssertEqual(badge.backgroundColor?.travelAlbumTestHexRGB, expectedColor.travelAlbumTestHexRGB)
|
||||
XCTAssertEqual(badge.textColor, .white)
|
||||
XCTAssertEqual(badge.frame.size, CGSize(width: 16, height: 16))
|
||||
// Auto Layout 对齐 alignmentRect,系统符号占位图的 frame 可能带有留白。
|
||||
let badgeAlignmentRect = badge.alignmentRect(forFrame: badge.frame)
|
||||
let thumbnailAlignmentRect = thumbnail.alignmentRect(forFrame: thumbnail.frame)
|
||||
XCTAssertEqual(badgeAlignmentRect.minY, thumbnailAlignmentRect.minY + 2, accuracy: 0.1)
|
||||
XCTAssertEqual(badgeAlignmentRect.maxX, thumbnailAlignmentRect.maxX - 2, accuracy: 0.1)
|
||||
XCTAssertEqual(badge.layer.cornerRadius, 8)
|
||||
XCTAssertEqual(badge.accessibilityLabel, expectedStatus)
|
||||
XCTAssertTrue(cell.accessibilityLabel?.contains("修图状态:\(expectedStatus)") == true)
|
||||
}
|
||||
|
||||
cell.prepareForReuse()
|
||||
cell.apply(item: makeWiredTransferPhotoItem(state: .none), selectionMode: false, selected: false)
|
||||
XCTAssertTrue(badge.isHidden)
|
||||
XCTAssertNil(badge.accessibilityLabel)
|
||||
XCTAssertFalse(cell.accessibilityLabel?.contains("修图状态:") == true)
|
||||
}
|
||||
|
||||
func testWiredTransferUploadLabelIgnoresAllRetouchStates() throws {
|
||||
let cell = WiredTransferPhotoCell(frame: CGRect(x: 0, y: 0, width: 300, height: 66))
|
||||
let label = try XCTUnwrap(cell.findSubview {
|
||||
$0.accessibilityIdentifier == "wiredTransfer.uploadStatus"
|
||||
} as? UILabel)
|
||||
let successColor = UIColor(hex: 0x16A34A)
|
||||
let cases: [(TravelAlbumOTGUploadStatus, String, UIColor, UIColor)] = [
|
||||
(.pending, "待上传", AppColor.textSecondary, AppColor.pageBackground),
|
||||
(.transferring, "传输中", AppColor.primary, AppColor.primary.withAlphaComponent(0.12)),
|
||||
(.uploading, "上传中", AppColor.primary, AppColor.primary.withAlphaComponent(0.12)),
|
||||
(.uploaded, "已上传", successColor, successColor.withAlphaComponent(0.12)),
|
||||
(.failed, "上传失败", AppColor.danger, AppColor.danger.withAlphaComponent(0.12)),
|
||||
]
|
||||
let retouchStates: [TravelAlbumAutoRetouchState] = [
|
||||
.none, .pendingSubmission, .submitting, .processing, .completed, .failed,
|
||||
]
|
||||
|
||||
for (uploadStatus, text, foreground, background) in cases {
|
||||
for retouchState in retouchStates {
|
||||
var item = makeWiredTransferPhotoItem(state: retouchState)
|
||||
item.status = uploadStatus
|
||||
cell.apply(item: item, selectionMode: false, selected: false)
|
||||
|
||||
XCTAssertFalse(label.isHidden)
|
||||
XCTAssertEqual(label.text, text)
|
||||
XCTAssertEqual(label.textColor, foreground)
|
||||
XCTAssertEqual(label.backgroundColor, background)
|
||||
XCTAssertTrue(cell.accessibilityLabel?.hasPrefix("photo.JPG,\(text)") == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeWiredTransferPhotoItem(state: TravelAlbumAutoRetouchState) -> TravelAlbumOTGPhotoItem {
|
||||
TravelAlbumOTGPhotoItem(
|
||||
id: "photo", sourceId: "photo", fileName: "photo.JPG", thumbnailURL: nil,
|
||||
capturedAt: "2026-08-27 12:00:00", fileSizeText: "1 KB", fileSizeBytes: 1024,
|
||||
status: .uploaded, progress: 100, errorMessage: nil, localPath: "", remoteUrl: "",
|
||||
autoRetouchState: state
|
||||
)
|
||||
}
|
||||
|
||||
private func waitUntil(_ condition: @escaping () -> Bool) async {
|
||||
for _ in 0 ..< 200 {
|
||||
if condition() { return }
|
||||
|
||||
@@ -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