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:
2026-06-30 09:41:05 +08:00
parent 5692134efc
commit d2fe5d71e4
48 changed files with 6477 additions and 11 deletions

View File

@ -0,0 +1,351 @@
//
// ProfileSpaceTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/29.
//
import XCTest
@testable import suixinkan
@MainActor
/// APIViewModel
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 { "" }
}