重构相册详情为信息卡片、Tab 筛选、排序、批量删除与底部上传栏;修复网格重叠、禁用按钮蒙层,并支持点击预览大图。同步扩展素材列表 API 与 ViewModel 分页逻辑,并优化有线传图缩略图与传输性能。 Co-authored-by: Cursor <cursoragent@cursor.com>
264 lines
9.6 KiB
Swift
264 lines
9.6 KiB
Swift
//
|
|
// TravelAlbumViewModelTests.swift
|
|
// suixinkanTests
|
|
//
|
|
// Created by Codex on 2026/6/29.
|
|
//
|
|
|
|
import XCTest
|
|
@testable import suixinkan
|
|
|
|
@MainActor
|
|
/// 旅拍相册 ViewModel 测试。
|
|
final class TravelAlbumViewModelTests: XCTestCase {
|
|
/// 测试先拍再买模式缺少单价时创建失败。
|
|
func testCreatePreShootRequiresSinglePrice() async {
|
|
let viewModel = TravelAlbumEntryViewModel()
|
|
let api = MockTravelAlbumAPI()
|
|
|
|
let success = await viewModel.confirmCreateTask(
|
|
api: api,
|
|
mode: TravelAlbumEntryViewModel.modePreShoot,
|
|
freeCount: "3",
|
|
singlePrice: "",
|
|
packagePrice: "99",
|
|
order: nil,
|
|
scenicSelected: true
|
|
)
|
|
|
|
XCTAssertFalse(success)
|
|
XCTAssertEqual(viewModel.errorMessage, "请输入单张照片价格")
|
|
XCTAssertEqual(api.createCallCount, 0)
|
|
}
|
|
|
|
/// 测试买了再拍模式缺少订单时创建失败。
|
|
func testCreatePreOrderRequiresOrder() async {
|
|
let viewModel = TravelAlbumEntryViewModel()
|
|
let api = MockTravelAlbumAPI()
|
|
|
|
let success = await viewModel.confirmCreateTask(
|
|
api: api,
|
|
mode: TravelAlbumEntryViewModel.modePreOrder,
|
|
freeCount: "",
|
|
singlePrice: "",
|
|
packagePrice: "",
|
|
order: nil,
|
|
scenicSelected: true
|
|
)
|
|
|
|
XCTAssertFalse(success)
|
|
XCTAssertEqual(viewModel.errorMessage, "请选择绑定订单")
|
|
}
|
|
|
|
/// 测试创建成功后刷新列表。
|
|
func testCreateSuccessReloadsList() async {
|
|
let viewModel = TravelAlbumEntryViewModel()
|
|
let api = MockTravelAlbumAPI()
|
|
|
|
let success = await viewModel.confirmCreateTask(
|
|
api: api,
|
|
mode: TravelAlbumEntryViewModel.modePreShoot,
|
|
freeCount: "3",
|
|
singlePrice: "9.9",
|
|
packagePrice: "99",
|
|
order: nil,
|
|
scenicSelected: true
|
|
)
|
|
|
|
XCTAssertTrue(success)
|
|
XCTAssertEqual(api.createCallCount, 1)
|
|
XCTAssertEqual(api.listCallCount, 1)
|
|
}
|
|
|
|
/// 测试相册名称按日期递增。
|
|
func testBuildTodayAlbumNameIncrementsSuffix() {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "yyyy-MM-dd"
|
|
let today = formatter.string(from: Date())
|
|
|
|
let name = TravelAlbumEntryViewModel.buildTodayAlbumName(
|
|
existingNames: ["\(today)-001", "\(today)-002", "2026-06-28-001"]
|
|
)
|
|
XCTAssertTrue(name.hasSuffix("003"))
|
|
}
|
|
|
|
/// 测试切换已购 Tab 时携带 is_purchased 参数并重置选择状态。
|
|
func testDetailSelectPurchasedTabUsesPurchasedFilter() async {
|
|
let viewModel = TravelAlbumDetailViewModel()
|
|
let api = MockTravelAlbumDetailAPI()
|
|
|
|
await viewModel.refreshAll(api: api, albumID: 8)
|
|
viewModel.isSelectionMode = true
|
|
viewModel.selectedMaterialIDs = [1]
|
|
|
|
await viewModel.selectTab(TravelAlbumDetailViewModel.tabPurchased, api: api)
|
|
|
|
XCTAssertEqual(viewModel.selectedTab, TravelAlbumDetailViewModel.tabPurchased)
|
|
XCTAssertFalse(viewModel.isSelectionMode)
|
|
XCTAssertTrue(viewModel.selectedMaterialIDs.isEmpty)
|
|
XCTAssertEqual(api.materialListCalls.last?.isPurchased, 1)
|
|
}
|
|
|
|
/// 测试排序变更触发重载。
|
|
func testDetailSetOrderByReloadsMaterials() async {
|
|
let viewModel = TravelAlbumDetailViewModel()
|
|
let api = MockTravelAlbumDetailAPI()
|
|
|
|
await viewModel.refreshAll(api: api, albumID: 8)
|
|
let initialCount = api.materialListCalls.count
|
|
|
|
await viewModel.setOrderBy(3, api: api)
|
|
|
|
XCTAssertEqual(viewModel.orderBy, 3)
|
|
XCTAssertGreaterThan(api.materialListCalls.count, initialCount)
|
|
XCTAssertEqual(api.materialListCalls.last?.orderBy, 3)
|
|
}
|
|
|
|
/// 测试仅未购买素材可选中。
|
|
func testDetailToggleSelectionRejectsPurchasedMaterial() async {
|
|
let viewModel = TravelAlbumDetailViewModel()
|
|
viewModel.isSelectionMode = true
|
|
|
|
let purchased = try! decodeMaterial(id: 2, status: 2)
|
|
viewModel.toggleMaterialSelection(purchased)
|
|
XCTAssertTrue(viewModel.selectedMaterialIDs.isEmpty)
|
|
XCTAssertEqual(viewModel.errorMessage, "仅未购买素材可删除")
|
|
|
|
let unpurchased = try! decodeMaterial(id: 3, status: TravelAlbumDetailViewModel.materialStatusUnpurchased)
|
|
viewModel.toggleMaterialSelection(unpurchased)
|
|
XCTAssertEqual(viewModel.selectedMaterialIDs, [3])
|
|
}
|
|
|
|
/// 测试批量删除按顺序调用 API。
|
|
func testDetailConfirmDeleteSelectedMaterialsDeletesInOrder() async {
|
|
let viewModel = TravelAlbumDetailViewModel()
|
|
let api = MockTravelAlbumDetailAPI()
|
|
viewModel.selectedMaterialIDs = [11, 12, 13]
|
|
|
|
let success = await viewModel.confirmDeleteSelectedMaterials(api: api)
|
|
|
|
XCTAssertTrue(success)
|
|
XCTAssertEqual(Set(api.deletedMaterialIDs), Set([11, 12, 13]))
|
|
XCTAssertFalse(viewModel.isSelectionMode)
|
|
XCTAssertTrue(viewModel.selectedMaterialIDs.isEmpty)
|
|
}
|
|
|
|
/// 测试全部 Tab 下 allPhotoCount 与 total 同步。
|
|
func testDetailAllPhotoCountSyncsWithTotalOnAllTab() async {
|
|
let viewModel = TravelAlbumDetailViewModel()
|
|
let api = MockTravelAlbumDetailAPI(materialTotal: 42)
|
|
|
|
await viewModel.refreshAll(api: api, albumID: 8)
|
|
|
|
XCTAssertEqual(viewModel.allPhotoCount, 42)
|
|
}
|
|
}
|
|
|
|
private func decodeMaterial(id: Int, status: Int) throws -> TravelAlbumMaterial {
|
|
let json = """
|
|
{"code":100000,"msg":"success","data":{"id":"\(id)","user_equity_travel_id":"8","status":"\(status)","order_number":"","user_id":"100","file_name":"DSC.JPG","file_type":"2","file_url":"https://cdn/a.jpg","file_size":"2048","cover_url":"","is_purchased":"0","created_at":"2026","updated_at":"2026"}}
|
|
"""
|
|
let data = Data(json.utf8)
|
|
let envelope = try JSONDecoder().decode(APIEnvelope<TravelAlbumMaterial>.self, from: data)
|
|
return try XCTUnwrap(envelope.data)
|
|
}
|
|
|
|
@MainActor
|
|
private final class MockTravelAlbumDetailAPI: TravelAlbumServing {
|
|
struct MaterialListCall {
|
|
let orderBy: Int
|
|
let isPurchased: Int?
|
|
let page: Int
|
|
}
|
|
|
|
var materialTotal: Int
|
|
var materialListCalls: [MaterialListCall] = []
|
|
var deletedMaterialIDs: [Int] = []
|
|
|
|
init(materialTotal: Int = 5) {
|
|
self.materialTotal = materialTotal
|
|
}
|
|
|
|
func availableOrders() async throws -> [TravelAlbumAvailableOrder] { [] }
|
|
func createAlbum(_ request: TravelAlbumCreateRequest) async throws -> TravelAlbumCreateResponse {
|
|
TravelAlbumCreateResponse(id: 1)
|
|
}
|
|
func albumList(page: Int, pageSize: Int) async throws -> ListPayload<TravelAlbumItem> {
|
|
ListPayload(total: 0, list: [])
|
|
}
|
|
func albumInfo(id: Int) async throws -> TravelAlbumItem {
|
|
let data = Data(
|
|
#"{"code":100000,"msg":"success","data":{"id":"8","store_user_id":"1","name":"2026-06-30-001","type":"1","order_number":"","material_num":"3","material_price":"990","material_package_price":"9900","photo_price":"0","cover_url":"","user_id":"100","status":"1","created_at":"2026-06-30","updated_at":"2026-06-30","user":{"id":"200","phone":"13800138000"}}}"#.utf8
|
|
)
|
|
let envelope = try JSONDecoder().decode(APIEnvelope<TravelAlbumItem>.self, from: data)
|
|
return try XCTUnwrap(envelope.data)
|
|
}
|
|
func editAlbum(_ request: TravelAlbumEditRequest) async throws {}
|
|
func deleteAlbum(_ request: TravelAlbumDeleteRequest) async throws {}
|
|
func materialList(
|
|
userEquityTravelId: Int,
|
|
page: Int,
|
|
pageSize: Int,
|
|
orderBy: Int,
|
|
isPurchased: Int?
|
|
) async throws -> ListPayload<TravelAlbumMaterial> {
|
|
materialListCalls.append(MaterialListCall(orderBy: orderBy, isPurchased: isPurchased, page: page))
|
|
return ListPayload(total: materialTotal, list: [])
|
|
}
|
|
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial {
|
|
throw APIError.emptyData
|
|
}
|
|
func deleteMaterial(_ request: TravelAlbumDeleteMaterialRequest) async throws {
|
|
deletedMaterialIDs.append(request.id)
|
|
}
|
|
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse {
|
|
TravelAlbumMpCodeResponse(mpCodeOssURL: "")
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class MockTravelAlbumAPI: TravelAlbumServing {
|
|
var createCallCount = 0
|
|
var listCallCount = 0
|
|
|
|
func availableOrders() async throws -> [TravelAlbumAvailableOrder] { [] }
|
|
|
|
func createAlbum(_ request: TravelAlbumCreateRequest) async throws -> TravelAlbumCreateResponse {
|
|
createCallCount += 1
|
|
return TravelAlbumCreateResponse(id: 9)
|
|
}
|
|
|
|
func albumList(page: Int, pageSize: Int) async throws -> ListPayload<TravelAlbumItem> {
|
|
listCallCount += 1
|
|
return ListPayload(total: 0, list: [])
|
|
}
|
|
|
|
func albumInfo(id: Int) async throws -> TravelAlbumItem {
|
|
throw APIError.emptyData
|
|
}
|
|
|
|
func editAlbum(_ request: TravelAlbumEditRequest) async throws {}
|
|
|
|
func deleteAlbum(_ request: TravelAlbumDeleteRequest) async throws {}
|
|
|
|
func materialList(
|
|
userEquityTravelId: Int,
|
|
page: Int,
|
|
pageSize: Int,
|
|
orderBy: Int,
|
|
isPurchased: Int?
|
|
) async throws -> ListPayload<TravelAlbumMaterial> {
|
|
ListPayload(total: 0, list: [])
|
|
}
|
|
|
|
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial {
|
|
throw APIError.emptyData
|
|
}
|
|
|
|
func deleteMaterial(_ request: TravelAlbumDeleteMaterialRequest) async throws {}
|
|
|
|
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse {
|
|
TravelAlbumMpCodeResponse(mpCodeOssURL: "")
|
|
}
|
|
}
|