2 Commits
Author SHA1 Message Date
lujiuyin b127495e52 feat(album): add pull-to-refresh to album entry 2026-08-17 15:19:21 +08:00
lujiuyin 76aa7bb4aa feat: refine AI retouch workflow 2026-08-17 15:09:15 +08:00
9 changed files with 154 additions and 36 deletions
@@ -386,11 +386,18 @@ enum TravelAlbumAIRetouchWorkflow: Sendable, Equatable {
} }
} }
/// 当前分组是否允许不选择;仅首次修图的氛围感模板选填。 /// 当前分组是否允许不选择;首次修图和原图 Tab 覆盖重修的氛围感模板选填。
func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool { func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool {
if case .initial = self, category == .atmosphere { return true } guard category == .atmosphere else { return false }
switch self {
case .initial:
return true
case .reretouch(_, _, .all):
return true
case .reretouch:
return false return false
} }
}
/// 工作流目标是否满足接口的最小参数要求。 /// 工作流目标是否满足接口的最小参数要求。
var isValid: Bool { var isValid: Bool {
@@ -66,9 +66,12 @@ final class TravelAlbumAIRetouchTemplateViewModel {
return false return false
} }
/// 首次修图页固定展示的氛围感与封面生成规则说明。 /// 首次修图页按照当前选图数量展示氛围感与封面生成规则。
var initialTipsText: String { var initialTipsText: String {
"Tips:氛围感修图为选填,可横向选择一种样式;选中后每张照片会额外生成1个独立结果,第一张照片仍另生成封面。" if visibleCategories.contains(.cover) {
return "Tips:氛围感修图为选填;封面风格为必选,将使用第一张照片另生成封面。"
}
return "Tips:氛围感修图为选填,选中后每张照片会额外生成1个独立结果。"
} }
/// 当前分组是否为选填。 /// 当前分组是否为选填。
@@ -52,7 +52,10 @@ final class TravelAlbumEntryViewModel {
} }
/// 重新拉取相册列表。 /// 重新拉取相册列表。
func loadAlbums(api: any TravelAlbumServing) async { func loadAlbums(
api: any TravelAlbumServing,
preservingContentOnFailure: Bool = false
) async {
isLoading = true isLoading = true
notifyStateChange() notifyStateChange()
defer { defer {
@@ -67,12 +70,20 @@ final class TravelAlbumEntryViewModel {
} catch is CancellationError { } catch is CancellationError {
return return
} catch { } catch {
if !preservingContentOnFailure {
albums = [] albums = []
albumTotal = 0 albumTotal = 0
}
onShowMessage?(error.localizedDescription) onShowMessage?(error.localizedDescription)
} }
} }
/// 用户下拉刷新相册列表;失败时保留当前内容,避免页面瞬间清空。
func refreshAlbums(api: any TravelAlbumServing) async {
guard !isLoading else { return }
await loadAlbums(api: api, preservingContentOnFailure: true)
}
/// 打开创建相册弹窗。 /// 打开创建相册弹窗。
func openCreateSheet(api: any TravelAlbumServing) async { func openCreateSheet(api: any TravelAlbumServing) async {
guard !isCreating else { return } guard !isCreating else { return }
@@ -284,8 +284,8 @@ final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
header.apply( header.apply(
title: category.title, title: category.title,
badge: category == .cover badge: category == .cover
? .gift ? .requiredGift
: (self.viewModel.isOptional(category) ? .optional : nil) : (self.viewModel.isOptional(category) ? .optional : .required)
) )
case .mode: case .mode:
return nil return nil
@@ -845,11 +845,12 @@ final class TravelAlbumAIRetouchModeCell: UICollectionViewCell {
/// 模板分组标题右侧的业务标记。 /// 模板分组标题右侧的业务标记。
fileprivate enum AIRetouchTemplateSectionBadge { fileprivate enum AIRetouchTemplateSectionBadge {
case required
case optional case optional
case gift case requiredGift
} }
/// AI 修图模板分组标题,可附带选填或赠送标记。 /// AI 修图模板分组标题,展示必选、选填或封面赠送规则。
final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView { final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView {
static let reuseIdentifier = "TravelAlbumAIRetouchSectionHeader" static let reuseIdentifier = "TravelAlbumAIRetouchSectionHeader"
@@ -896,12 +897,16 @@ final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView {
titleLabel.text = title titleLabel.text = title
badgeContainer.isHidden = badge == nil badgeContainer.isHidden = badge == nil
switch badge { switch badge {
case .required:
badgeLabel.text = "必选"
badgeLabel.textColor = AIRetouchTemplateStyle.danger
badgeContainer.backgroundColor = AIRetouchTemplateStyle.danger.withAlphaComponent(0.1)
case .optional: case .optional:
badgeLabel.text = "选填" badgeLabel.text = "选填"
badgeLabel.textColor = AIRetouchTemplateStyle.primary badgeLabel.textColor = AIRetouchTemplateStyle.primary
badgeContainer.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1) badgeContainer.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1)
case .gift: case .requiredGift:
badgeLabel.text = "赠送 · 不占额度" badgeLabel.text = "必选 · 赠送 · 不占额度"
badgeLabel.textColor = AIRetouchTemplateStyle.gift badgeLabel.textColor = AIRetouchTemplateStyle.gift
badgeContainer.backgroundColor = AIRetouchTemplateStyle.giftBackground badgeContainer.backgroundColor = AIRetouchTemplateStyle.giftBackground
case nil: case nil:
@@ -36,6 +36,7 @@ final class TravelAlbumEntryViewController: BaseViewController {
private let heroCard = TravelAlbumHeroCard() private let heroCard = TravelAlbumHeroCard()
private let titleLabel = UILabel() private let titleLabel = UILabel()
private let tableView = UITableView(frame: .zero, style: .plain) private let tableView = UITableView(frame: .zero, style: .plain)
private let refreshControl = UIRefreshControl()
private let emptyView = TravelAlbumEmptyView() private let emptyView = TravelAlbumEmptyView()
private var dataSource: UITableViewDiffableDataSource<Int, TravelAlbum>! private var dataSource: UITableViewDiffableDataSource<Int, TravelAlbum>!
private var refreshState = TravelAlbumReturnRefreshState() private var refreshState = TravelAlbumReturnRefreshState()
@@ -65,6 +66,8 @@ final class TravelAlbumEntryViewController: BaseViewController {
tableView.separatorStyle = .none tableView.separatorStyle = .none
tableView.rowHeight = UITableView.automaticDimension tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 148 tableView.estimatedRowHeight = 148
tableView.alwaysBounceVertical = true
tableView.refreshControl = refreshControl
tableView.delegate = self tableView.delegate = self
tableView.register(TravelAlbumTaskCell.self, forCellReuseIdentifier: TravelAlbumTaskCell.reuseIdentifier) tableView.register(TravelAlbumTaskCell.self, forCellReuseIdentifier: TravelAlbumTaskCell.reuseIdentifier)
@@ -84,6 +87,7 @@ final class TravelAlbumEntryViewController: BaseViewController {
view.addSubview(titleLabel) view.addSubview(titleLabel)
view.addSubview(tableView) view.addSubview(tableView)
view.addSubview(emptyView) view.addSubview(emptyView)
emptyView.isUserInteractionEnabled = false
} }
override func setupConstraints() { override func setupConstraints() {
@@ -108,6 +112,7 @@ final class TravelAlbumEntryViewController: BaseViewController {
override func bindActions() { override func bindActions() {
heroCard.addTarget(self, action: #selector(createTapped), for: .touchUpInside) heroCard.addTarget(self, action: #selector(createTapped), for: .touchUpInside)
refreshControl.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
viewModel.onStateChange = { [weak self] in viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() } Task { @MainActor in self?.applyViewModel() }
} }
@@ -138,8 +143,8 @@ final class TravelAlbumEntryViewController: BaseViewController {
heroCard.isLoading = viewModel.isCreating heroCard.isLoading = viewModel.isCreating
titleLabel.text = "我的任务(\(viewModel.albumTotal))" titleLabel.text = "我的任务(\(viewModel.albumTotal))"
titleLabel.isHidden = viewModel.albums.isEmpty titleLabel.isHidden = viewModel.albums.isEmpty
tableView.isHidden = viewModel.albums.isEmpty emptyView.isHidden = !viewModel.albums.isEmpty
emptyView.isHidden = !viewModel.albums.isEmpty || viewModel.isLoading || (viewModel.isLoading && !refreshControl.isRefreshing)
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbum>() var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbum>()
snapshot.appendSections([0]) snapshot.appendSections([0])
snapshot.appendItems(viewModel.albums) snapshot.appendItems(viewModel.albums)
@@ -147,13 +152,23 @@ final class TravelAlbumEntryViewController: BaseViewController {
snapshot, snapshot,
animatingDifferences: !refreshState.suppressesSnapshotAnimations animatingDifferences: !refreshState.suppressesSnapshotAnimations
) )
if viewModel.isLoading && viewModel.albums.isEmpty { if viewModel.isLoading && viewModel.albums.isEmpty && !refreshControl.isRefreshing {
showLoading() showLoading()
} else { } else {
hideLoading() hideLoading()
} }
} }
@objc private func refreshPulled() {
Task {
await viewModel.refreshAlbums(api: api)
await MainActor.run {
self.refreshControl.endRefreshing()
self.applyViewModel()
}
}
}
@objc private func createTapped() { @objc private func createTapped() {
Task { Task {
await viewModel.openCreateSheet(api: api) await viewModel.openCreateSheet(api: api)
@@ -25,7 +25,7 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
XCTAssertTrue(viewModel.shouldShowInitialTips) XCTAssertTrue(viewModel.shouldShowInitialTips)
XCTAssertEqual( XCTAssertEqual(
viewModel.initialTipsText, viewModel.initialTipsText,
"Tips:氛围感修图为选填,可横向选择一种样式;选中后每张照片会额外生成1个独立结果,第一张照片仍另生成封面。" "Tips:氛围感修图为选填;封面风格为必选,将使用第一张照片另生成封面。"
) )
XCTAssertEqual(viewModel.visibleCategories, [.refined, .atmosphere, .cover]) XCTAssertEqual(viewModel.visibleCategories, [.refined, .atmosphere, .cover])
XCTAssertEqual(viewModel.selectedRefinedTemplateId, 11) XCTAssertEqual(viewModel.selectedRefinedTemplateId, 11)
@@ -44,6 +44,14 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
) )
await viewModel.loadTemplates(api: api) await viewModel.loadTemplates(api: api)
XCTAssertEqual(
viewModel.initialTipsText,
"Tips:氛围感修图为选填,选中后每张照片会额外生成1个独立结果。"
)
viewModel.toggleTemplate(id: 11, category: .refined)
XCTAssertEqual(viewModel.selectedRefinedTemplateId, 11)
viewModel.toggleTemplate(id: 21, category: .atmosphere) viewModel.toggleTemplate(id: 21, category: .atmosphere)
XCTAssertEqual(viewModel.selectedAtmosphereTemplateId, 21) XCTAssertEqual(viewModel.selectedAtmosphereTemplateId, 21)
@@ -126,7 +134,10 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
await all.loadTemplates(api: api) await all.loadTemplates(api: api)
XCTAssertEqual(all.visibleCategories, [.refined, .atmosphere]) XCTAssertEqual(all.visibleCategories, [.refined, .atmosphere])
XCTAssertEqual(all.selectedRefinedTemplateId, 11) XCTAssertEqual(all.selectedRefinedTemplateId, 11)
XCTAssertEqual(all.selectedAtmosphereTemplateId, 21) XCTAssertNil(all.selectedAtmosphereTemplateId)
XCTAssertTrue(all.isOptional(.atmosphere))
XCTAssertEqual(all.requiredQuota, 1)
XCTAssertTrue(all.canSubmit)
await all.submit(api: api) await all.submit(api: api)
XCTAssertEqual(api.aiReretouchRequests, [ XCTAssertEqual(api.aiReretouchRequests, [
@@ -149,7 +160,7 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
aiRetouchBatchId: 90, aiRetouchBatchId: 90,
type: .all, type: .all,
refinedTemplateId: 11, refinedTemplateId: 11,
atmosphereTemplateId: 21 atmosphereTemplateId: nil
), ),
]) ])
} }
@@ -172,12 +183,13 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
XCTAssertTrue(api.aiReretouchRequests.isEmpty) XCTAssertTrue(api.aiReretouchRequests.isEmpty)
} }
func testMissingRequiredTemplateDisablesMatchingWorkflow() async { func testMissingOptionalAtmosphereDoesNotDisableAllReretouchWorkflow() async {
let api = makeAPI() let api = makeAPI()
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse( api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
refinedTemplates: [template(11, "清透")], refinedTemplates: [template(11, "清透")],
atmosphereTemplates: [], atmosphereTemplates: [],
coverTemplates: [] coverTemplates: [],
remainingQuota: 100
) )
let viewModel = TravelAlbumAIRetouchTemplateViewModel( let viewModel = TravelAlbumAIRetouchTemplateViewModel(
scenicId: 18, scenicId: 18,
@@ -186,8 +198,42 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
await viewModel.loadTemplates(api: api) await viewModel.loadTemplates(api: api)
XCTAssertFalse(viewModel.canSubmit) XCTAssertTrue(viewModel.canSubmit)
XCTAssertEqual(viewModel.validationMessage, "暂无可用的氛围感修图模板") XCTAssertNil(viewModel.validationMessage)
XCTAssertEqual(viewModel.requiredQuota, 1)
}
func testMissingRequiredRefinedAndCoverTemplatesDisableMatchingWorkflows() async {
let refinedAPI = makeAPI()
refinedAPI.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
refinedTemplates: [],
atmosphereTemplates: [template(21, "暖阳")],
coverTemplates: [template(31, "杂志")],
remainingQuota: 100
)
let reretouch = TravelAlbumAIRetouchTemplateViewModel(
scenicId: 18,
workflow: .reretouch(materialId: 7, batchId: 70, type: .all)
)
await reretouch.loadTemplates(api: refinedAPI)
XCTAssertFalse(reretouch.canSubmit)
XCTAssertEqual(reretouch.validationMessage, "暂无可用的原图精修模板")
let coverAPI = makeAPI()
coverAPI.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
refinedTemplates: [template(11, "清透")],
atmosphereTemplates: [template(21, "暖阳")],
coverTemplates: [],
remainingQuota: 100
)
let batch = TravelAlbumAIRetouchTemplateViewModel(
albumId: 8,
scenicId: 18,
materialIds: [1, 2, 3, 4]
)
await batch.loadTemplates(api: coverAPI)
XCTAssertFalse(batch.canSubmit)
XCTAssertEqual(batch.validationMessage, "暂无可用的封面风格模板")
} }
func testLoadFailureExposesRetryMessageAndKeepsSubmissionDisabled() async { func testLoadFailureExposesRetryMessageAndKeepsSubmissionDisabled() async {
+18 -1
View File
@@ -243,6 +243,7 @@ final class TravelAlbumAPITests: XCTestCase {
jobSubmissionJSON(batchId: 51, albumId: 6), jobSubmissionJSON(batchId: 51, albumId: 6),
jobSubmissionJSON(batchId: 52, albumId: 6), jobSubmissionJSON(batchId: 52, albumId: 6),
jobSubmissionJSON(batchId: 53, albumId: 6), jobSubmissionJSON(batchId: 53, albumId: 6),
jobSubmissionJSON(batchId: 54, albumId: 6),
]) ])
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
@@ -273,13 +274,22 @@ final class TravelAlbumAPITests: XCTestCase {
atmosphereTemplateId: 22 atmosphereTemplateId: 22
) )
) )
try await api.submitAIReretouch(
TravelAlbumAIReretouchRequest(
id: 14,
aiRetouchBatchId: 54,
type: .all,
refinedTemplateId: 21,
atmosphereTemplateId: nil
)
)
let bodies = try session.requests.map { request in let bodies = try session.requests.map { request in
try JSONSerialization.jsonObject(with: XCTUnwrap(request.httpBody)) as? [String: Any] try JSONSerialization.jsonObject(with: XCTUnwrap(request.httpBody)) as? [String: Any]
} }
XCTAssertEqual(session.requests.map { $0.url?.path }, Array( XCTAssertEqual(session.requests.map { $0.url?.path }, Array(
repeating: "/api/yf-handset-app/photog/travel-album/ai-reretouch", repeating: "/api/yf-handset-app/photog/travel-album/ai-reretouch",
count: 3 count: 4
)) ))
XCTAssertEqual(Set(bodies[0]?.keys.map { $0 } ?? []), ["id", "ai_retouch_batch_id", "type", "refined_template_id"]) XCTAssertEqual(Set(bodies[0]?.keys.map { $0 } ?? []), ["id", "ai_retouch_batch_id", "type", "refined_template_id"])
XCTAssertEqual(Set(bodies[1]?.keys.map { $0 } ?? []), ["id", "ai_retouch_batch_id", "type", "atmosphere_template_id"]) XCTAssertEqual(Set(bodies[1]?.keys.map { $0 } ?? []), ["id", "ai_retouch_batch_id", "type", "atmosphere_template_id"])
@@ -290,9 +300,16 @@ final class TravelAlbumAPITests: XCTestCase {
"refined_template_id", "refined_template_id",
"atmosphere_template_id", "atmosphere_template_id",
]) ])
XCTAssertEqual(Set(bodies[3]?.keys.map { $0 } ?? []), [
"id",
"ai_retouch_batch_id",
"type",
"refined_template_id",
])
XCTAssertEqual(bodies[0]?["type"] as? Int, 1) XCTAssertEqual(bodies[0]?["type"] as? Int, 1)
XCTAssertEqual(bodies[1]?["type"] as? Int, 2) XCTAssertEqual(bodies[1]?["type"] as? Int, 2)
XCTAssertEqual(bodies[2]?["type"] as? Int, 3) XCTAssertEqual(bodies[2]?["type"] as? Int, 3)
XCTAssertEqual(bodies[3]?["type"] as? Int, 3)
} }
func testAIJobListBuildsCursorQueryAndDecodesUnknownStatusSafely() async throws { func testAIJobListBuildsCursorQueryAndDecodesUnknownStatusSafely() async throws {
@@ -763,7 +763,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
XCTAssertEqual(selectionCountLabel.text, "已选择 4 张照片") XCTAssertEqual(selectionCountLabel.text, "已选择 4 张照片")
XCTAssertEqual( XCTAssertEqual(
tipsLabel.text, tipsLabel.text,
"Tips:氛围感修图为选填,可横向选择一种样式;选中后每张照片会额外生成1个独立结果,第一张照片仍另生成封面。" "Tips:氛围感修图为选填;封面风格为必选,将使用第一张照片另生成封面。"
) )
XCTAssertFalse(tipsContainer.isHidden) XCTAssertFalse(tipsContainer.isHidden)
XCTAssertEqual(tipsContainer.backgroundColor?.travelAlbumTestHexRGB, 0xF4F8FF) XCTAssertEqual(tipsContainer.backgroundColor?.travelAlbumTestHexRGB, 0xF4F8FF)
@@ -806,10 +806,9 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
} as? UICollectionView } as? UICollectionView
) )
let accessibleLabels = controller.view.allAccessibilityLabels() let accessibleLabels = controller.view.allAccessibilityLabels()
XCTAssertTrue(accessibleLabels.contains("原图精修")) XCTAssertTrue(accessibleLabels.contains("原图精修,必选"))
XCTAssertFalse(accessibleLabels.contains("原图精修,必选"))
XCTAssertTrue(accessibleLabels.contains("氛围感修图,选填")) XCTAssertTrue(accessibleLabels.contains("氛围感修图,选填"))
XCTAssertTrue(accessibleLabels.contains("封面风格,赠送 · 不占额度")) XCTAssertTrue(accessibleLabels.contains("封面风格,必选 · 赠送 · 不占额度"))
collectionView.scrollToItem( collectionView.scrollToItem(
at: IndexPath(item: 0, section: 4), at: IndexPath(item: 0, section: 4),
@@ -954,7 +953,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
XCTAssertEqual(comparisonButton.bounds.size, CGSize(width: 48, height: 48)) XCTAssertEqual(comparisonButton.bounds.size, CGSize(width: 48, height: 48))
} }
func testPreviewAIRetouchUsesSelectedRetouchedTabWorkflowAndShowsTaskAction() async throws { func testPreviewAIRetouchUsesSelectedRetouchedTabWorkflowAndNotifiesSubmission() async throws {
UIView.setAnimationsEnabled(false) UIView.setAnimationsEnabled(false)
defer { UIView.setAnimationsEnabled(true) } defer { UIView.setAnimationsEnabled(true) }
let api = TravelAlbumMockAPI() let api = TravelAlbumMockAPI()
@@ -985,13 +984,15 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
), ),
] ]
) )
var submissionNotified = false
let controller = TravelAlbumPhotoPreviewViewController( let controller = TravelAlbumPhotoPreviewViewController(
projects: [project], projects: [project],
totalCount: 1, totalCount: 1,
startProjectIndex: 0, startProjectIndex: 0,
albumId: 8, albumId: 8,
scenicIdProvider: { 18 }, scenicIdProvider: { 18 },
aiRetouchAPI: api aiRetouchAPI: api,
onAIRetouchSubmitted: { submissionNotified = true }
) )
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
window.rootViewController = controller window.rootViewController = controller
@@ -1048,12 +1049,10 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
XCTAssertTrue(tipsLabel.isHidden) XCTAssertTrue(tipsLabel.isHidden)
confirmButton.sendActions(for: .touchUpInside) confirmButton.sendActions(for: .touchUpInside)
await waitUntil { api.aiReretouchRequests.count == 1 } await waitUntil { api.aiReretouchRequests.count == 1 }
await waitUntil { controller.presentedViewController is UIAlertController } await waitUntil { submissionNotified }
XCTAssertEqual(api.aiReretouchRequests.first?.type, .refined) XCTAssertEqual(api.aiReretouchRequests.first?.type, .refined)
let successAlert = try XCTUnwrap(controller.presentedViewController as? UIAlertController) XCTAssertTrue(submissionNotified)
XCTAssertEqual(successAlert.title, "AI修图任务已提交")
XCTAssertEqual(successAlert.actions.map(\.title), ["知道了", "查看任务"])
XCTAssertTrue(window.rootViewController === controller) XCTAssertTrue(window.rootViewController === controller)
} }
+16 -1
View File
@@ -123,6 +123,19 @@ final class TravelAlbumEntryViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.albums.first?.name, "A") XCTAssertEqual(viewModel.albums.first?.name, "A")
} }
func testPullRefreshFailureKeepsExistingAlbums() async {
let api = TravelAlbumMockAPI()
api.listResponse = TravelAlbumListResponse(total: 1, list: [TravelAlbum(id: 1, name: "A")])
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })
await viewModel.loadAlbums(api: api)
api.listError = APIError.serverCode(500, "刷新失败")
await viewModel.refreshAlbums(api: api)
XCTAssertEqual(viewModel.albumTotal, 1)
XCTAssertEqual(viewModel.albums.map(\.id), [1])
}
func testPreShootRequiresSinglePrice() async { func testPreShootRequiresSinglePrice() async {
let api = TravelAlbumMockAPI() let api = TravelAlbumMockAPI()
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 }) let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })
@@ -1226,6 +1239,7 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
var availableOrdersResponse: [TravelAlbumAvailableOrder] = [] var availableOrdersResponse: [TravelAlbumAvailableOrder] = []
var listResponse = TravelAlbumListResponse<TravelAlbum>() var listResponse = TravelAlbumListResponse<TravelAlbum>()
var listError: Error?
var createResponse = TravelAlbumCreateResponse(id: 0) var createResponse = TravelAlbumCreateResponse(id: 0)
var infoResponse = TravelAlbum() var infoResponse = TravelAlbum()
var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = [] var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = []
@@ -1281,7 +1295,8 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
} }
func list(page: Int, pageSize: Int) async throws -> TravelAlbumListResponse<TravelAlbum> { func list(page: Int, pageSize: Int) async throws -> TravelAlbumListResponse<TravelAlbum> {
listResponse if let listError { throw listError }
return listResponse
} }
func info(id: Int) async throws -> TravelAlbum { func info(id: Int) async throws -> TravelAlbum {