feat: add AI retouch task center

This commit is contained in:
2026-08-14 15:06:57 +08:00
parent 0f3b26991e
commit 30bfe0313f
26 changed files with 3679 additions and 50 deletions
+4 -4
View File
@@ -19,8 +19,8 @@ final class MessageCenterAPITests: XCTestCase {
"id": 21,
"receiver_id": 9,
"receiver_type": "staff",
"type": 2,
"type_name": "退款成功通知",
"type": 14,
"type_name": "AI修图任务通知",
"title": "unused",
"content": "退款已完成",
"push_at": "2026-07-09 10:00:00",
@@ -28,7 +28,7 @@ final class MessageCenterAPITests: XCTestCase {
"read_at": "",
"push_channel": 1,
"push_channel_name": "站内信",
"extra_data": {"order_no":"NO123","amount":12.5},
"extra_data": {"ai_retouch_batch_id":"59","user_equity_travel_id":17},
"created_at": "2026-07-09 10:00:00",
"updated_at": "2026-07-09 10:01:00"
}]
@@ -50,7 +50,7 @@ final class MessageCenterAPITests: XCTestCase {
XCTAssertEqual(response.lastId, 18)
XCTAssertEqual(response.items.first?.id, 21)
XCTAssertEqual(response.items.first?.isRead, false)
XCTAssertEqual(response.items.first?.extraData?["order_no"], .string("NO123"))
XCTAssertEqual(response.items.first?.aiRetouchBatchId, 59)
}
func testUnreadCountReadAllReadAndDeleteUseExpectedContracts() async throws {
@@ -178,6 +178,44 @@ final class MessageCenterViewModelTests: XCTestCase {
XCTAssertEqual(failureMessage, "删除失败: server down")
}
func testAIRetouchMessageExposesTaskActionAndParsesDirectBatchId() {
let message = makeMessage(
id: 14,
type: 14,
extraData: ["ai_retouch_batch_id": .number(59)]
)
let viewModel = MessageDetailViewModel(message: message)
XCTAssertTrue(viewModel.showsAIRetouchTaskAction)
XCTAssertEqual(viewModel.aiRetouchBatchId, 59)
}
func testAIRetouchMessageParsesNestedStringBatchIdAndFallsBackWhenInvalid() {
let nested = makeMessage(
id: 15,
type: 14,
extraData: ["data": .object(["ai_retouch_batch_id": .string(" 61 ")])]
)
let wrapped = makeMessage(
id: 17,
type: 14,
extraData: ["data": .string(#"{"ai_retouch_batch_id":63}"#)]
)
let missing = makeMessage(id: 16, type: 14, extraData: [:])
let manualRetouch = makeMessage(
id: 10,
type: 10,
extraData: ["ai_retouch_batch_id": .number(59)]
)
XCTAssertEqual(MessageDetailViewModel(message: nested).aiRetouchBatchId, 61)
XCTAssertEqual(MessageDetailViewModel(message: wrapped).aiRetouchBatchId, 63)
XCTAssertTrue(MessageDetailViewModel(message: missing).showsAIRetouchTaskAction)
XCTAssertNil(MessageDetailViewModel(message: missing).aiRetouchBatchId)
XCTAssertFalse(MessageDetailViewModel(message: manualRetouch).showsAIRetouchTaskAction)
XCTAssertNil(MessageDetailViewModel(message: manualRetouch).aiRetouchBatchId)
}
}
@MainActor
@@ -226,12 +264,17 @@ private struct TestError: LocalizedError {
var errorDescription: String? { message }
}
private func makeMessage(id: Int, isRead: Bool = false) -> MessageItem {
private func makeMessage(
id: Int,
isRead: Bool = false,
type: Int = 1,
extraData: [String: MessageJSONValue]? = nil
) -> MessageItem {
MessageItem(
id: id,
receiverId: 1,
receiverType: "staff",
type: 1,
type: type,
typeName: "系统通知",
title: "系统通知",
content: "消息内容",
@@ -240,6 +283,7 @@ private func makeMessage(id: Int, isRead: Bool = false) -> MessageItem {
readAt: "",
pushChannel: 1,
pushChannelName: "站内信",
extraData: extraData,
createdAt: "2026-07-09 10:00:00",
updatedAt: "2026-07-09 10:00:00"
)
@@ -63,6 +63,34 @@ final class PushNotificationTests: XCTestCase {
XCTAssertEqual(nested.destination, .paymentDetails)
}
func testAIRetouchPushRoutesToDetailAndFallsBackToList() {
XCTAssertEqual(
PushPayload(userInfo: [
"type": 14,
"data": ["ai_retouch_batch_id": 91, "status": "succeeded"],
]).destination,
.aiRetouchTaskDetail(batchId: 91)
)
XCTAssertEqual(
PushPayload(userInfo: ["type": 14, "data": [:]]).destination,
.aiRetouchTaskList
)
XCTAssertEqual(
PushPayload(userInfo: ["type": 14, "data": ["ai_retouch_batch_id": "0"]]).destination,
.aiRetouchTaskList
)
XCTAssertEqual(PushPayload(userInfo: ["type": 10]).destination, .messageCenter)
}
func testAIRetouchPushReadsEncodedDataAndVendorWrapper() {
let payload = PushPayload(userInfo: [
"n_extras": #"{"type":14,"data":"{\"ai_retouch_batch_id\":92}"}"#,
])
XCTAssertEqual(payload.destination, .aiRetouchTaskDetail(batchId: 92))
XCTAssertEqual(payload.normalizedValues["ai_retouch_batch_id"], "92")
}
func testPushAPIUsesAndroidCompatibleEndpointAndQuery() async throws {
let session = MockURLSession(responses: [try TestJSON.envelope(data: EmptyPayload())])
let api = PushAPI(client: APIClient(environment: .testing, session: session))
@@ -216,7 +216,7 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
var message: String?
var submitted = false
viewModel.onShowMessage = { message = $0 }
viewModel.onSubmitted = { submitted = true }
viewModel.onSubmitted = { _ in submitted = true }
await viewModel.loadTemplates(api: api)
await viewModel.submit(api: api)
+52 -5
View File
@@ -183,7 +183,7 @@ final class TravelAlbumAPITests: XCTestCase {
}
func testSubmitAIRetouchEncodesRequiredAndSelectedOptionalTemplates() async throws {
let session = MockURLSession(responses: [envelopeJSON(#"{"task_id":9}"#)])
let session = MockURLSession(responses: [jobSubmissionJSON(batchId: 9, albumId: 6)])
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
try await api.submitAIRetouch(
@@ -215,7 +215,7 @@ final class TravelAlbumAPITests: XCTestCase {
}
func testSubmitAIRetouchOmitsAllOptionalTemplatesWhenAbsent() async throws {
let session = MockURLSession(responses: [envelopeJSON(#"{"accepted":true}"#)])
let session = MockURLSession(responses: [jobSubmissionJSON(batchId: 10, albumId: 6)])
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
try await api.submitAIRetouch(
@@ -240,9 +240,9 @@ final class TravelAlbumAPITests: XCTestCase {
func testSubmitAIReretouchEncodesOnlyFieldsRequiredByEachType() async throws {
let session = MockURLSession(responses: [
envelopeJSON(#"{"accepted":true}"#),
envelopeJSON(#"{"accepted":true}"#),
envelopeJSON(#"{"accepted":true}"#),
jobSubmissionJSON(batchId: 51, albumId: 6),
jobSubmissionJSON(batchId: 52, albumId: 6),
jobSubmissionJSON(batchId: 53, albumId: 6),
])
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
@@ -295,9 +295,56 @@ final class TravelAlbumAPITests: XCTestCase {
XCTAssertEqual(bodies[2]?["type"] as? Int, 3)
}
func testAIJobListBuildsCursorQueryAndDecodesUnknownStatusSafely() async throws {
let data = envelopeJSON(
#"{"items":[{"ai_retouch_batch_id":91,"user_equity_travel_id":6,"scope":"album","status":"future_status","album":{"id":6,"name":"九寨沟旅拍","user_phone":"138****0000","cover_url":""},"source_count":1,"outputs":[{"type":"refined","count":1}],"preview_images":[],"progress":{"total":1,"queued":1,"processing":0,"succeeded":0,"failed":0,"canceled":0},"estimated_finish_at":null,"failure_summary":null,"created_at":"2026-08-14T06:00:00Z","started_at":null,"finished_at":null}],"next_cursor":"cursor-2","has_more":true}"#
)
let session = MockURLSession(responses: [data])
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
let response = try await api.aiRetouchJobList(
statusGroup: .inProgress,
limit: 20,
cursor: "cursor-1"
)
XCTAssertEqual(response.items.first?.status, .unknown("future_status"))
XCTAssertEqual(response.nextCursor, "cursor-2")
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/ai-retouch-job-list")
let query = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems
XCTAssertEqual(query?.first { $0.name == "status_group" }?.value, "in_progress")
XCTAssertEqual(query?.first { $0.name == "limit" }?.value, "20")
XCTAssertEqual(query?.first { $0.name == "cursor" }?.value, "cursor-1")
}
func testAIJobInfoDecodesPerOutputFailureReason() async throws {
let data = envelopeJSON(
#"{"ai_retouch_batch_id":91,"user_equity_travel_id":6,"scope":"album","status":"partially_succeeded","album":{"id":6,"name":"九寨沟旅拍","user_phone":"138****0000","cover_url":""},"source_count":1,"outputs":[{"type":"refined","count":1}],"progress":{"total":1,"queued":0,"processing":0,"succeeded":0,"failed":1,"canceled":0},"quota_settlement":{"status":"settled","reserved_units":1,"consumed_units":0,"released_units":1,"cover_units":0},"targets":[{"target_id":1,"source_material":{"id":11,"file_name":"A.JPG","thumbnail_url":""},"input_material_ids":[11],"output_type":"refined","template":{"id":21,"name":"清透"},"status":"failed","result_asset":null,"error":{"code":"FACE_NOT_FOUND","message":"未识别到清晰人脸,请更换照片","retryable":true},"created_at":"2026-08-14T06:00:00Z","started_at":null,"finished_at":"2026-08-14T06:01:00Z"}],"estimated_finish_at":null,"created_at":"2026-08-14T06:00:00Z","started_at":null,"finished_at":"2026-08-14T06:01:00Z","duration_seconds":60}"#
)
let session = MockURLSession(responses: [data])
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
let detail = try await api.aiRetouchJobInfo(batchId: 91)
XCTAssertEqual(detail.status, .partiallySucceeded)
XCTAssertEqual(detail.targets.first?.displayFailureMessage, "未识别到清晰人脸,请更换照片")
XCTAssertEqual(detail.targets.first?.error?.retryable, true)
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/ai-retouch-job-info")
let query = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems
XCTAssertEqual(query?.first { $0.name == "ai_retouch_batch_id" }?.value, "91")
}
private func envelopeJSON(_ dataJSON: String) -> Data {
"""
{"code":100000,"msg":"success","data":\(dataJSON)}
""".data(using: .utf8)!
}
private func jobSubmissionJSON(batchId: Int, albumId: Int) -> Data {
envelopeJSON(
#"{"ai_retouch_batch_id":\#(batchId),"user_equity_travel_id":\#(albumId),"status":"queued","progress":{"total":3,"queued":3,"processing":0,"succeeded":0,"failed":0,"canceled":0},"created_at":"2026-08-14T06:00:00Z"}"#
)
}
}
@@ -10,6 +10,231 @@ import XCTest
/// 相册管理页刷新、预览与选择态交互测试。
@MainActor
final class TravelAlbumDetailViewControllerTests: XCTestCase {
func testAIJobDetailMatchesDesignCardHierarchyAndShowsInlineFailureReason() async throws {
let api = TravelAlbumMockAPI()
let source = TravelAlbumAIJobSourceMaterial(
id: 691,
fileName: "IMG_8293.JPG",
thumbnailURL: ""
)
let failedTarget = TravelAlbumAIJobTarget(
targetId: 3,
sourceMaterial: source,
inputMaterialIds: [691],
outputType: .refined,
template: TravelAlbumAIJobTemplate(id: 7, name: "自然通透"),
status: .failed,
resultAsset: nil,
error: TravelAlbumAIJobError(
code: "VENDOR_TIMEOUT",
message: "AI服务处理超时,请重新修图",
retryable: true
),
createdAt: "2026-08-14T03:20:43.000Z",
startedAt: nil,
finishedAt: "2026-08-14T03:21:43.000Z"
)
api.aiJobDetailResponse = TravelAlbumAIJobDetail(
aiRetouchBatchId: 59,
userEquityTravelId: 17,
scope: "batch",
status: .processing,
album: TravelAlbumAIJobAlbum(
id: 17,
name: "旅拍相册",
userPhone: "13222319413",
coverURL: ""
),
sourceCount: 1,
outputs: [
TravelAlbumAIJobOutput(type: .refined, count: 4),
TravelAlbumAIJobOutput(type: .atmosphere, count: 4),
TravelAlbumAIJobOutput(type: .cover, count: 1),
],
progress: TravelAlbumAIJobProgress(
total: 9,
queued: 2,
processing: 1,
succeeded: 5,
failed: 1,
canceled: 0
),
quotaSettlement: TravelAlbumAIJobQuotaSettlement(
status: "reserved",
reservedUnits: 9,
consumedUnits: 6,
releasedUnits: 0,
coverUnits: 1
),
targets: [failedTarget],
estimatedFinishAt: "2026-08-14T03:32:00.000Z",
createdAt: "2026-08-14T03:20:43.000Z",
startedAt: "2026-08-14T03:20:44.000Z",
finishedAt: nil,
durationSeconds: nil
)
let controller = TravelAlbumAIJobDetailViewController(batchId: 59, api: api)
let navigationController = UINavigationController(rootViewController: controller)
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
window.rootViewController = navigationController
window.makeKeyAndVisible()
defer { window.isHidden = true }
controller.loadViewIfNeeded()
await waitUntil {
controller.view.findSubview {
$0.accessibilityIdentifier == "aiRetouchJob.detail.statusCard"
} != nil
}
controller.view.layoutIfNeeded()
for identifier in [
"aiRetouchJob.detail.statusCard",
"aiRetouchJob.detail.albumCard",
"aiRetouchJob.detail.contentCard",
"aiRetouchJob.detail.processingCard",
"aiRetouchJob.detail.albumButton",
] {
XCTAssertNotNil(controller.view.findSubview { $0.accessibilityIdentifier == identifier })
}
let labels = controller.view.allLabels().compactMap(\.text)
XCTAssertTrue(labels.contains("修图中"))
XCTAssertTrue(labels.contains("已完成 6 / 9"))
XCTAssertTrue(labels.contains("67%"))
XCTAssertTrue(labels.contains("失败原因:AI服务处理超时,请重新修图"))
XCTAssertFalse(labels.contains("去相册处理"))
XCTAssertFalse(labels.contains { $0.contains("VENDOR_TIMEOUT") })
let detailScreenshot = UIGraphicsImageRenderer(bounds: window.bounds).image { context in
window.layer.render(in: context.cgContext)
}
let detailAttachment = XCTAttachment(image: detailScreenshot)
detailAttachment.name = "AI修图任务详情设计还原"
detailAttachment.lifetime = .keepAlways
add(detailAttachment)
}
func testAIJobListCardMatchesDesignStructureAndKeepsSinglePreviewInGrid() async throws {
let api = TravelAlbumMockAPI()
let job = TravelAlbumAIJobSummary(
aiRetouchBatchId: 59,
userEquityTravelId: 17,
scope: "batch",
status: .queued,
album: TravelAlbumAIJobAlbum(
id: 17,
name: "2026-06-17-005",
userPhone: "13222319413",
coverURL: ""
),
sourceCount: 1,
outputs: [
TravelAlbumAIJobOutput(type: .refined, count: 1),
TravelAlbumAIJobOutput(type: .atmosphere, count: 1),
],
previewImages: [TravelAlbumAIJobPreviewImage(materialId: 691, thumbnailURL: "")],
progress: TravelAlbumAIJobProgress(
total: 2,
queued: 2,
processing: 0,
succeeded: 0,
failed: 0,
canceled: 0
),
estimatedFinishAt: "2026-08-14T03:23:34.147Z",
failureSummary: nil,
createdAt: "2026-08-14T03:20:43.000Z",
startedAt: nil,
finishedAt: nil
)
api.aiJobListResponses = [
TravelAlbumAIJobListResponse(items: [job], nextCursor: nil, hasMore: false),
]
let controller = TravelAlbumAIJobListViewController(api: api)
let navigationController = UINavigationController(rootViewController: controller)
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
window.rootViewController = navigationController
window.makeKeyAndVisible()
defer { window.isHidden = true }
controller.loadViewIfNeeded()
await waitUntil { api.aiJobListRequests.count == 1 }
try await Task.sleep(for: .milliseconds(150))
controller.view.layoutIfNeeded()
let collectionView = try XCTUnwrap(
controller.view.findSubview { $0 is UICollectionView } as? UICollectionView
)
collectionView.layoutIfNeeded()
let cell = try XCTUnwrap(collectionView.cellForItem(at: IndexPath(item: 0, section: 0)))
let albumCover = try XCTUnwrap(
cell.findSubview { $0.accessibilityIdentifier == "aiRetouchJob.albumCover" }
)
let firstPreview = try XCTUnwrap(
cell.findSubview { $0.accessibilityIdentifier == "aiRetouchJob.preview.0" }
)
let filterContainer = try XCTUnwrap(
controller.view.findSubview { $0.accessibilityIdentifier == "aiRetouchJob.filterContainer" }
)
XCTAssertEqual(filterContainer.bounds.height, 46, accuracy: 0.5)
XCTAssertEqual(albumCover.bounds.width, 68, accuracy: 2)
XCTAssertEqual(albumCover.bounds.height, 68, accuracy: 2)
XCTAssertLessThan(firstPreview.bounds.width, cell.bounds.width * 0.4)
XCTAssertTrue(cell.allLabels().contains { $0.text == "排队中" })
XCTAssertTrue(cell.allLabels().contains { $0.text == "已完成 0 / 2" })
XCTAssertTrue(cell.allLabels().contains { $0.text == "0%" })
XCTAssertTrue(cell.allLabels().contains { $0.text == "精修 1张 · 氛围感 1张" })
XCTAssertTrue(cell.allLabels().contains { $0.text == "查看详情" })
let listScreenshot = UIGraphicsImageRenderer(bounds: window.bounds).image { context in
window.layer.render(in: context.cgContext)
}
let listAttachment = XCTAttachment(image: listScreenshot)
listAttachment.name = "AI修图任务列表设计还原"
listAttachment.lifetime = .keepAlways
add(listAttachment)
}
func testAIJobEmptyInProgressFilterDismissesGlobalLoading() async throws {
GlobalLoadingManager.shared.hideAll()
defer { GlobalLoadingManager.shared.hideAll() }
let api = TravelAlbumMockAPI()
api.aiJobListDelayNanoseconds = 30_000_000
api.aiJobListResponses = [
TravelAlbumAIJobListResponse(items: [], nextCursor: nil, hasMore: false),
TravelAlbumAIJobListResponse(items: [], nextCursor: nil, hasMore: false),
]
let controller = TravelAlbumAIJobListViewController(api: api)
controller.loadViewIfNeeded()
controller.viewDidAppear(false)
defer { controller.viewWillDisappear(false) }
await waitUntil { api.aiJobListRequests.count == 1 && !GlobalLoadingManager.shared.isShowing }
let inProgressButton = try XCTUnwrap(
controller.view.findSubview {
($0 as? UIButton)?.accessibilityLabel == "筛选:进行中"
} as? UIButton
)
inProgressButton.sendActions(for: .touchUpInside)
await waitUntil {
api.aiJobListRequests.count == 2 &&
api.aiJobListRequests.last?.statusGroup == .inProgress &&
!GlobalLoadingManager.shared.isShowing
}
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
}
func testAIJobEntryOnlyAppearsOnAlbumManagementNavigationBar() {
let detail = TravelAlbumDetailViewController(albumId: 2, api: TravelAlbumMockAPI())
detail.setupNavigationBar()
let entry = TravelAlbumEntryViewController(api: TravelAlbumMockAPI())
entry.setupNavigationBar()
XCTAssertTrue(detail.navigationItem.rightBarButtonItems?.contains { $0.title == "修图任务" } == true)
XCTAssertNil(entry.navigationItem.rightBarButtonItem)
XCTAssertTrue(entry.navigationItem.rightBarButtonItems?.isEmpty ?? true)
}
func testPullToRefreshReloadsGridAndEndsRefreshing() async throws {
let api = TravelAlbumMockAPI()
api.infoResponse = TravelAlbum(id: 2, name: "测试相册")
@@ -483,7 +708,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
let controller = TravelAlbumAIRetouchTemplateViewController(
viewModel: viewModel,
api: api,
onSubmitted: {}
onSubmitted: { _ in }
)
controller.loadViewIfNeeded()
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
@@ -538,7 +763,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
XCTAssertEqual(selectionCountLabel.text, "已选择 4 张照片")
XCTAssertEqual(
tipsLabel.text,
"每张照片生成精修结果,氛围感可选;本次可免费生成 1 张封面"
"Tips:氛围感修图为选填,可横向选择一种样式;选中后每张照片会额外生成1个独立结果,第一张照片仍另生成封面。"
)
XCTAssertFalse(tipsContainer.isHidden)
XCTAssertEqual(tipsContainer.backgroundColor?.travelAlbumTestHexRGB, 0xF4F8FF)
@@ -568,7 +793,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
let controller = TravelAlbumAIRetouchTemplateViewController(
viewModel: viewModel,
api: api,
onSubmitted: {}
onSubmitted: { _ in }
)
controller.loadViewIfNeeded()
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
@@ -729,7 +954,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
XCTAssertEqual(comparisonButton.bounds.size, CGSize(width: 48, height: 48))
}
func testPreviewAIRetouchUsesSelectedRetouchedTabWorkflowAndStaysPresented() async throws {
func testPreviewAIRetouchUsesSelectedRetouchedTabWorkflowAndShowsTaskAction() async throws {
UIView.setAnimationsEnabled(false)
defer { UIView.setAnimationsEnabled(true) }
let api = TravelAlbumMockAPI()
@@ -823,10 +1048,12 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
XCTAssertTrue(tipsLabel.isHidden)
confirmButton.sendActions(for: .touchUpInside)
await waitUntil { api.aiReretouchRequests.count == 1 }
await waitUntil { controller.presentedViewController == nil }
await waitUntil { controller.presentedViewController is UIAlertController }
XCTAssertEqual(api.aiReretouchRequests.first?.type, .refined)
XCTAssertNil(controller.presentedViewController)
let successAlert = try XCTUnwrap(controller.presentedViewController as? UIAlertController)
XCTAssertEqual(successAlert.title, "AI修图任务已提交")
XCTAssertEqual(successAlert.actions.map(\.title), ["知道了", "查看任务"])
XCTAssertTrue(window.rootViewController === controller)
}
+129 -2
View File
@@ -6,6 +6,96 @@
import XCTest
@testable import suixinkan
/// AI 修图任务中心 ViewModel 测试。
@MainActor
final class TravelAlbumAIJobViewModelTests: XCTestCase {
func testListFiltersPaginatesAndDeduplicatesJobs() async {
let api = TravelAlbumMockAPI()
api.aiJobListResponses = [
TravelAlbumAIJobListResponse(
items: [makeSummary(id: 1, status: .processing)],
nextCursor: "page-2",
hasMore: true
),
TravelAlbumAIJobListResponse(
items: [makeSummary(id: 1, status: .processing), makeSummary(id: 2, status: .succeeded)],
nextCursor: nil,
hasMore: false
),
]
let viewModel = TravelAlbumAIJobListViewModel()
await viewModel.selectFilter(.inProgress, api: api)
await viewModel.loadMore(api: api)
XCTAssertEqual(viewModel.selectedFilter, .inProgress)
XCTAssertEqual(viewModel.items.map(\.id), [1, 2])
XCTAssertTrue(viewModel.containsInProgressJobs)
XCTAssertEqual(api.aiJobListRequests.map(\.cursor), [nil, "page-2"])
XCTAssertEqual(api.aiJobListRequests.map(\.statusGroup), [.inProgress, .inProgress])
}
func testDetailStopsPollingAtTerminalStateAndRecognizes404() async {
let api = TravelAlbumMockAPI()
api.aiJobDetailResponse = makeDetail(status: .succeeded)
let viewModel = TravelAlbumAIJobDetailViewModel(batchId: 91)
await viewModel.load(api: api)
XCTAssertFalse(viewModel.shouldPoll)
XCTAssertEqual(viewModel.detail?.targets.first?.displayFailureMessage, "处理失败,请前往相册重新修图")
let missing = TravelAlbumAIJobDetailViewModel(batchId: 92)
api.aiJobDetailResponse = nil
var notFoundCount = 0
missing.onNotFound = { notFoundCount += 1 }
await missing.load(api: api)
await missing.load(api: api)
XCTAssertTrue(missing.isNotFound)
XCTAssertEqual(notFoundCount, 1)
}
private func makeSummary(id: Int, status: TravelAlbumAIJobStatus) -> TravelAlbumAIJobSummary {
TravelAlbumAIJobSummary(
aiRetouchBatchId: id,
userEquityTravelId: 6,
scope: "album",
status: status,
album: TravelAlbumAIJobAlbum(id: 6, name: "九寨沟", userPhone: "138****0000", coverURL: ""),
sourceCount: 1,
outputs: [TravelAlbumAIJobOutput(type: .refined, count: 1)],
previewImages: [],
progress: TravelAlbumAIJobProgress(total: 1, queued: 0, processing: status.isInProgress ? 1 : 0, succeeded: status == .succeeded ? 1 : 0, failed: 0, canceled: 0),
estimatedFinishAt: nil,
failureSummary: nil,
createdAt: "2026-08-14T06:00:00Z",
startedAt: nil,
finishedAt: nil
)
}
private func makeDetail(status: TravelAlbumAIJobStatus) -> TravelAlbumAIJobDetail {
TravelAlbumAIJobDetail(
aiRetouchBatchId: 91,
userEquityTravelId: 6,
scope: "album",
status: status,
album: TravelAlbumAIJobAlbum(id: 6, name: "九寨沟", userPhone: "138****0000", coverURL: ""),
sourceCount: 1,
outputs: [TravelAlbumAIJobOutput(type: .refined, count: 1)],
progress: TravelAlbumAIJobProgress(total: 1, queued: 0, processing: 0, succeeded: 0, failed: 1, canceled: 0),
quotaSettlement: TravelAlbumAIJobQuotaSettlement(status: "settled", reservedUnits: 1, consumedUnits: 0, releasedUnits: 1, coverUnits: 0),
targets: [TravelAlbumAIJobTarget(targetId: 1, sourceMaterial: nil, inputMaterialIds: [11], outputType: .refined, template: nil, status: .failed, resultAsset: nil, error: nil, createdAt: "", startedAt: nil, finishedAt: nil)],
estimatedFinishAt: nil,
createdAt: "2026-08-14T06:00:00Z",
startedAt: nil,
finishedAt: "2026-08-14T06:01:00Z",
durationSeconds: 60
)
}
}
@MainActor
/// 旅拍相册 ViewModel 测试。
final class TravelAlbumEntryViewModelTests: XCTestCase {
@@ -1076,6 +1166,11 @@ private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading {
/// 旅拍相册 API 测试替身。
@MainActor
final class TravelAlbumMockAPI: TravelAlbumServing {
struct AIJobListRequest: Equatable {
let statusGroup: TravelAlbumAIJobFilter
let limit: Int
let cursor: String?
}
struct MaterialRequest: Equatable {
let userEquityTravelId: Int
let page: Int
@@ -1107,6 +1202,17 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
var submitAIRetouchDelayNanoseconds: UInt64 = 0
var submitAIReretouchError: Error?
var submitAIReretouchDelayNanoseconds: UInt64 = 0
var aiJobSubmission = TravelAlbumAIJobSubmission(
aiRetouchBatchId: 1,
userEquityTravelId: 1,
status: .queued,
progress: TravelAlbumAIJobProgress(total: 1, queued: 1, processing: 0, succeeded: 0, failed: 0, canceled: 0),
createdAt: "2026-08-14T06:26:12.123Z"
)
var aiJobListResponse = TravelAlbumAIJobListResponse(items: [], nextCursor: nil, hasMore: false)
var aiJobListResponses: [TravelAlbumAIJobListResponse] = []
var aiJobListDelayNanoseconds: UInt64 = 0
var aiJobDetailResponse: TravelAlbumAIJobDetail?
var deleteMaterialError: Error?
var deleteMaterialDelayNanoseconds: UInt64 = 0
@@ -1121,6 +1227,7 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
private(set) var aiRetouchTemplateScenicIds: [Int] = []
private(set) var aiRetouchRequests: [TravelAlbumAIRetouchRequest] = []
private(set) var aiReretouchRequests: [TravelAlbumAIReretouchRequest] = []
private(set) var aiJobListRequests: [AIJobListRequest] = []
func availableOrders() async throws -> [TravelAlbumAvailableOrder] {
availableOrdersCallCount += 1
@@ -1205,19 +1312,39 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
return aiRetouchTemplatesResponse
}
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws {
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission {
aiRetouchRequests.append(request)
if submitAIRetouchDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: submitAIRetouchDelayNanoseconds)
}
if let submitAIRetouchError { throw submitAIRetouchError }
return aiJobSubmission
}
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws {
func submitAIReretouch(_ request: TravelAlbumAIReretouchRequest) async throws -> TravelAlbumAIJobSubmission {
aiReretouchRequests.append(request)
if submitAIReretouchDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: submitAIReretouchDelayNanoseconds)
}
if let submitAIReretouchError { throw submitAIReretouchError }
return aiJobSubmission
}
func aiRetouchJobList(
statusGroup: TravelAlbumAIJobFilter,
limit: Int,
cursor: String?
) async throws -> TravelAlbumAIJobListResponse {
aiJobListRequests.append(AIJobListRequest(statusGroup: statusGroup, limit: limit, cursor: cursor))
if aiJobListDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: aiJobListDelayNanoseconds)
}
if !aiJobListResponses.isEmpty { return aiJobListResponses.removeFirst() }
return aiJobListResponse
}
func aiRetouchJobInfo(batchId: Int) async throws -> TravelAlbumAIJobDetail {
guard let aiJobDetailResponse else { throw APIError.httpStatus(404, "not found") }
return aiJobDetailResponse
}
}