// // ProjectViewModelTests.swift // suixinkanTests // // Created by Codex on 2026/6/24. // import XCTest @testable import suixinkan @MainActor /// 项目 ViewModel 测试,覆盖分页、校验和上传提交顺序。 final class ProjectViewModelTests: XCTestCase { /// 测试无景区时清空列表且不请求接口。 func testProjectReloadWithoutScenicClearsListAndSkipsRequest() async { let api = FakeProjectService() let viewModel = ProjectManagementViewModel() await viewModel.reload(api: api, scenicId: nil) XCTAssertTrue(viewModel.items.isEmpty) XCTAssertEqual(api.projectListCalls.count, 0) } /// 测试项目列表首屏和加载更多分页。 func testProjectListReloadAndLoadMore() async { let api = FakeProjectService() api.projectPages = [ ListPayload(total: 2, list: [.fixture(id: 1, name: "A")]), ListPayload(total: 2, list: [.fixture(id: 2, name: "B")]) ] let viewModel = ProjectManagementViewModel() await viewModel.reload(api: api, scenicId: 9) await viewModel.loadMore(api: api, scenicId: 9) XCTAssertEqual(api.projectListCalls.map(\.page), [1, 2]) XCTAssertEqual(viewModel.items.map(\.id).sorted(), [1, 2]) XCTAssertFalse(viewModel.hasMore) } /// 测试摄影师项目提交会先上传图片再调用创建接口。 func testProjectEditorUploadsImagesBeforeCreate() async { let api = FakeProjectService() let uploader = FakeProjectUploader() let viewModel = ProjectEditorViewModel(mode: .create) viewModel.name = "新项目" viewModel.descriptionText = "描述" viewModel.price = "99" viewModel.otPrice = "199" viewModel.deposit = "10" viewModel.selectedSpotIds = [6] viewModel.coverImage = ProjectLocalImage(data: Data([1]), fileName: "cover.jpg", previewURL: nil) viewModel.carouselImages = [ProjectLocalImage(data: Data([2]), fileName: "banner.jpg", previewURL: nil)] let success = await viewModel.submit(scenicId: 9, userId: 100, api: api, uploadService: uploader) XCTAssertTrue(success) XCTAssertEqual(uploader.uploadedFileNames, ["cover.jpg", "banner.jpg"]) XCTAssertEqual(api.createdProjects.first?.coverProject, "https://cdn/cover.jpg") XCTAssertEqual(api.createdProjects.first?.coverCarousel, ["https://cdn/banner.jpg"]) } /// 测试上传失败时不提交业务接口。 func testProjectEditorUploadFailureDoesNotCreateProject() async { let api = FakeProjectService() let uploader = FakeProjectUploader() uploader.shouldFail = true let viewModel = ProjectEditorViewModel(mode: .create) viewModel.name = "新项目" viewModel.descriptionText = "描述" viewModel.price = "99" viewModel.selectedSpotIds = [6] viewModel.coverImage = ProjectLocalImage(data: Data([1]), fileName: "cover.jpg", previewURL: nil) let success = await viewModel.submit(scenicId: 9, userId: 100, api: api, uploadService: uploader) XCTAssertFalse(success) XCTAssertTrue(api.createdProjects.isEmpty) } /// 测试店铺项目缺用户 ID 时不提交。 func testStoreProjectEditorRequiresUserId() async { let viewModel = StoreProjectEditorViewModel(mode: .create) let success = await viewModel.submit(userId: 0, api: FakeProjectService(), uploadService: FakeProjectUploader()) XCTAssertFalse(success) XCTAssertEqual(viewModel.errorMessage, ProjectEditorError.missingUser.localizedDescription) } } private extension PhotographerProjectItem { /// 创建测试项目列表项。 static func fixture(id: Int, name: String) -> PhotographerProjectItem { PhotographerProjectItem(id: id, name: name, price: "99.00") } } /// 项目测试服务替身。 @MainActor private final class FakeProjectService: ProjectServing { var projectPages: [ListPayload] = [] var projectListCalls: [(scenicId: Int, name: String?, page: Int, pageSize: Int)] = [] var createdProjects: [ProjectCreateRequest] = [] /// 获取摄影师项目列表。 func projectList(scenicId: Int, name: String?, page: Int, pageSize: Int) async throws -> ListPayload { projectListCalls.append((scenicId, name, page, pageSize)) return projectPages.isEmpty ? ListPayload(total: 0, list: []) : projectPages.removeFirst() } /// 获取摄影师项目详情。 func projectDetail(id: Int) async throws -> PhotographerProjectDetailResponse { try Self.detail() } /// 创建摄影师项目。 func createProject(_ request: ProjectCreateRequest) async throws { createdProjects.append(request) } /// 编辑摄影师项目。 func editProject(_ request: ProjectCreateRequest) async throws {} /// 删除摄影师项目。 func deleteProject(id: Int) async throws {} /// 获取店铺项目可管理景区。 func storeManagerScenicList(userId: String) async throws -> ListPayload { ListPayload(total: 0, list: []) } /// 获取店铺项目列表。 func storeManagerProjectList(userId: String?, page: Int, pageSize: Int) async throws -> ListPayload { ListPayload(total: 0, list: []) } /// 获取店铺项目详情。 func storeManagerProjectDetail(id: Int) async throws -> PhotographerProjectDetailResponse { try Self.detail() } /// 创建店铺多点位项目。 func storeManagerCreate(_ request: StoreManagerCreateRequest) async throws {} /// 创建店铺押金项目。 func storeManagerOfflineCreate(_ request: StoreManagerOfflineCreateRequest) async throws {} /// 更新店铺多点位项目。 func storeManagerUpdate(_ request: StoreManagerUpdateRequest) async throws {} /// 更新店铺押金项目。 func storeManagerOfflineUpdate(_ request: StoreManagerOfflineUpdateRequest) async throws {} /// 删除店铺项目。 func storeManagerDeleteProject(id: Int) async throws {} /// 获取全部门店。 func storeAll() async throws -> ListPayload { ListPayload(total: 0, list: []) } private static func detail() throws -> PhotographerProjectDetailResponse { let data = Data(#"{"id":1,"name":"项目","type":11,"status":1,"price":"99","description":"描述","material_num":1,"photo_num":1,"video_num":1}"#.utf8) return try JSONDecoder().decode(PhotographerProjectDetailResponse.self, from: data) } } /// 项目上传测试替身。 @MainActor private final class FakeProjectUploader: OSSUploadServing { var uploadedFileNames: [String] = [] var shouldFail = false /// 上传项目图片。 func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { if shouldFail { throw TestUploadError.failed } uploadedFileNames.append(fileName) return "https://cdn/\(fileName)" } func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } 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 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 { "" } } /// 测试上传错误。 private enum TestUploadError: Error { case failed }