Files
suixinkan_ios_new/suixinkanTests/ProfileSpace/ProfileSpaceTests.swift
汉秋 d2fe5d71e4 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>
2026-06-30 09:41:05 +08:00

352 lines
14 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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 { "" }
}