// // ScenicPermissionViewModelTests.swift // suixinkanTests // // Created by Codex on 2026/6/23. // import Foundation import XCTest @testable import suixinkan @MainActor /// 景区权限申请测试,覆盖角色景区权限申请和新增景区申请流程。 final class ScenicPermissionViewModelTests: XCTestCase { /// 测试角色选项会按角色 ID 去重。 func testPermissionApplyDeduplicatesRoles() { let viewModel = PermissionApplyViewModel() viewModel.bootstrap(rolePermissions: [ RolePermissionResponse(role: RoleInfo(id: 1, name: "摄影师"), scenic: []), RolePermissionResponse(role: RoleInfo(id: 1, name: "摄影师重复"), scenic: []) ]) XCTAssertEqual(viewModel.roleOptions.map(\.id), [1]) XCTAssertEqual(viewModel.roleOptions.first?.name, "摄影师") } /// 测试已拥有的景区会被禁用,不能作为新增申请提交。 func testPermissionApplyDisablesExistingScenics() async { let api = ScenicPermissionServingFake() api.scenicList = ScenicListAllResponse(total: 2, list: [ ScenicListItem(id: 1, name: "已有景区"), ScenicListItem(id: 2, name: "新增景区") ]) let viewModel = PermissionApplyViewModel() viewModel.bootstrap(rolePermissions: [ RolePermissionResponse(role: RoleInfo(id: 1, name: "摄影师"), scenic: [ScenicInfo(id: 1, name: "已有景区")]) ]) viewModel.selectRole(id: 1) await viewModel.loadScenicListIfNeeded(api: api) viewModel.toggleScenic(id: 1) viewModel.toggleScenic(id: 2) XCTAssertTrue(viewModel.scenicOptions.first { $0.id == 1 }?.disabled == true) XCTAssertEqual(viewModel.selectedScenicIds, [2]) XCTAssertTrue(viewModel.canSubmit) } /// 测试切换角色后清空当前景区选择。 func testPermissionApplyChangingRoleClearsSelection() async { let api = ScenicPermissionServingFake() api.scenicList = ScenicListAllResponse(total: 1, list: [ScenicListItem(id: 2, name: "新增景区")]) let viewModel = PermissionApplyViewModel() viewModel.bootstrap(rolePermissions: [ RolePermissionResponse(role: RoleInfo(id: 1, name: "摄影师"), scenic: []), RolePermissionResponse(role: RoleInfo(id: 2, name: "店长"), scenic: []) ]) viewModel.selectRole(id: 1) await viewModel.loadScenicListIfNeeded(api: api) viewModel.toggleScenic(id: 2) viewModel.selectRole(id: 2) XCTAssertTrue(viewModel.selectedScenicIds.isEmpty) XCTAssertTrue(viewModel.scenicOptions.isEmpty) } /// 测试无角色或无景区时禁止提交。 func testPermissionApplyRequiresRoleAndScenicBeforeSubmit() { let viewModel = PermissionApplyViewModel() XCTAssertFalse(viewModel.canSubmit) viewModel.bootstrap(rolePermissions: [RolePermissionResponse(role: RoleInfo(id: 1, name: "摄影师"), scenic: [])]) viewModel.selectRole(id: 1) XCTAssertFalse(viewModel.canSubmit) } /// 测试提交成功和失败会更新状态。 func testPermissionApplySubmitSuccessAndFailure() async { let api = ScenicPermissionServingFake() let viewModel = PermissionApplyViewModel() viewModel.bootstrap(rolePermissions: [RolePermissionResponse(role: RoleInfo(id: 1, name: "摄影师"), scenic: [])]) viewModel.selectRole(id: 1) viewModel.scenicOptions = [PermissionScenicOption(id: 2, name: "新增景区", selected: false, disabled: false)] viewModel.toggleScenic(id: 2) await viewModel.submit(api: api) XCTAssertEqual(api.submittedRoleId, 1) XCTAssertEqual(api.submittedScenicIds, [2]) XCTAssertEqual(viewModel.message, "提交成功,等待审核") api.roleApplySubmitError = TestError.sample await viewModel.submit(api: api) XCTAssertEqual(viewModel.message, TestError.sample.localizedDescription) } /// 测试地区加载失败会进入初始化失败态。 func testScenicApplicationAreaFailureMarksLoadFailed() async { let api = ScenicPermissionServingFake() api.areasError = TestError.sample let viewModel = ScenicApplicationViewModel() await viewModel.loadInitial(api: api) XCTAssertTrue(viewModel.loadFailed) XCTAssertTrue(viewModel.provinces.isEmpty) } /// 测试待审核或驳回申请能回填表单。 func testScenicApplicationPendingRefillsForm() async { let api = ScenicPermissionServingFake() api.areas = [ScenicAreaNode(id: "zj", name: "浙江", children: [ScenicAreaNode(id: "hz", name: "杭州")])] api.scenicPendings = ScenicApplicationPendingsResponse(items: [ ScenicApplicationPendingResponse( id: 1, code: "A001", scenicId: 9, scenicName: "新景区", scenicImages: "https://a.com/1.jpg,https://a.com/2.jpg", scenicProvince: "浙江", scenicCity: "杭州", coopType: 3, remark: "备注", status: 3 ) ]) let viewModel = ScenicApplicationViewModel() await viewModel.loadInitial(api: api) XCTAssertEqual(viewModel.scenicName, "新景区") XCTAssertEqual(viewModel.remoteImageURLs.count, 2) XCTAssertEqual(viewModel.selectedProvince, "浙江") XCTAssertEqual(viewModel.selectedCity, "杭州") XCTAssertEqual(viewModel.coopType, 3) XCTAssertFalse(viewModel.isReadOnly) } /// 测试景区申请必填项和协议校验。 func testScenicApplicationValidation() { let viewModel = ScenicApplicationViewModel() XCTAssertEqual(viewModel.validationMessage, "请输入景区名称") viewModel.scenicName = "新景区" XCTAssertEqual(viewModel.validationMessage, "请至少上传一张景区图片") viewModel.imageURLs = "https://a.com/1.jpg" XCTAssertEqual(viewModel.validationMessage, "请选择省份") viewModel.selectedProvince = "浙江" XCTAssertEqual(viewModel.validationMessage, "请选择城市") viewModel.selectedCity = "杭州" XCTAssertEqual(viewModel.validationMessage, "请先阅读并同意用户须知与隐私政策") viewModel.agreed = true XCTAssertNil(viewModel.validationMessage) } /// 测试本地图片会先上传 OSS,再提交最终 URL。 func testScenicApplicationUploadsBeforeSubmit() async { let api = ScenicPermissionServingFake() let uploader = OSSUploadServingFake() uploader.uploadedURLs = ["https://oss.com/scenic.jpg"] let viewModel = ScenicApplicationViewModel() viewModel.scenicName = "新景区" viewModel.selectedProvince = "浙江" viewModel.selectedCity = "杭州" viewModel.agreed = true viewModel.addLocalImage(data: Data([1, 2, 3]), fileName: "local.jpg") await viewModel.submit(api: api, uploader: uploader) XCTAssertEqual(uploader.uploadedFileNames, ["local.jpg"]) XCTAssertEqual(api.submittedScenicRequest?.scenicImages, ["https://oss.com/scenic.jpg"]) XCTAssertEqual(api.uploadPlaceholders.first?.fileName, "local.jpg") XCTAssertEqual(viewModel.message, "提交成功") } /// 测试图片上传失败时不提交表单。 func testScenicApplicationUploadFailureDoesNotSubmit() async { let api = ScenicPermissionServingFake() let uploader = OSSUploadServingFake() uploader.uploadError = TestError.sample let viewModel = ScenicApplicationViewModel() viewModel.scenicName = "新景区" viewModel.selectedProvince = "浙江" viewModel.selectedCity = "杭州" viewModel.agreed = true viewModel.addLocalImage(data: Data([1]), fileName: "local.jpg") await viewModel.submit(api: api, uploader: uploader) XCTAssertNil(api.submittedScenicRequest) XCTAssertEqual(viewModel.message, TestError.sample.localizedDescription) } /// 测试驳回申请初始化时会回填角色和景区选择。 func testPermissionApplyBootstrapWithRejectedPending() { let pending = RoleApplyPendingResponse( id: 9, code: "AP-001", roleId: 2, roleName: "店长", scenicList: [RoleApplyScenicItem(id: 3, name: "驳回景区")], status: 3 ) let viewModel = PermissionApplyViewModel(initialPending: pending) viewModel.bootstrap(rolePermissions: [ RolePermissionResponse(role: RoleInfo(id: 1, name: "摄影师", notes: "拍摄"), scenic: []), RolePermissionResponse(role: RoleInfo(id: 2, name: "店长", notes: "门店管理"), scenic: []) ]) XCTAssertEqual(viewModel.selectedRoleId, 2) XCTAssertEqual(viewModel.selectedScenicIds, [3]) XCTAssertEqual(viewModel.selectedRoleName, "店长") XCTAssertEqual(viewModel.selectedRoleNotes, "门店管理") } /// 测试景区选择摘要会根据选择数量变化。 func testPermissionApplySelectedScenicSummary() { let viewModel = PermissionApplyViewModel() viewModel.scenicOptions = [ PermissionScenicOption(id: 1, name: "景区A", selected: false, disabled: false), PermissionScenicOption(id: 2, name: "景区B", selected: false, disabled: false) ] viewModel.toggleScenic(id: 1) viewModel.toggleScenic(id: 2) XCTAssertEqual(viewModel.selectedScenicSummary, "已选择 2 个景区") viewModel.toggleScenic(id: 2) XCTAssertEqual(viewModel.selectedScenicSummary, "景区A") } /// 测试已有角色权限列表和景区加载失败态。 func testPermissionApplyExistingRolesAndScenicLoadFailure() async { let api = ScenicPermissionServingFake() api.scenicListError = TestError.sample let viewModel = PermissionApplyViewModel() viewModel.bootstrap(rolePermissions: [ RolePermissionResponse(role: RoleInfo(id: 1, name: "摄影师"), scenic: [ScenicInfo(id: 1, name: "已有景区")]) ]) viewModel.selectRole(id: 1) await viewModel.loadScenicListIfNeeded(api: api) XCTAssertEqual(viewModel.existingRoleInfos.first?.scenics.map(\.id), [1]) XCTAssertTrue(viewModel.scenicLoadFailed) XCTAssertEqual(viewModel.scenicLoadFailureReason, TestError.sample.localizedDescription) XCTAssertEqual(viewModel.message, "景区列表加载失败,请重试") XCTAssertTrue(viewModel.selectedScenicIds.isEmpty) } /// 测试权限申请状态页会按申请编号匹配记录。 func testPermissionApplyStatusLoadByApplyCode() async { let api = ScenicPermissionServingFake() api.roleApplies = [ RoleApplyPendingResponse(id: 1, code: "AP-001", roleId: 1, roleName: "摄影师", status: 2), RoleApplyPendingResponse(id: 2, code: "AP-002", roleId: 2, roleName: "店长", status: 1) ] let viewModel = PermissionApplyStatusViewModel() await viewModel.load(api: api, applyCode: " AP-002 ") XCTAssertEqual(viewModel.pending?.code, "AP-002") XCTAssertFalse(viewModel.loadFailed) } /// 测试权限申请状态页无编号时会展示审核中或驳回记录。 func testPermissionApplyStatusLoadPendingOrRejectedWithoutCode() async { let api = ScenicPermissionServingFake() api.roleApplies = [ RoleApplyPendingResponse(id: 1, code: "AP-001", roleId: 1, roleName: "摄影师", status: 2), RoleApplyPendingResponse(id: 2, code: "AP-002", roleId: 2, roleName: "店长", status: 3) ] let viewModel = PermissionApplyStatusViewModel() await viewModel.load(api: api, applyCode: nil) XCTAssertEqual(viewModel.pending?.status, 3) } /// 测试权限申请状态页加载失败时会进入失败态。 func testPermissionApplyStatusLoadFailure() async { let api = ScenicPermissionServingFake() api.roleApplyAllError = TestError.sample let viewModel = PermissionApplyStatusViewModel() await viewModel.load(api: api, applyCode: "AP-001") XCTAssertNil(viewModel.pending) XCTAssertTrue(viewModel.loadFailed) XCTAssertEqual(viewModel.loadFailureReason, TestError.sample.localizedDescription) } /// 测试审核中的景区申请会进入只读态。 func testScenicApplicationReadOnlyWhenPendingReview() async { let api = ScenicPermissionServingFake() api.areas = [ScenicAreaNode(id: "zj", name: "浙江", children: [ScenicAreaNode(id: "hz", name: "杭州")])] api.scenicPendings = ScenicApplicationPendingsResponse(items: [ ScenicApplicationPendingResponse( id: 1, code: "A001", scenicId: 9, scenicName: "审核中景区", scenicImages: "https://a.com/1.jpg", scenicProvince: "浙江", scenicCity: "杭州", coopType: 1, remark: nil, status: 1 ) ]) let viewModel = ScenicApplicationViewModel() await viewModel.loadInitial(api: api) XCTAssertTrue(viewModel.isReadOnly) XCTAssertTrue(viewModel.isSubmitLocked) XCTAssertFalse(viewModel.canSubmit) } /// 测试省份切换后会清空不匹配的城市。 func testScenicApplicationOnProvinceChangeClearsInvalidCity() { let viewModel = ScenicApplicationViewModel() viewModel.provinces = [ ScenicAreaNode(id: "zj", name: "浙江", children: [ScenicAreaNode(id: "hz", name: "杭州")]), ScenicAreaNode(id: "js", name: "江苏", children: [ScenicAreaNode(id: "nj", name: "南京")]) ] viewModel.selectedProvince = "浙江" viewModel.onProvinceChange() viewModel.selectedCity = "杭州" viewModel.selectedProvince = "江苏" viewModel.onProvinceChange() XCTAssertEqual(viewModel.cities.map(\.name), ["南京"]) XCTAssertEqual(viewModel.selectedCity, "") } /// 测试远程图片解析和删除逻辑。 func testScenicApplicationRemoteImageParsingAndRemoval() { let viewModel = ScenicApplicationViewModel() viewModel.imageURLs = "https://a.com/1.jpg,https://a.com/2.jpg\nhttps://a.com/3.jpg" XCTAssertEqual(viewModel.remoteImageURLs, [ "https://a.com/1.jpg", "https://a.com/2.jpg", "https://a.com/3.jpg" ]) XCTAssertEqual(viewModel.uploadPlaceholderCount, 3) viewModel.removeRemoteImage("https://a.com/2.jpg") XCTAssertEqual(viewModel.remoteImageURLs, ["https://a.com/1.jpg", "https://a.com/3.jpg"]) } /// 测试本地图片数量上限为 20 张。 func testScenicApplicationMaxImageLimit() { let viewModel = ScenicApplicationViewModel() viewModel.imageURLs = Array(repeating: "https://a.com/x.jpg", count: 19).joined(separator: "\n") viewModel.addLocalImage(data: Data([1]), fileName: "local.jpg") XCTAssertEqual(viewModel.uploadPlaceholderCount, 20) viewModel.addLocalImage(data: Data([2]), fileName: "overflow.jpg") XCTAssertEqual(viewModel.uploadPlaceholderCount, 20) XCTAssertEqual(viewModel.message, "最多上传20张景区图片") } /// 测试上传占位策略会从 URL 提取文件名和类型。 func testScenicUploadPlaceholderPolicy() { XCTAssertEqual( ScenicUploadPlaceholderPolicy.fileName(from: "https://cdn.example.com/scenic/a.jpg?x=1#preview", fallbackPrefix: "scenic"), "a.jpg" ) XCTAssertEqual(ScenicUploadPlaceholderPolicy.fileType(from: "clip.mp4"), "video") XCTAssertEqual(ScenicUploadPlaceholderPolicy.fileType(from: "doc.pdf"), "file") XCTAssertEqual(ScenicUploadPlaceholderPolicy.fileType(from: "photo.heic"), "image") } /// 测试仅远程图片时不会走 OSS 上传,但会提交占位和表单。 func testScenicApplicationSubmitWithRemoteImagesOnly() async { let api = ScenicPermissionServingFake() let uploader = OSSUploadServingFake() let viewModel = ScenicApplicationViewModel() viewModel.scenicName = "新景区" viewModel.imageURLs = "https://a.com/1.jpg" viewModel.selectedProvince = "浙江" viewModel.selectedCity = "杭州" viewModel.agreed = true await viewModel.submit(api: api, uploader: uploader) XCTAssertTrue(uploader.uploadedFileNames.isEmpty) XCTAssertEqual(api.submittedScenicRequest?.scenicImages, ["https://a.com/1.jpg"]) XCTAssertEqual(api.uploadPlaceholders.count, 1) XCTAssertEqual(viewModel.message, "提交成功") } /// 测试没有匹配状态时清空待审核表单。 func testScenicApplicationClearPendingWhenNoMatchingStatus() async { let api = ScenicPermissionServingFake() api.areas = [ScenicAreaNode(id: "zj", name: "浙江", children: [ScenicAreaNode(id: "hz", name: "杭州")])] api.scenicPendings = ScenicApplicationPendingsResponse(items: [ ScenicApplicationPendingResponse( id: 1, code: "A001", scenicId: 9, scenicName: "已通过", scenicImages: "", scenicProvince: "浙江", scenicCity: "杭州", coopType: 1, remark: nil, status: 2 ) ]) let viewModel = ScenicApplicationViewModel() viewModel.scenicName = "草稿" await viewModel.loadInitial(api: api) XCTAssertNil(viewModel.pending) XCTAssertEqual(viewModel.scenicName, "") XCTAssertFalse(viewModel.agreed) } } @MainActor /// 景区权限服务测试替身,用于验证 ViewModel 请求行为。 private final class ScenicPermissionServingFake: ScenicPermissionServing { var scenicList = ScenicListAllResponse(total: 0, list: []) var scenicListError: Error? var areas: [ScenicAreaNode] = [] var areasError: Error? var scenicPendings = ScenicApplicationPendingsResponse() var scenicPendingsError: Error? var submittedScenicRequest: ScenicApplicationSubmitRequest? var uploadPlaceholders: [ScenicApplicationUploadPlaceholder] = [] var roleApplies: [RoleApplyPendingResponse] = [] var roleApplyAllError: Error? var submittedRoleId: Int? var submittedScenicIds: [Int] = [] var roleApplySubmitError: Error? /// 返回景区列表或抛出测试错误。 func scenicListAll() async throws -> ScenicListAllResponse { if let scenicListError { throw scenicListError } return scenicList } /// 返回地区树或抛出测试错误。 func areas() async throws -> [ScenicAreaNode] { if let areasError { throw areasError } return areas } /// 返回景区申请记录或抛出测试错误。 func scenicApplicationPendingAll() async throws -> ScenicApplicationPendingsResponse { if let scenicPendingsError { throw scenicPendingsError } return scenicPendings } /// 记录景区申请提交请求。 func scenicSubmit(_ request: ScenicApplicationSubmitRequest) async throws { submittedScenicRequest = request } /// 记录景区申请图片占位信息。 func scenicApplicationUploadPlaceholder(_ items: [ScenicApplicationUploadPlaceholder]) async throws { uploadPlaceholders = items } /// 返回角色权限申请记录。 func roleApplyAll() async throws -> [RoleApplyPendingResponse] { if let roleApplyAllError { throw roleApplyAllError } return roleApplies } /// 记录角色权限申请提交请求。 func roleApplySubmit(roleId: Int, scenicIds: [Int]) async throws { if let roleApplySubmitError { throw roleApplySubmitError } submittedRoleId = roleId submittedScenicIds = scenicIds } } @MainActor /// OSS 上传测试替身,用于验证景区申请图片上传顺序。 private final class OSSUploadServingFake: OSSUploadServing { var uploadedURLs: [String] = [] var uploadedFileNames: [String] = [] var uploadError: Error? /// 上传用户头像测试替身。 func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress) } /// 上传实名认证图片测试替身。 func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress) } /// 上传云盘文件测试替身。 func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress) } /// 上传相册文件测试替身。 func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress) } /// 上传任务文件测试替身。 func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress) } /// 上传项目图片测试替身。 func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress) } /// 上传打卡点图片测试替身。 func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress) } /// 上传景区申请图片测试替身。 func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress) } /// 上传银行卡图片测试替身。 func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress) } /// 统一记录上传请求并返回预设 URL。 private func upload(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { if let uploadError { throw uploadError } uploadedFileNames.append(fileName) onProgress(100) return uploadedURLs.removeFirst() } } /// 测试错误实体。 private enum TestError: LocalizedError { case sample var errorDescription: String? { "测试错误" } }