feat: add AI retouch task center
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user