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