feat: add travel album module
This commit is contained in:
81
suixinkanTests/TravelAlbumAPITests.swift
Normal file
81
suixinkanTests/TravelAlbumAPITests.swift
Normal file
@ -0,0 +1,81 @@
|
||||
//
|
||||
// TravelAlbumAPITests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 旅拍相册 API 测试。
|
||||
final class TravelAlbumAPITests: XCTestCase {
|
||||
func testListBuildsPathAndQuery() async throws {
|
||||
let data = envelopeJSON(#"{"total":1,"list":[]}"#)
|
||||
let session = MockURLSession(responses: [data])
|
||||
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||
|
||||
_ = try await api.list(page: 2, pageSize: 30)
|
||||
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/list")
|
||||
let query = try XCTUnwrap(URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems)
|
||||
XCTAssertEqual(query.first { $0.name == "page" }?.value, "2")
|
||||
XCTAssertEqual(query.first { $0.name == "page_size" }?.value, "30")
|
||||
}
|
||||
|
||||
func testCreateEncodesBody() async throws {
|
||||
let data = envelopeJSON(#"{"id":8}"#)
|
||||
let session = MockURLSession(responses: [data])
|
||||
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||
|
||||
_ = try await api.create(
|
||||
TravelAlbumCreateRequest(
|
||||
name: "2026-07-07-001",
|
||||
type: 1,
|
||||
orderNumber: nil,
|
||||
materialNum: 2,
|
||||
materialPrice: 10.5,
|
||||
materialPackagePrice: 88,
|
||||
photoPrice: 0
|
||||
)
|
||||
)
|
||||
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.httpMethod, "POST")
|
||||
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/create")
|
||||
let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(request.httpBody)) as? [String: Any]
|
||||
XCTAssertEqual(body?["name"] as? String, "2026-07-07-001")
|
||||
XCTAssertEqual(body?["type"] as? Int, 1)
|
||||
XCTAssertEqual(body?["material_num"] as? Int, 2)
|
||||
XCTAssertEqual(body?["material_price"] as? Double, 10.5)
|
||||
}
|
||||
|
||||
func testMaterialListAndDeleteAndMpCode() async throws {
|
||||
let materialList = envelopeJSON(#"{"total":0,"list":[]}"#)
|
||||
let empty = envelopeJSON("{}")
|
||||
let code = envelopeJSON(#"{"mp_code_oss_url":"https://cdn/qr.png"}"#)
|
||||
let session = MockURLSession(responses: [materialList, empty, code])
|
||||
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||
|
||||
_ = try await api.materialList(userEquityTravelId: 3, page: 1, pageSize: 30, orderBy: 4, isPurchased: 1)
|
||||
try await api.deleteAlbum(id: 3)
|
||||
let response = try await api.mpCode(id: 3)
|
||||
|
||||
XCTAssertEqual(response.mpCodeOssUrl, "https://cdn/qr.png")
|
||||
XCTAssertEqual(session.requests[0].url?.path, "/api/yf-handset-app/photog/travel-album/material-list")
|
||||
let query = URLComponents(url: session.requests[0].url!, resolvingAgainstBaseURL: false)?.queryItems
|
||||
XCTAssertEqual(query?.first { $0.name == "user_equity_travel_id" }?.value, "3")
|
||||
XCTAssertEqual(query?.first { $0.name == "order_by" }?.value, "4")
|
||||
XCTAssertEqual(query?.first { $0.name == "is_purchased" }?.value, "1")
|
||||
XCTAssertEqual(session.requests[1].url?.path, "/api/yf-handset-app/photog/travel-album/delete")
|
||||
let body = try JSONSerialization.jsonObject(with: session.requests[1].httpBody!) as? [String: Any]
|
||||
XCTAssertEqual(body?["id"] as? Int, 3)
|
||||
XCTAssertEqual(session.requests[2].url?.path, "/api/yf-handset-app/photog/travel-album/mp-code")
|
||||
}
|
||||
|
||||
private func envelopeJSON(_ dataJSON: String) -> Data {
|
||||
"""
|
||||
{"code":100000,"msg":"success","data":\(dataJSON)}
|
||||
""".data(using: .utf8)!
|
||||
}
|
||||
}
|
||||
92
suixinkanTests/TravelAlbumModelsTests.swift
Normal file
92
suixinkanTests/TravelAlbumModelsTests.swift
Normal file
@ -0,0 +1,92 @@
|
||||
//
|
||||
// TravelAlbumModelsTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 旅拍相册模型与展示工具测试。
|
||||
final class TravelAlbumModelsTests: XCTestCase {
|
||||
func testTravelAlbumDecodesSnakeCaseFields() throws {
|
||||
let json = """
|
||||
{
|
||||
"id": 7,
|
||||
"store_user_id": 2,
|
||||
"name": "旅拍相册",
|
||||
"type": 1,
|
||||
"order_number": "NO001",
|
||||
"material_num": 3,
|
||||
"material_price": 1200,
|
||||
"material_package_price": 5000,
|
||||
"photo_price": 0,
|
||||
"cover_url": "https://cdn/cover.jpg",
|
||||
"user_id": 9,
|
||||
"status": 1,
|
||||
"created_at": "2026-07-07 10:00:00",
|
||||
"updated_at": "2026-07-07 10:10:00",
|
||||
"user": {"id": 4, "phone": "13800138000"}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let album = try JSONDecoder().decode(TravelAlbum.self, from: json)
|
||||
XCTAssertEqual(album.id, 7)
|
||||
XCTAssertEqual(album.storeUserId, 2)
|
||||
XCTAssertEqual(album.orderNumber, "NO001")
|
||||
XCTAssertEqual(album.displayPhone, "13800138000")
|
||||
}
|
||||
|
||||
func testTravelAlbumMaterialDecodesSnakeCaseFields() throws {
|
||||
let json = """
|
||||
{
|
||||
"id": 11,
|
||||
"user_equity_travel_id": 7,
|
||||
"status": 1,
|
||||
"order_number": "",
|
||||
"user_id": 9,
|
||||
"file_name": "IMG_0001.JPG",
|
||||
"file_type": 2,
|
||||
"file_url": "https://cdn/a.jpg",
|
||||
"file_size": 2048,
|
||||
"cover_url": "",
|
||||
"is_purchased": false,
|
||||
"created_at": "",
|
||||
"updated_at": ""
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let material = try JSONDecoder().decode(TravelAlbumMaterial.self, from: json)
|
||||
XCTAssertEqual(material.userEquityTravelId, 7)
|
||||
XCTAssertEqual(material.fileName, "IMG_0001.JPG")
|
||||
XCTAssertFalse(material.isPurchased)
|
||||
}
|
||||
|
||||
func testDisplayFormatters() {
|
||||
XCTAssertEqual(TravelAlbumDisplayFormatter.maskPhone("13800138000"), "138****8000")
|
||||
XCTAssertEqual(TravelAlbumDisplayFormatter.maskPhone(""), "--")
|
||||
XCTAssertEqual(TravelAlbumDisplayFormatter.fileSizeText(2048), "2.0KB")
|
||||
XCTAssertEqual(TravelAlbumDisplayFormatter.fileSizeText(2 * 1024 * 1024), "2.0MB")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testBuildTodayAlbumNameUsesExistingSameDayCount() {
|
||||
let date = Calendar(identifier: .gregorian).date(from: DateComponents(year: 2026, month: 7, day: 7))!
|
||||
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 }, dateProvider: { date })
|
||||
viewModel.onStateChange = {}
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.listResponse = TravelAlbumListResponse(
|
||||
total: 2,
|
||||
list: [
|
||||
TravelAlbum(id: 1, createdAt: "2026-07-07 09:00:00"),
|
||||
TravelAlbum(id: 2, createdAt: "2026-07-06 09:00:00"),
|
||||
]
|
||||
)
|
||||
let exp = expectation(description: "load")
|
||||
Task {
|
||||
await viewModel.loadAlbums(api: api)
|
||||
XCTAssertEqual(viewModel.buildTodayAlbumName(), "2026-07-07-002")
|
||||
exp.fulfill()
|
||||
}
|
||||
wait(for: [exp], timeout: 1)
|
||||
}
|
||||
}
|
||||
221
suixinkanTests/TravelAlbumViewModelTests.swift
Normal file
221
suixinkanTests/TravelAlbumViewModelTests.swift
Normal file
@ -0,0 +1,221 @@
|
||||
//
|
||||
// TravelAlbumViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 旅拍相册 ViewModel 测试。
|
||||
final class TravelAlbumEntryViewModelTests: XCTestCase {
|
||||
func testLoadAlbumsUpdatesListAndTotal() 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)
|
||||
|
||||
XCTAssertEqual(viewModel.albumTotal, 1)
|
||||
XCTAssertEqual(viewModel.albums.first?.name, "A")
|
||||
}
|
||||
|
||||
func testPreShootRequiresSinglePrice() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })
|
||||
var message = ""
|
||||
viewModel.onShowMessage = { message = $0 }
|
||||
|
||||
await viewModel.confirmCreate(mode: .preShoot, freeCount: "", singlePrice: "", packagePrice: "", order: nil, api: api)
|
||||
|
||||
XCTAssertEqual(message, "请输入单张照片价格")
|
||||
XCTAssertEqual(api.createRequests.count, 0)
|
||||
}
|
||||
|
||||
func testPreOrderRequiresOrder() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })
|
||||
var message = ""
|
||||
viewModel.onShowMessage = { message = $0 }
|
||||
|
||||
await viewModel.confirmCreate(mode: .preOrder, freeCount: "", singlePrice: "", packagePrice: "", order: nil, api: api)
|
||||
|
||||
XCTAssertEqual(message, "请选择绑定订单")
|
||||
}
|
||||
|
||||
func testCreateSuccessRefreshesAlbums() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.createResponse = TravelAlbumCreateResponse(id: 9)
|
||||
api.listResponse = TravelAlbumListResponse(total: 1, list: [TravelAlbum(id: 9)])
|
||||
let date = Calendar(identifier: .gregorian).date(from: DateComponents(year: 2026, month: 7, day: 7))!
|
||||
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 }, dateProvider: { date })
|
||||
|
||||
await viewModel.confirmCreate(mode: .preShoot, freeCount: "2", singlePrice: "12.5", packagePrice: "88", order: nil, api: api)
|
||||
|
||||
XCTAssertEqual(api.createRequests.first?.name, "2026-07-07-001")
|
||||
XCTAssertEqual(api.createRequests.first?.type, 1)
|
||||
XCTAssertEqual(viewModel.albums.first?.id, 9)
|
||||
XCTAssertFalse(viewModel.isCreateSheetVisible)
|
||||
}
|
||||
|
||||
func testOrderAlreadyBoundReloadsOrders() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.createError = APIError.serverCode(100001, "订单已关联")
|
||||
api.availableOrdersResponse = [TravelAlbumAvailableOrder(projectName: "新订单", orderNumber: "NO1")]
|
||||
let viewModel = TravelAlbumEntryViewModel(currentScenicIdProvider: { 1 })
|
||||
let order = TravelAlbumAvailableOrder(projectName: "项目", orderNumber: "NO0")
|
||||
|
||||
await viewModel.confirmCreate(mode: .preOrder, freeCount: "", singlePrice: "", packagePrice: "", order: order, api: api)
|
||||
|
||||
XCTAssertEqual(api.availableOrdersCallCount, 1)
|
||||
XCTAssertEqual(viewModel.availableOrders.first?.projectName, "新订单")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 旅拍相册详情 ViewModel 测试。
|
||||
final class TravelAlbumDetailViewModelTests: XCTestCase {
|
||||
func testRefreshLoadsInfoAndMaterials() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.infoResponse = TravelAlbum(id: 2, name: "详情")
|
||||
api.materialListResponses = [
|
||||
TravelAlbumListResponse(total: 2, list: []),
|
||||
TravelAlbumListResponse(total: 2, list: [TravelAlbumMaterial(id: 1), TravelAlbumMaterial(id: 2)]),
|
||||
]
|
||||
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
|
||||
|
||||
await viewModel.refreshAll(api: api)
|
||||
|
||||
XCTAssertEqual(viewModel.album?.name, "详情")
|
||||
XCTAssertEqual(viewModel.allPhotoCount, 2)
|
||||
XCTAssertEqual(viewModel.materials.count, 2)
|
||||
}
|
||||
|
||||
func testTabAndSortTriggerMaterialRequests() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.materialListResponses = [
|
||||
TravelAlbumListResponse(total: 0, list: []),
|
||||
TravelAlbumListResponse(total: 0, list: []),
|
||||
]
|
||||
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
|
||||
|
||||
await viewModel.selectTab(.purchased, api: api)
|
||||
await viewModel.setSortOption(.fileNameDesc, api: api)
|
||||
|
||||
XCTAssertEqual(api.materialRequests.first?.isPurchased, 1)
|
||||
XCTAssertEqual(api.materialRequests.last?.orderBy, 4)
|
||||
}
|
||||
|
||||
func testOnlyUnpurchasedMaterialCanBeSelected() {
|
||||
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
|
||||
viewModel.toggleSelectionMode()
|
||||
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, status: 2))
|
||||
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
|
||||
|
||||
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, status: 1))
|
||||
XCTAssertEqual(viewModel.selectedMaterialIds, [1])
|
||||
}
|
||||
|
||||
func testDeleteAlbumCallsCallback() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
let viewModel = TravelAlbumDetailViewModel(albumId: 5)
|
||||
var deletedId = 0
|
||||
viewModel.onDeletedAlbum = { deletedId = $0 }
|
||||
|
||||
await viewModel.deleteAlbum(api: api)
|
||||
|
||||
XCTAssertEqual(api.deletedAlbumIds, [5])
|
||||
XCTAssertEqual(deletedId, 5)
|
||||
}
|
||||
}
|
||||
|
||||
/// 有线传输 ViewModel 测试。
|
||||
final class WiredCameraTransferViewModelTests: XCTestCase {
|
||||
func testDefaultDisconnectedStateDoesNotDependOnBusinessServices() {
|
||||
let viewModel = WiredCameraTransferViewModel(albumId: 1, albumTitle: "相册", headerPhone: "13800138000")
|
||||
var message = ""
|
||||
viewModel.onShowMessage = { message = $0 }
|
||||
|
||||
XCTAssertEqual(viewModel.cameraStatusText, "未连接 USB")
|
||||
XCTAssertEqual(viewModel.tabCounts, [0, 0, 0])
|
||||
viewModel.showUIOnlyMessage()
|
||||
XCTAssertEqual(message, "仅同步 UI,暂未接入 OTG 传输")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 旅拍相册 API 测试替身。
|
||||
final class TravelAlbumMockAPI: TravelAlbumServing {
|
||||
struct MaterialRequest: Equatable {
|
||||
let userEquityTravelId: Int
|
||||
let page: Int
|
||||
let pageSize: Int
|
||||
let orderBy: Int
|
||||
let isPurchased: Int?
|
||||
}
|
||||
|
||||
var availableOrdersResponse: [TravelAlbumAvailableOrder] = []
|
||||
var listResponse = TravelAlbumListResponse<TravelAlbum>()
|
||||
var createResponse = TravelAlbumCreateResponse(id: 0)
|
||||
var infoResponse = TravelAlbum()
|
||||
var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = []
|
||||
var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "")
|
||||
var createError: Error?
|
||||
|
||||
private(set) var availableOrdersCallCount = 0
|
||||
private(set) var createRequests: [TravelAlbumCreateRequest] = []
|
||||
private(set) var materialRequests: [MaterialRequest] = []
|
||||
private(set) var deletedAlbumIds: [Int] = []
|
||||
private(set) var deletedMaterialIds: [Int] = []
|
||||
|
||||
func availableOrders() async throws -> [TravelAlbumAvailableOrder] {
|
||||
availableOrdersCallCount += 1
|
||||
return availableOrdersResponse
|
||||
}
|
||||
|
||||
func create(_ request: TravelAlbumCreateRequest) async throws -> TravelAlbumCreateResponse {
|
||||
createRequests.append(request)
|
||||
if let createError { throw createError }
|
||||
return createResponse
|
||||
}
|
||||
|
||||
func list(page: Int, pageSize: Int) async throws -> TravelAlbumListResponse<TravelAlbum> {
|
||||
listResponse
|
||||
}
|
||||
|
||||
func info(id: Int) async throws -> TravelAlbum {
|
||||
infoResponse
|
||||
}
|
||||
|
||||
func materialList(
|
||||
userEquityTravelId: Int,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
orderBy: Int,
|
||||
isPurchased: Int?
|
||||
) async throws -> TravelAlbumListResponse<TravelAlbumMaterial> {
|
||||
materialRequests.append(
|
||||
MaterialRequest(
|
||||
userEquityTravelId: userEquityTravelId,
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
orderBy: orderBy,
|
||||
isPurchased: isPurchased
|
||||
)
|
||||
)
|
||||
if materialListResponses.isEmpty { return TravelAlbumListResponse() }
|
||||
return materialListResponses.removeFirst()
|
||||
}
|
||||
|
||||
func deleteAlbum(id: Int) async throws {
|
||||
deletedAlbumIds.append(id)
|
||||
}
|
||||
|
||||
func deleteMaterial(id: Int) async throws {
|
||||
deletedMaterialIds.append(id)
|
||||
}
|
||||
|
||||
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse {
|
||||
mpCodeResponse
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user