Add TravelAlbum, ProfileSpace, and wired camera transfer modules.
Introduce travel album entry with Sony PTP tethering pipeline, profile space settings page, home routing updates, Launch Screen storyboard, and related tests/docs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -0,0 +1,70 @@
|
||||
//
|
||||
// CameraTransferPipelineTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 相机传输管道测试,使用 Mock 相机与上传 Sink。
|
||||
final class CameraTransferPipelineTests: XCTestCase {
|
||||
/// 测试新照片下载后触发上传 Sink。
|
||||
func testNewAssetTriggersUploadSink() async {
|
||||
let camera = MockCameraService()
|
||||
let sink = MockUploadSink()
|
||||
let pipeline = CameraTransferPipeline(cameraService: camera)
|
||||
pipeline.configure(uploadSink: sink, uploadEnabled: true)
|
||||
|
||||
let asset = CameraAsset(id: "ptp_1", filename: "DSC_001.JPG", fileSize: 1024)
|
||||
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("DSC_001.JPG")
|
||||
try? Data(repeating: 0xFF, count: 8).write(to: tempURL)
|
||||
|
||||
camera.onNewAsset?(asset)
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
|
||||
XCTAssertEqual(sink.uploadCount, 1)
|
||||
try? FileManager.default.removeItem(at: tempURL)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MockCameraService: CameraServiceProtocol {
|
||||
let brand: CameraBrand = .sony
|
||||
var connectionState: CameraConnectionState = .connected(deviceName: "Mock")
|
||||
var onConnectionStateChange: ((CameraConnectionState) -> Void)?
|
||||
var onNewAsset: ((CameraAsset) -> Void)?
|
||||
|
||||
func connect() async {}
|
||||
func disconnect() async {}
|
||||
|
||||
func listAssets() async throws -> [CameraAsset] {
|
||||
[]
|
||||
}
|
||||
|
||||
func downloadAsset(_ asset: CameraAsset) async throws -> URL {
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(asset.filename)
|
||||
if !FileManager.default.fileExists(atPath: url.path) {
|
||||
try Data(repeating: 0xFF, count: 8).write(to: url)
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MockUploadSink: CameraAssetUploadSink {
|
||||
var uploadCount = 0
|
||||
|
||||
func upload(
|
||||
localURL: URL,
|
||||
fileName: String,
|
||||
fileType: Int,
|
||||
progress: @escaping @Sendable (Int) -> Void
|
||||
) async throws -> String {
|
||||
uploadCount += 1
|
||||
progress(100)
|
||||
return "https://cdn/mock/\(fileName)"
|
||||
}
|
||||
}
|
||||
63
suixinkanTests/CameraTransfer/SonyPTPCommandsTests.swift
Normal file
63
suixinkanTests/CameraTransfer/SonyPTPCommandsTests.swift
Normal file
@ -0,0 +1,63 @@
|
||||
//
|
||||
// SonyPTPCommandsTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// Sony PTP 命令构建与解析测试。
|
||||
final class SonyPTPCommandsTests: XCTestCase {
|
||||
/// 测试 OpenSession 命令包长度与 opcode。
|
||||
func testBuildOpenSessionCommand() {
|
||||
let data = SonyPTPHelper.buildCommand(
|
||||
code: SonyPTPCommand.openSession,
|
||||
transactionID: 1,
|
||||
parameters: [1]
|
||||
)
|
||||
|
||||
XCTAssertEqual(data.count, 16)
|
||||
XCTAssertEqual(data[6], 0x02)
|
||||
XCTAssertEqual(data[7], 0x10)
|
||||
}
|
||||
|
||||
/// 测试 SDIO Connect 命令 opcode。
|
||||
func testBuildSonyConnectCommand() {
|
||||
let data = SonyPTPHelper.buildCommand(
|
||||
code: SonyPTPCommand.sdioConnect,
|
||||
transactionID: 42,
|
||||
parameters: [1, 0, 0]
|
||||
)
|
||||
|
||||
XCTAssertEqual(data.count, 24)
|
||||
XCTAssertEqual(data[6], 0x01)
|
||||
XCTAssertEqual(data[7], 0x92)
|
||||
}
|
||||
|
||||
/// 测试 GetObject 压缩大小解析。
|
||||
func testParseObjectCompressedSize() {
|
||||
var data = Data(repeating: 0, count: 52)
|
||||
data[8] = 0x00
|
||||
data[9] = 0x10
|
||||
data[10] = 0x00
|
||||
data[11] = 0x00
|
||||
|
||||
XCTAssertEqual(SonyPTPHelper.parseObjectCompressedSize(data), 0x1000)
|
||||
}
|
||||
|
||||
/// 测试 SDIE ObjectAdded 事件解析。
|
||||
func testParseObjectAddedEvent() throws {
|
||||
var event = Data()
|
||||
event.append(contentsOf: [0x10, 0x00, 0x00, 0x00])
|
||||
event.append(contentsOf: [0x04, 0x00])
|
||||
event.append(contentsOf: [0x01, 0xC2])
|
||||
event.append(contentsOf: [0xFF, 0xFF, 0xFF, 0xFF])
|
||||
event.append(contentsOf: [0x01, 0xC0, 0xFF, 0xFF])
|
||||
|
||||
let container = try SonyPTPHelper.parseContainer(event)
|
||||
XCTAssertEqual(container.code, SonyPTPCommand.sdieObjectAdded)
|
||||
XCTAssertEqual(container.params.first, SonyPTPCommand.shotObjectHandle)
|
||||
}
|
||||
}
|
||||
@ -65,6 +65,7 @@ final class HomeMenuRouterTests: XCTestCase {
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "queue_management", title: ""), .destination(.queueManagement))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_stream_management", title: ""), .destination(.liveManagement))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_album", title: ""), .destination(.liveAlbum))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "travel_album", title: ""), .destination(.travelAlbumEntry))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "operating-area", title: ""), .destination(.operatingArea))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "pilot_cert", title: ""), .destination(.pilotCertification))
|
||||
}
|
||||
@ -212,6 +213,7 @@ final class HomeMenuRouterTests: XCTestCase {
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "queue_management", title: ""), .destination(.queueManagement))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_stream_management", title: ""), .destination(.liveManagement))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_album", title: ""), .destination(.liveAlbum))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "travel_album", title: ""), .destination(.travelAlbumEntry))
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
351
suixinkanTests/ProfileSpace/ProfileSpaceTests.swift
Normal file
351
suixinkanTests/ProfileSpace/ProfileSpaceTests.swift
Normal file
@ -0,0 +1,351 @@
|
||||
//
|
||||
// ProfileSpaceTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 个人空间配置测试,覆盖 API、ViewModel 校验和首页路由接入。
|
||||
final class ProfileSpaceTests: XCTestCase {
|
||||
/// 测试个人空间信息接口路径、查询参数和响应解码。
|
||||
func testProfileSpaceInfoAPIRequestsExpectedEndpoint() async throws {
|
||||
let session = ProfileSpaceURLSession(responses: [Self.profileInfoEnvelope])
|
||||
let api = ProfileAPI(client: APIClient(session: session))
|
||||
|
||||
let response = try await api.profileSpaceInfo(scenicId: 9)
|
||||
|
||||
XCTAssertEqual(response.nickname, "云端摄影师")
|
||||
XCTAssertEqual(response.cameraDevice, ["Sony A7M4"])
|
||||
XCTAssertEqual(response.schedule["2026-06-29"]?.first?.name, "上午拍摄")
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.httpMethod, "GET")
|
||||
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/profile/info")
|
||||
let queryItems = URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems
|
||||
XCTAssertEqual(queryItems?.first(where: { $0.name == "scenic_id" })?.value, "9")
|
||||
}
|
||||
|
||||
/// 测试个人空间更新接口使用正确路径和请求体。
|
||||
func testUpdateProfileSpaceAPIEncodesExpectedBody() async throws {
|
||||
let session = ProfileSpaceURLSession(responses: [Self.emptyEnvelope])
|
||||
let api = ProfileAPI(client: APIClient(session: session))
|
||||
let request = UpdateProfileSpaceRequest(
|
||||
scenicId: "9",
|
||||
description: "简介",
|
||||
cameraDevice: ["Sony"],
|
||||
attrLabel: ["亲和"],
|
||||
shootLabel: ["夜景"],
|
||||
businessStartTime: "09:00:00",
|
||||
businessEndTime: "18:00:00",
|
||||
holidayStartTime: "10:00:00",
|
||||
holidayEndTime: "20:00:00",
|
||||
businessRange: [],
|
||||
acceptOrderStatus: 1
|
||||
)
|
||||
|
||||
try await api.updateProfileSpaceInfo(request)
|
||||
|
||||
let urlRequest = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(urlRequest.httpMethod, "POST")
|
||||
XCTAssertEqual(urlRequest.url?.path, "/api/yf-handset-app/photog/profile/info-update")
|
||||
let body = try XCTUnwrap(urlRequest.httpBody)
|
||||
let json = try JSONSerialization.jsonObject(with: body) as? [String: Any]
|
||||
XCTAssertEqual(json?["scenic_id"] as? String, "9")
|
||||
XCTAssertEqual(json?["description"] as? String, "简介")
|
||||
XCTAssertEqual(json?["accept_order_status"] as? Int, 1)
|
||||
XCTAssertEqual(json?["business_range"] as? [Int], [])
|
||||
}
|
||||
|
||||
/// 测试加载成功后回填页面状态。
|
||||
func testViewModelLoadAppliesProfileSpaceState() async throws {
|
||||
let api = MockProfileSpaceAPI()
|
||||
let viewModel = ProfileSpaceViewModel()
|
||||
|
||||
await viewModel.load(api: api, scenicId: 9)
|
||||
|
||||
XCTAssertEqual(viewModel.nickname, "云端摄影师")
|
||||
XCTAssertEqual(viewModel.attrLabels, ["亲和", "构图"])
|
||||
XCTAssertEqual(viewModel.cameraDevices, ["Sony A7M4"])
|
||||
XCTAssertEqual(viewModel.acceptOrderStatus, 1)
|
||||
XCTAssertEqual(viewModel.scheduleMap["2026-06-29"]?.count, 1)
|
||||
XCTAssertFalse(viewModel.hasChanges)
|
||||
}
|
||||
|
||||
/// 测试缺少景区时不发请求并展示错误。
|
||||
func testViewModelLoadWithoutScenicDoesNotRequest() async {
|
||||
let api = MockProfileSpaceAPI()
|
||||
let viewModel = ProfileSpaceViewModel()
|
||||
|
||||
await viewModel.load(api: api, scenicId: nil)
|
||||
|
||||
XCTAssertEqual(api.infoRequests, [])
|
||||
XCTAssertEqual(viewModel.errorMessage, "当前缺少景区信息")
|
||||
}
|
||||
|
||||
/// 测试标签校验覆盖空值、重复、超长和数量边界。
|
||||
func testViewModelValidatesLabels() async throws {
|
||||
let viewModel = ProfileSpaceViewModel()
|
||||
|
||||
XCTAssertThrowsError(try viewModel.addAttrLabel(" ")) { error in
|
||||
XCTAssertEqual(error as? ProfileSpaceValidationError, .emptyLabel)
|
||||
}
|
||||
|
||||
try viewModel.addAttrLabel("亲和")
|
||||
XCTAssertThrowsError(try viewModel.addAttrLabel("亲和")) { error in
|
||||
XCTAssertEqual(error as? ProfileSpaceValidationError, .duplicateLabel)
|
||||
}
|
||||
XCTAssertThrowsError(try viewModel.addAttrLabel(String(repeating: "长", count: 21))) { error in
|
||||
XCTAssertEqual(error as? ProfileSpaceValidationError, .labelTooLong)
|
||||
}
|
||||
|
||||
for index in 0..<7 {
|
||||
try viewModel.addAttrLabel("标签\(index)")
|
||||
}
|
||||
XCTAssertThrowsError(try viewModel.addAttrLabel("第九个")) { error in
|
||||
XCTAssertEqual(error as? ProfileSpaceValidationError, .labelLimit)
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试营业时间必须开始早于结束。
|
||||
func testViewModelValidatesBusinessTimes() async throws {
|
||||
let api = MockProfileSpaceAPI()
|
||||
let viewModel = ProfileSpaceViewModel()
|
||||
await viewModel.load(api: api, scenicId: 9)
|
||||
viewModel.businessStartTime = Self.time(hour: 18, minute: 0)
|
||||
viewModel.businessEndTime = Self.time(hour: 9, minute: 0)
|
||||
|
||||
XCTAssertThrowsError(try viewModel.makeUpdateRequest(scenicId: 9)) { error in
|
||||
XCTAssertEqual(error as? ProfileSpaceValidationError, .invalidWorkdayTime)
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试保存成功后清除 dirty 状态并提交更新请求。
|
||||
func testViewModelSaveClearsDirtyState() async throws {
|
||||
let api = MockProfileSpaceAPI()
|
||||
let uploader = ProfileSpaceMockUploader()
|
||||
let viewModel = ProfileSpaceViewModel()
|
||||
await viewModel.load(api: api, scenicId: 9)
|
||||
viewModel.introduction = "新的简介"
|
||||
|
||||
try await viewModel.save(api: api, uploader: uploader, scenicId: 9)
|
||||
|
||||
XCTAssertEqual(api.updateRequests.count, 1)
|
||||
XCTAssertEqual(api.updateRequests.first?.description, "新的简介")
|
||||
XCTAssertFalse(viewModel.hasChanges)
|
||||
}
|
||||
|
||||
/// 测试保存失败时保留编辑内容并暴露错误。
|
||||
func testViewModelSaveFailureKeepsDraft() async throws {
|
||||
let api = MockProfileSpaceAPI()
|
||||
let uploader = ProfileSpaceMockUploader()
|
||||
let viewModel = ProfileSpaceViewModel()
|
||||
await viewModel.load(api: api, scenicId: 9)
|
||||
viewModel.introduction = "保留草稿"
|
||||
api.updateError = APIError.serverCode(500, "保存失败")
|
||||
|
||||
do {
|
||||
try await viewModel.save(api: api, uploader: uploader, scenicId: 9)
|
||||
XCTFail("保存失败应抛出错误")
|
||||
} catch {
|
||||
XCTAssertEqual(viewModel.introduction, "保留草稿")
|
||||
XCTAssertEqual(viewModel.errorMessage, error.localizedDescription)
|
||||
XCTAssertTrue(viewModel.hasChanges)
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试删除日程会调用 ScheduleAPI 并刷新空间配置。
|
||||
func testViewModelDeleteScheduleCallsScheduleAPIAndReloads() async throws {
|
||||
let api = MockProfileSpaceAPI()
|
||||
let scheduleAPI = MockProfileSpaceScheduleAPI()
|
||||
let viewModel = ProfileSpaceViewModel()
|
||||
await viewModel.load(api: api, scenicId: 9)
|
||||
let item = try XCTUnwrap(viewModel.scheduleMap["2026-06-29"]?.first)
|
||||
|
||||
await viewModel.deleteSchedule(item: item, profileAPI: api, scheduleAPI: scheduleAPI, scenicId: 9)
|
||||
|
||||
XCTAssertEqual(scheduleAPI.deletedIds, [item.id])
|
||||
XCTAssertEqual(api.infoRequests, [9, 9])
|
||||
}
|
||||
|
||||
/// 测试首页空间设置和基本信息路由拆分正确。
|
||||
func testHomeRoutesResolveProfileSpaceAndBasicInfoSeparately() {
|
||||
XCTAssertEqual(
|
||||
HomeMenuRouter.resolve(uri: "space_settings", title: "空间设置"),
|
||||
.destination(.profileSpaceSettings)
|
||||
)
|
||||
XCTAssertEqual(
|
||||
HomeMenuRouter.resolve(uri: "basic_info", title: "基本信息"),
|
||||
.destination(.profileSpace)
|
||||
)
|
||||
}
|
||||
|
||||
fileprivate static var profileInfoEnvelope: Data {
|
||||
"""
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"id": "1",
|
||||
"scenic_id": "9",
|
||||
"photog_uid": 88,
|
||||
"real_name": "测试摄影师",
|
||||
"nickname": "云端摄影师",
|
||||
"avatar": "https://cdn.example.com/avatar.jpg",
|
||||
"scenic_certification": ["官方认证"],
|
||||
"description": "擅长旅拍",
|
||||
"attr_label": ["亲和", "构图"],
|
||||
"shoot_label": ["夜景"],
|
||||
"camera_device": ["Sony A7M4"],
|
||||
"business_start_time": "09:00:00",
|
||||
"business_end_time": "18:00:00",
|
||||
"holiday_start_time": "10:00:00",
|
||||
"holiday_end_time": "20:00:00",
|
||||
"business_range": [1, 2],
|
||||
"accept_order_status": 1,
|
||||
"schedule": {
|
||||
"2026-06-29": [
|
||||
{
|
||||
"id": 31,
|
||||
"name": "上午拍摄",
|
||||
"remark": "东门集合",
|
||||
"start_time": "09:00:00",
|
||||
"end_time": "10:00:00",
|
||||
"schedule_date": "2026-06-29",
|
||||
"order_number": "NO123",
|
||||
"user_phone": "13800000000"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
}
|
||||
|
||||
fileprivate static var emptyEnvelope: Data {
|
||||
#"{"code":100000,"msg":"success","data":{}}"#.data(using: .utf8)!
|
||||
}
|
||||
|
||||
private static func time(hour: Int, minute: Int) -> Date {
|
||||
Calendar.current.date(from: DateComponents(hour: hour, minute: minute)) ?? Date()
|
||||
}
|
||||
}
|
||||
|
||||
/// 个人空间 API 测试用 URLSession 替身。
|
||||
private final class ProfileSpaceURLSession: URLSessionProtocol {
|
||||
private var responses: [Data]
|
||||
private(set) var requests: [URLRequest] = []
|
||||
|
||||
/// 初始化顺序响应。
|
||||
init(responses: [Data]) {
|
||||
self.responses = responses
|
||||
}
|
||||
|
||||
/// 记录请求并返回下一份响应。
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
requests.append(request)
|
||||
let data = responses.isEmpty ? Data() : responses.removeFirst()
|
||||
return (
|
||||
data,
|
||||
HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 个人空间 ViewModel 测试用 API 替身。
|
||||
private final class MockProfileSpaceAPI: ProfileSpaceServing {
|
||||
var updateError: Error?
|
||||
private(set) var infoRequests: [Int] = []
|
||||
private(set) var updateRequests: [UpdateProfileSpaceRequest] = []
|
||||
private(set) var nicknameUpdates: [String?] = []
|
||||
private(set) var avatarUpdates: [String] = []
|
||||
|
||||
/// 返回固定个人空间配置。
|
||||
func profileSpaceInfo(scenicId: Int) async throws -> ProfileSpaceInfoResponse {
|
||||
infoRequests.append(scenicId)
|
||||
let envelope = try JSONDecoder().decode(APIEnvelope<ProfileSpaceInfoResponse>.self, from: ProfileSpaceTests.profileInfoEnvelope)
|
||||
return try XCTUnwrap(envelope.data)
|
||||
}
|
||||
|
||||
/// 记录空间配置更新请求。
|
||||
func updateProfileSpaceInfo(_ request: UpdateProfileSpaceRequest) async throws {
|
||||
if let updateError { throw updateError }
|
||||
updateRequests.append(request)
|
||||
}
|
||||
|
||||
/// 记录昵称更新请求。
|
||||
func updateUserInfo(nickname: String?, password: String?, avatar: String?) async throws {
|
||||
nicknameUpdates.append(nickname)
|
||||
}
|
||||
|
||||
/// 记录头像 URL 更新请求。
|
||||
func updateUserAvatarURL(_ fileURL: String) async throws {
|
||||
avatarUpdates.append(fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 个人空间日程测试用 API 替身。
|
||||
private final class MockProfileSpaceScheduleAPI: ScheduleServing {
|
||||
private(set) var deletedIds: [Int] = []
|
||||
|
||||
/// 返回空月标记。
|
||||
func monthScheduleDays(scenicId: Int, yearMonth: String) async throws -> [String] {
|
||||
[]
|
||||
}
|
||||
|
||||
/// 返回空日程列表。
|
||||
func dayScheduleList(scenicId: Int, date: String) async throws -> [ScheduleItem] {
|
||||
[]
|
||||
}
|
||||
|
||||
/// 新增日程测试不使用。
|
||||
func addSchedule(_ request: AddScheduleRequest) async throws {}
|
||||
|
||||
/// 记录删除的日程 ID。
|
||||
func deleteSchedule(id: Int) async throws {
|
||||
deletedIds.append(id)
|
||||
}
|
||||
|
||||
/// 返回空可关联订单。
|
||||
func availableOrderList(scenicId: Int) async throws -> [AvailableOrderResponse] {
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 个人空间上传测试替身。
|
||||
private final class ProfileSpaceMockUploader: OSSUploadServing {
|
||||
/// 上传头像并返回固定 URL。
|
||||
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
onProgress(100)
|
||||
return "https://cdn.example.com/new-avatar.jpg"
|
||||
}
|
||||
|
||||
/// 实名图片上传测试不使用。
|
||||
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
|
||||
/// 云盘上传测试不使用。
|
||||
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
|
||||
/// 相册上传测试不使用。
|
||||
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
|
||||
/// 任务附件上传测试不使用。
|
||||
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
|
||||
/// 项目图片上传测试不使用。
|
||||
func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
|
||||
/// 打卡点图片上传测试不使用。
|
||||
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
|
||||
/// 景区申请图片上传测试不使用。
|
||||
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
|
||||
/// 银行卡图片上传测试不使用。
|
||||
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
}
|
||||
100
suixinkanTests/TravelAlbum/TravelAlbumAPITests.swift
Normal file
100
suixinkanTests/TravelAlbum/TravelAlbumAPITests.swift
Normal file
@ -0,0 +1,100 @@
|
||||
//
|
||||
// TravelAlbumAPITests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 旅拍相册 API 测试,覆盖 path、query 与 body。
|
||||
final class TravelAlbumAPITests: XCTestCase {
|
||||
/// 测试列表与创建接口 path。
|
||||
func testAlbumListAndCreateUseExpectedPaths() async throws {
|
||||
let session = TravelAlbumRecordingURLSession(responses: [Self.listResponse, Self.createResponse])
|
||||
let api = TravelAlbumAPI(client: APIClient(session: session))
|
||||
|
||||
let list = try await api.albumList(page: 1, pageSize: 20)
|
||||
_ = try await api.createAlbum(
|
||||
TravelAlbumCreateRequest(
|
||||
name: "2026-06-29-001",
|
||||
type: 1,
|
||||
orderNumber: nil,
|
||||
materialNum: 3,
|
||||
materialPrice: 9.9,
|
||||
materialPackagePrice: 99,
|
||||
photoPrice: 0
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||||
"/api/yf-handset-app/photog/travel-album/list",
|
||||
"/api/yf-handset-app/photog/travel-album/create"
|
||||
])
|
||||
XCTAssertEqual(list.total, 1)
|
||||
XCTAssertEqual(list.list.first?.name, "2026-06-29-001")
|
||||
|
||||
let createBody = try bodyObject(from: session.requests[1])
|
||||
XCTAssertEqual(createBody["name"] as? String, "2026-06-29-001")
|
||||
XCTAssertEqual(createBody["type"] as? Int, 1)
|
||||
}
|
||||
|
||||
/// 测试上传素材登记接口 body。
|
||||
func testUploadMaterialUsesExpectedBody() async throws {
|
||||
let session = TravelAlbumRecordingURLSession(data: Self.uploadMaterialResponse)
|
||||
let api = TravelAlbumAPI(client: APIClient(session: session))
|
||||
|
||||
_ = try await api.uploadMaterial(
|
||||
TravelAlbumUploadMaterialRequest(
|
||||
userEquityTravelId: 8,
|
||||
fileName: "DSC_001.JPG",
|
||||
fileURL: "https://cdn/a.jpg"
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/travel-album/upload-material")
|
||||
let body = try bodyObject(from: try XCTUnwrap(session.requests.first))
|
||||
XCTAssertEqual(body["user_equity_travel_id"] as? Int, 8)
|
||||
XCTAssertEqual(body["file_name"] as? String, "DSC_001.JPG")
|
||||
XCTAssertEqual(body["file_url"] as? String, "https://cdn/a.jpg")
|
||||
}
|
||||
|
||||
fileprivate static let listResponse = Data(
|
||||
#"{"code":100000,"msg":"success","data":{"total":"1","list":[{"id":"8","store_user_id":"1","name":"2026-06-29-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","updated_at":"2026","user":{"id":"200","phone":"13800138000"}}]}}"#.utf8
|
||||
)
|
||||
|
||||
fileprivate static let createResponse = Data(
|
||||
#"{"code":100000,"msg":"success","data":{"id":"9"}}"#.utf8
|
||||
)
|
||||
|
||||
fileprivate static let uploadMaterialResponse = Data(
|
||||
#"{"code":100000,"msg":"success","data":{"id":"11","user_equity_travel_id":"8","status":"1","order_number":"","user_id":"100","file_name":"DSC_001.JPG","file_type":"2","file_url":"https://cdn/a.jpg","file_size":"2048","cover_url":"","is_purchased":"0","created_at":"2026","updated_at":"2026"}}"#.utf8
|
||||
)
|
||||
}
|
||||
|
||||
private final class TravelAlbumRecordingURLSession: URLSessionProtocol {
|
||||
private var responses: [Data]
|
||||
private(set) var requests: [URLRequest] = []
|
||||
|
||||
init(data: Data) {
|
||||
responses = [data]
|
||||
}
|
||||
|
||||
init(responses: [Data]) {
|
||||
self.responses = responses
|
||||
}
|
||||
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
requests.append(request)
|
||||
let data = responses.isEmpty ? TravelAlbumAPITests.listResponse : responses.removeFirst()
|
||||
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||
return (data, response)
|
||||
}
|
||||
}
|
||||
|
||||
private func bodyObject(from request: URLRequest) throws -> [String: Any] {
|
||||
let data = try XCTUnwrap(request.httpBody)
|
||||
return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
}
|
||||
124
suixinkanTests/TravelAlbum/TravelAlbumViewModelTests.swift
Normal file
124
suixinkanTests/TravelAlbum/TravelAlbumViewModelTests.swift
Normal file
@ -0,0 +1,124 @@
|
||||
//
|
||||
// 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"))
|
||||
}
|
||||
}
|
||||
|
||||
@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) 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: "")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user