// // ProfileSpaceSettingsViewModelTests.swift // suixinkanTests // import XCTest @testable import suixinkan @MainActor /// 个人空间配置 ViewModel 与首页路由测试。 final class ProfileSpaceSettingsViewModelTests: XCTestCase { private var defaults: UserDefaults! private var appStore: AppStore! private var calendar: Calendar! private var fixedNow: Date! override func setUp() { super.setUp() defaults = UserDefaults(suiteName: "ProfileSpaceSettingsViewModelTests")! defaults.removePersistentDomain(forName: "ProfileSpaceSettingsViewModelTests") appStore = AppStore(defaults: defaults) appStore.session.currentScenicId = 11 calendar = Calendar(identifier: .gregorian) calendar.timeZone = TimeZone(secondsFromGMT: 0)! fixedNow = calendar.date(from: DateComponents(year: 2026, month: 7, day: 8))! } override func tearDown() { AppStore.shared.session.currentScenicId = 0 appStore.logout() defaults.removePersistentDomain(forName: "ProfileSpaceSettingsViewModelTests") super.tearDown() } func testProfileInfoDecodeSupportsEmptyStringsArraysAndScheduleMap() throws { let json = """ { "id": "9", "scenic_id": "11", "photog_uid": "", "real_name": "", "nickname": "小林", "avatar": "", "scenic_certification": ["西湖", ""], "description": "", "attr_label": ["航拍", 8], "shoot_label": "", "camera_device": ["Sony A7"], "business_start_time": "09:00:00", "business_end_time": "18:00:00", "holiday_start_time": "", "holiday_end_time": "20:00:00", "business_range": ["1", 2], "accept_order_status": "1", "virtual_phone": "", "schedule": { "2026-07-08": [{ "id": "22", "name": "旅拍", "remark": "", "start_time": "10:00", "end_time": "11:00", "schedule_date": "2026-07-08", "order_number": "NO1", "user_nickname": "游客", "user_phone": "13800000000", "order_status": "2", "order_status_name": "待拍摄" }] } } """.data(using: .utf8)! let response = try JSONDecoder().decode(PhotographerProfileInfoResponse.self, from: json) XCTAssertEqual(response.id, 9) XCTAssertEqual(response.photogUid, 0) XCTAssertEqual(response.attrLabel, ["航拍", "8"]) XCTAssertEqual(response.shootLabel, []) XCTAssertEqual(response.businessRange, [1, 2]) XCTAssertEqual(response.schedule["2026-07-08"]?.first?.id, 22) XCTAssertEqual(response.schedule["2026-07-08"]?.first?.userPhone, "13800000000") } func testProfileInfoEnvelopeDecodeSupportsEmptyScheduleArray() throws { let json = """ { "code": 100000, "data": { "accept_order_status": 1, "attr_label": [], "avatar": "https://vipsky.oss-cn-shanghai.aliyuncs.com/avatar/20260707/128/E7036CC996884E8A963A3962A9E72850_avatar_1783416288.jpg", "business_end_time": "", "business_start_time": "", "camera_device": [], "description": "", "holiday_end_time": "", "holiday_start_time": "", "id": 732, "nickname": "陆久银12", "real_name": "陆久银", "scenic_certification": [ "伊犁那拉提景区-5A" ], "schedule": [], "shoot_label": [] }, "msg": "success", "time": "2026-07-08 16:16:18" } """.data(using: .utf8)! let envelope = try JSONDecoder().decode(APIEnvelope.self, from: json) let response = try XCTUnwrap(envelope.data) XCTAssertEqual(response.id, 732) XCTAssertEqual(response.scenicId, 0) XCTAssertEqual(response.photogUid, 0) XCTAssertEqual(response.realName, "陆久银") XCTAssertEqual(response.nickname, "陆久银12") XCTAssertEqual(response.scenicCertification, ["伊犁那拉提景区-5A"]) XCTAssertEqual(response.acceptOrderStatus, 1) XCTAssertEqual(response.schedule, [:]) XCTAssertEqual(response.virtualPhone, "") } func testLabelValidationCoversEmptyTooLongDuplicateAndLimit() { let viewModel = makeViewModel() var messages: [String] = [] viewModel.onShowMessage = { messages.append($0) } viewModel.addAttrLabel(" ") XCTAssertEqual(messages.last, "标签不能为空") viewModel.addAttrLabel(String(repeating: "a", count: 21)) XCTAssertEqual(messages.last, "标签最多20个字符") viewModel.addAttrLabel("航拍") viewModel.addAttrLabel("航拍") XCTAssertEqual(viewModel.attrLabels, ["航拍"]) XCTAssertEqual(messages.last, "标签已存在") for index in 1...7 { viewModel.addAttrLabel("标签\(index)") } viewModel.addAttrLabel("第九个") XCTAssertEqual(viewModel.attrLabels.count, 8) XCTAssertEqual(messages.last, "最多8个标签") } func testBusinessTimeValidationRejectsInvalidRanges() throws { let viewModel = makeViewModel() var messages: [String] = [] viewModel.onShowMessage = { messages.append($0) } viewModel.updateBusinessEndTime("10:00") viewModel.updateBusinessStartTime("11:00") XCTAssertNil(viewModel.businessStartTime) XCTAssertEqual(messages.last, "开始时间不能大于结束时间") viewModel.updateHolidayStartTime("13:00") viewModel.updateHolidayEndTime("12:00") XCTAssertNil(viewModel.holidayEndTime) XCTAssertEqual(messages.last, "结束时间不能小于开始时间") viewModel.updateBusinessStartTime("09:00") viewModel.updateBusinessEndTime("18:00") viewModel.updateHolidayStartTime("08:00") viewModel.updateHolidayEndTime("20:00") viewModel.updateBusinessEndTime("08:00") XCTAssertEqual(viewModel.businessEndTime, "18:00") XCTAssertNoThrow(try viewModel.makeSaveRequest()) } func testHasChangesAndSaveRequestFieldsMatchAndroid() async throws { let api = FakeProfileSpaceSettingsAPI(response: .loaded()) let viewModel = makeViewModel() await viewModel.load(api: api) XCTAssertFalse(viewModel.hasChanges) viewModel.updateIntroduction("更新后的简介") viewModel.addDevice("Nikon Z8") viewModel.updateAcceptOrderStatus(2) XCTAssertTrue(viewModel.hasChanges) await viewModel.save(api: api) let request = try XCTUnwrap(api.lastUpdateRequest) XCTAssertEqual(request.scenicId, "11") XCTAssertEqual(request.description, "更新后的简介") XCTAssertEqual(request.cameraDevice, ["Sony A7", "Nikon Z8"]) XCTAssertEqual(request.attrLabel, ["航拍"]) XCTAssertEqual(request.shootLabel, ["亲子"]) XCTAssertEqual(request.businessStartTime, "09:00:00") XCTAssertEqual(request.businessEndTime, "18:00:00") XCTAssertEqual(request.holidayStartTime, "10:00:00") XCTAssertEqual(request.holidayEndTime, "20:00:00") XCTAssertEqual(request.businessRange, []) XCTAssertEqual(request.acceptOrderStatus, 2) XCTAssertFalse(viewModel.hasChanges) } func testAddScheduleValidationAndDeleteId() async throws { let api = FakeProfileSpaceSettingsAPI(response: .loaded()) let viewModel = makeViewModel() let scheduleDate = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 7, day: 9))) XCTAssertThrowsError(try viewModel.makeAddScheduleRequest( name: "", remark: "内容", startTime: "10:00", endTime: "11:00", scheduleDate: scheduleDate, orderNumber: nil )) { error in XCTAssertEqual(error as? ProfileSpaceSettingsValidationError, .emptyScheduleName) } XCTAssertThrowsError(try viewModel.makeAddScheduleRequest( name: "日程", remark: "内容", startTime: "12:00", endTime: "11:00", scheduleDate: scheduleDate, orderNumber: nil )) { error in XCTAssertEqual(error as? ProfileSpaceSettingsValidationError, .invalidScheduleTime) } let didAdd = await viewModel.addSchedule( name: " 日程 ", remark: "内容", startTime: "10:00", endTime: "11:00", scheduleDate: scheduleDate, orderNumber: " ", api: api ) XCTAssertTrue(didAdd) XCTAssertEqual(api.lastAddScheduleRequest?.scenicId, "11") XCTAssertEqual(api.lastAddScheduleRequest?.name, "日程") XCTAssertEqual(api.lastAddScheduleRequest?.scheduleDate, "2026-07-09") XCTAssertNil(api.lastAddScheduleRequest?.orderNumber) await viewModel.deleteSchedule(id: 33, api: api) XCTAssertEqual(api.lastDeletedScheduleId, 33) } func testSpaceSettingsRoutePushesProfileSpaceSettingsPage() { AppStore.shared.session.currentScenicId = 11 let root = UIViewController() let navigationController = UINavigationController(rootViewController: root) HomeRouteHandler.open(uri: "space_settings", from: root, storeItem: nil) XCTAssertTrue(navigationController.topViewController is ProfileSpaceSettingsViewController) } func testCertificationExpansionAndAvatarUploadRefresh() async { let response = PhotographerProfileInfoResponse.loaded(certifications: ["景区1", "景区2", "景区3", "景区4"]) let api = FakeProfileSpaceSettingsAPI(response: response) let viewModel = makeViewModel() await viewModel.load(api: api) XCTAssertFalse(viewModel.isCertificationExpanded) viewModel.toggleCertificationExpanded() XCTAssertTrue(viewModel.isCertificationExpanded) let avatarData = Data([1, 2, 3]) await viewModel.uploadAvatar(data: avatarData, fileName: "avatar.jpg", api: api) XCTAssertEqual(api.uploadedAvatarData, avatarData) XCTAssertEqual(api.uploadedAvatarFileName, "avatar.jpg") XCTAssertEqual(viewModel.avatarURL, "https://example.com/new-avatar.jpg") } func testSystemSettingsRoutePushesSettingPage() { AppStore.shared.session.currentScenicId = 11 let tabBarController = MainTabBarController() tabBarController.loadViewIfNeeded() guard let navigationController = tabBarController.selectedViewController as? UINavigationController, let root = navigationController.viewControllers.first else { XCTFail("首页 Tab 未正确初始化") return } HomeRouteHandler.open(uri: "system_settings", from: root, storeItem: nil) XCTAssertEqual(tabBarController.selectedIndex, 0) XCTAssertTrue(navigationController.topViewController is SettingViewController) } private func makeViewModel() -> ProfileSpaceSettingsViewModel { ProfileSpaceSettingsViewModel( appStore: appStore, calendar: calendar, nowProvider: { self.fixedNow } ) } } @MainActor private final class FakeProfileSpaceSettingsAPI: ProfileSpaceSettingsAPI { var response: PhotographerProfileInfoResponse var lastUpdateRequest: UpdatePhotographerProfileInfoRequest? var lastAddScheduleRequest: AddPhotographerScheduleRequest? var lastDeletedScheduleId: Int? var uploadedAvatarData: Data? var uploadedAvatarFileName: String? init(response: PhotographerProfileInfoResponse) { self.response = response } func profileInfo(scenicId: Int) async throws -> PhotographerProfileInfoResponse { response } func updateProfileInfo(_ request: UpdatePhotographerProfileInfoRequest) async throws { lastUpdateRequest = request response = PhotographerProfileInfoResponse( id: response.id, scenicId: Int(request.scenicId) ?? 0, photogUid: response.photogUid, realName: response.realName, nickname: response.nickname, avatar: response.avatar, scenicCertification: response.scenicCertification, description: request.description, attrLabel: request.attrLabel, shootLabel: request.shootLabel, cameraDevice: request.cameraDevice, businessStartTime: request.businessStartTime, businessEndTime: request.businessEndTime, holidayStartTime: request.holidayStartTime, holidayEndTime: request.holidayEndTime, businessRange: request.businessRange, acceptOrderStatus: request.acceptOrderStatus, schedule: response.schedule, virtualPhone: response.virtualPhone ) } func updateUserInfo(nickname: String?, password: String?, avatar: String?) async throws {} func addSchedule(_ request: AddPhotographerScheduleRequest) async throws { lastAddScheduleRequest = request } func deleteSchedule(id: Int) async throws { lastDeletedScheduleId = id } func uploadAvatar(data: Data, fileName: String) async throws { uploadedAvatarData = data uploadedAvatarFileName = fileName response = PhotographerProfileInfoResponse( id: response.id, scenicId: response.scenicId, photogUid: response.photogUid, realName: response.realName, nickname: response.nickname, avatar: "https://example.com/new-avatar.jpg", scenicCertification: response.scenicCertification, description: response.description, attrLabel: response.attrLabel, shootLabel: response.shootLabel, cameraDevice: response.cameraDevice, businessStartTime: response.businessStartTime, businessEndTime: response.businessEndTime, holidayStartTime: response.holidayStartTime, holidayEndTime: response.holidayEndTime, businessRange: response.businessRange, acceptOrderStatus: response.acceptOrderStatus, schedule: response.schedule, virtualPhone: response.virtualPhone ) } } private extension PhotographerProfileInfoResponse { static func loaded() -> PhotographerProfileInfoResponse { loaded(certifications: ["西湖"]) } static func loaded(certifications: [String]) -> PhotographerProfileInfoResponse { PhotographerProfileInfoResponse( id: 1, scenicId: 11, photogUid: 100, realName: "林摄影", nickname: "小林", avatar: "https://example.com/a.jpg", scenicCertification: certifications, description: "简介", attrLabel: ["航拍"], shootLabel: ["亲子"], cameraDevice: ["Sony A7"], businessStartTime: "09:00:00", businessEndTime: "18:00:00", holidayStartTime: "10:00:00", holidayEndTime: "20:00:00", businessRange: [], acceptOrderStatus: 1, schedule: [ "2026-07-08": [ PhotographerSchedule( id: 22, name: "旅拍", remark: "西湖", startTime: "10:00", endTime: "11:00", scheduleDate: "2026-07-08", userNickname: "游客", userPhone: "13800000000" ), ], ], virtualPhone: "" ) } }