// // AssetsViewModelTests.swift // suixinkanTests // // Created by Codex on 2026/6/23. // import XCTest @testable import suixinkan @MainActor /// 资产 ViewModel 测试,覆盖云盘和素材管理核心业务逻辑。 final class AssetsViewModelTests: XCTestCase { /// 测试云盘无上传权限时不会继续上传 OSS 或写入云盘。 func testCloudUploadStopsWhenPermissionDenied() async { let api = MockAssetsService() api.uploadPermission = false api.uploadReason = "空间不足" let uploader = MockAssetsUploader() let viewModel = CloudStorageViewModel() let success = await viewModel.upload( localFiles: [CloudLocalUploadFile(data: Data([1]), fileName: "a.jpg", fileType: 2)], scenicId: 9, api: api, uploadService: uploader, transferStore: CloudTransferStore() ) XCTAssertFalse(success) XCTAssertEqual(viewModel.errorMessage, "空间不足") XCTAssertEqual(uploader.uploadedFiles.count, 0) XCTAssertEqual(api.cloudUploadRequests.count, 0) } /// 测试云盘上传会先上传 OSS,再登记云盘文件并刷新列表。 func testCloudUploadUploadsOSSBeforeCloudRecord() async { let api = MockAssetsService() let uploader = MockAssetsUploader() let transferStore = CloudTransferStore() let viewModel = CloudStorageViewModel() let success = await viewModel.upload( localFiles: [CloudLocalUploadFile(data: Data([1, 2]), fileName: "a.jpg", fileType: 2)], scenicId: 9, api: api, uploadService: uploader, transferStore: transferStore ) XCTAssertTrue(success) XCTAssertEqual(uploader.uploadedFiles.map(\.fileName), ["a.jpg"]) XCTAssertEqual(api.cloudUploadRequests.first?.fileUrl, "https://cdn.example.com/a.jpg") XCTAssertEqual(transferStore.records.first?.status, .success) } /// 测试云盘分页、进入文件夹和返回文件夹。 func testCloudPaginationAndFolderNavigation() async { let folder = CloudDriveFile(id: 2, parentFolderId: 0, fileUrl: "", name: "子目录", type: 99) let api = MockAssetsService() api.cloudPages = [ ListPayload(total: 2, list: [folder]), ListPayload(total: 2, list: [CloudDriveFile(id: 3, parentFolderId: 0, fileUrl: "https://cdn/a.jpg", name: "a.jpg", type: 2)]) ] let viewModel = CloudStorageViewModel() await viewModel.reload(api: api) await viewModel.loadMore(api: api) await viewModel.enterFolder(folder, api: api) await viewModel.popToFolder(at: 0, api: api) XCTAssertEqual(api.cloudListRequests.first?.parentFolderId, 0) XCTAssertEqual(api.cloudListRequests[1].page, 2) XCTAssertEqual(api.cloudListRequests[2].parentFolderId, 2) XCTAssertEqual(viewModel.currentFolderId, 0) } /// 测试素材列表筛选和分页请求参数。 func testMediaListFilterAndPagination() async { let api = MockAssetsService() api.mediaListResponses = [ MediaLibraryListResponse.fixture(total: 2, ids: [1]), MediaLibraryListResponse.fixture(total: 2, ids: [2]) ] let viewModel = MediaLibraryViewModel(kind: .material) viewModel.keyword = " 航拍 " viewModel.selectedAuditFilter = .approved await viewModel.reload(api: api) await viewModel.loadMore(api: api) XCTAssertEqual(api.mediaListRequests.first?.keyword, "航拍") XCTAssertEqual(api.mediaListRequests.first?.status, 1) XCTAssertEqual(api.mediaListRequests.last?.page, 2) XCTAssertEqual(viewModel.items.map(\.id), [1, 2]) } /// 测试样片列表请求使用样片类型。 func testSampleMediaListUsesSampleKind() async { let api = MockAssetsService() let viewModel = MediaLibraryViewModel(kind: .sample) await viewModel.reload(api: api) XCTAssertEqual(api.mediaListRequests.first?.kind, .sample) } /// 测试审核未通过素材禁止上下架。 func testRejectedMediaCannotToggleListing() async { let api = MockAssetsService() let viewModel = MediaLibraryViewModel() let item = MediaLibraryItem.fixture(id: 1, status: 2, listingStatus: 0) let success = await viewModel.toggleListing(item, api: api) XCTAssertFalse(success) XCTAssertEqual(api.mediaOperationRequests.count, 0) XCTAssertEqual(viewModel.errorMessage, "审核通过后才能上下架") } /// 测试样片上下架会携带样片类型。 func testSampleToggleListingUsesSampleType() async { let api = MockAssetsService() let viewModel = MediaLibraryViewModel(kind: .sample) let item = MediaLibraryItem.fixture(id: 1, status: 1, listingStatus: 0) let success = await viewModel.toggleListing(item, api: api) XCTAssertTrue(success) XCTAssertEqual(api.mediaOperationRequests.first?.type, 2) } /// 测试上传素材会先上传封面和媒体文件,再提交最终 OSS URL。 func testMediaUploadUploadsFilesBeforeSubmit() async { let api = MockAssetsService() let uploader = MockAssetsUploader() let viewModel = MediaLibraryEditorViewModel() viewModel.name = "航拍素材" viewModel.selectedSpotId = 6 viewModel.tagsText = "航拍,夜景" viewModel.setCover(MediaLocalUploadFile(data: Data([1]), fileName: "cover.jpg", fileType: 2, width: 100, height: 80)) viewModel.addMediaFiles([MediaLocalUploadFile(data: Data([2]), fileName: "a.jpg", fileType: 2, width: 120, height: 90)]) let success = await viewModel.submit(scenicId: 9, api: api, uploadService: uploader) XCTAssertTrue(success) XCTAssertEqual(uploader.uploadedFiles.map(\.fileName), ["cover.jpg", "a.jpg"]) XCTAssertEqual(api.mediaUploadRequests.first?.coverUrl, "https://cdn.example.com/cover.jpg") XCTAssertEqual(api.mediaUploadRequests.first?.mediaList.first?.ossUrl, "https://cdn.example.com/a.jpg") XCTAssertEqual(api.mediaUploadRequests.first?.materialTag, "航拍,夜景") } /// 测试素材上传失败时不会提交素材接口。 func testMediaUploadFailureDoesNotSubmit() async { let api = MockAssetsService() let uploader = MockAssetsUploader() uploader.shouldFail = true let viewModel = MediaLibraryEditorViewModel() viewModel.name = "航拍素材" viewModel.selectedSpotId = 6 viewModel.setCover(MediaLocalUploadFile(data: Data([1]), fileName: "cover.jpg", fileType: 2, width: 100, height: 80)) viewModel.addMediaFiles([MediaLocalUploadFile(data: Data([2]), fileName: "a.jpg", fileType: 2, width: 120, height: 90)]) let success = await viewModel.submit(scenicId: 9, api: api, uploadService: uploader) XCTAssertFalse(success) XCTAssertEqual(api.mediaUploadRequests.count, 0) } /// 测试样片上传缺少关联项目时不提交。 func testSampleUploadRequiresProject() async { let api = MockAssetsService() let uploader = MockAssetsUploader() let viewModel = MediaLibraryEditorViewModel() viewModel.name = "亲子样片" viewModel.selectedSpotId = 6 viewModel.setCover(MediaLocalUploadFile(data: Data([1]), fileName: "cover.jpg", fileType: 2, width: 100, height: 80)) viewModel.addMediaFiles([MediaLocalUploadFile(data: Data([2]), fileName: "a.jpg", fileType: 2, width: 120, height: 90)]) let success = await viewModel.submit(kind: .sample, scenicId: 9, api: api, uploadService: uploader) XCTAssertFalse(success) XCTAssertEqual(viewModel.errorMessage, "请选择关联项目") XCTAssertEqual(api.mediaUploadRequests.count, 0) } /// 测试样片上传会先上传文件,再提交样片类型和关联项目。 func testSampleUploadUploadsFilesAndSubmitsProject() async { let api = MockAssetsService() let uploader = MockAssetsUploader() let viewModel = MediaLibraryEditorViewModel() viewModel.name = "亲子样片" viewModel.selectedSpotId = 6 viewModel.selectedProjectId = 88 viewModel.setCover(MediaLocalUploadFile(data: Data([1]), fileName: "cover.jpg", fileType: 2, width: 100, height: 80)) viewModel.addMediaFiles([MediaLocalUploadFile(data: Data([2]), fileName: "a.jpg", fileType: 2, width: 120, height: 90)]) let success = await viewModel.submit(kind: .sample, scenicId: 9, api: api, uploadService: uploader) XCTAssertTrue(success) XCTAssertEqual(uploader.uploadedFiles.map(\.fileName), ["cover.jpg", "a.jpg"]) XCTAssertEqual(api.mediaUploadRequests.first?.type, 2) XCTAssertEqual(api.mediaUploadRequests.first?.projectId, 88) } /// 测试重复提交样片会被忽略。 func testSampleDuplicateSubmitIsIgnored() async { let api = MockAssetsService() let uploader = MockAssetsUploader() let viewModel = MediaLibraryEditorViewModel() viewModel.name = "亲子样片" viewModel.selectedSpotId = 6 viewModel.selectedProjectId = 88 viewModel.isSubmitting = true viewModel.setCover(MediaLocalUploadFile(data: Data([1]), fileName: "cover.jpg", fileType: 2, width: 100, height: 80)) viewModel.addMediaFiles([MediaLocalUploadFile(data: Data([2]), fileName: "a.jpg", fileType: 2, width: 120, height: 90)]) let success = await viewModel.submit(kind: .sample, scenicId: 9, api: api, uploadService: uploader) XCTAssertFalse(success) XCTAssertEqual(api.mediaUploadRequests.count, 0) XCTAssertEqual(uploader.uploadedFiles.count, 0) } /// 测试相册列表无景区时清空数据且不请求接口。 func testAlbumListClearsWithoutScenic() async { let api = MockAssetsService() let viewModel = AlbumListViewModel() await viewModel.reload(api: api, scenicId: nil) XCTAssertTrue(viewModel.folders.isEmpty) XCTAssertEqual(api.albumFolderListRequests.count, 0) XCTAssertEqual(viewModel.errorMessage, "缺少当前景区,无法加载相册") } /// 测试相册列表搜索、日期筛选和分页参数。 func testAlbumListFilterAndPagination() async { let api = MockAssetsService() api.albumFolderPages = [ ListPayload(total: 2, list: [AlbumFolderItem.fixture(id: 1, name: "旅拍")]), ListPayload(total: 2, list: [AlbumFolderItem.fixture(id: 2, name: "亲子")]) ] let viewModel = AlbumListViewModel() viewModel.searchText = " 旅拍 " viewModel.startTime = "2026-06-01" viewModel.endTime = "2026-06-24" await viewModel.reload(api: api, scenicId: 9) await viewModel.loadMore(api: api, scenicId: 9) XCTAssertEqual(api.albumFolderListRequests.first?.name, "旅拍") XCTAssertEqual(api.albumFolderListRequests.first?.startTime, "2026-06-01") XCTAssertEqual(api.albumFolderListRequests.last?.page, 2) XCTAssertEqual(viewModel.folders.map(\.id), [1, 2]) } /// 测试新建相册成功后刷新列表。 func testAlbumCreateRefreshesList() async { let api = MockAssetsService() api.albumFolderPages = [ListPayload(total: 1, list: [AlbumFolderItem.fixture(id: 3, name: "新相册")])] let viewModel = AlbumListViewModel() let success = await viewModel.createFolder(name: " 新相册 ", remark: " 备注 ", scenicId: 9, api: api) XCTAssertTrue(success) XCTAssertEqual(api.albumAddRequests.first?.name, "新相册") XCTAssertEqual(api.albumAddRequests.first?.remark, "备注") XCTAssertEqual(viewModel.folders.first?.id, 3) } /// 测试相册详情加载信息和文件列表,切换 Tab 会重置分页。 func testAlbumDetailLoadsInfoAndSwitchesTab() async { let api = MockAssetsService() api.albumFilePages = [ ListPayload(total: 1, list: [AlbumFileItem.fixture(id: 11, fileType: 2)]), ListPayload(total: 1, list: [AlbumFileItem.fixture(id: 12, fileType: 1)]) ] let viewModel = AlbumDetailViewModel(folderId: 3) await viewModel.reload(api: api, scenicId: 9) await viewModel.selectTab(.video, api: api, scenicId: 9) XCTAssertEqual(api.albumInfoRequests, [3, 3]) XCTAssertEqual(api.albumFileListRequests.map { $0.fileType }, [2, 1]) XCTAssertEqual(viewModel.files.first?.id, 12) XCTAssertEqual(viewModel.page, 1) } /// 测试相册详情删除、设置封面和编辑信息会调用正确接口。 func testAlbumDetailMutationsUseExpectedRequests() async { let api = MockAssetsService() let viewModel = AlbumDetailViewModel(folderId: 3) let file = AlbumFileItem.fixture(id: 11, fileType: 2) _ = await viewModel.delete(file: file, scenicId: 9, api: api) _ = await viewModel.setCover(file: file, scenicId: 9, api: api) _ = await viewModel.rename(name: " 改名 ", scenicId: 9, api: api) _ = await viewModel.updateRemark(" 新备注 ", scenicId: 9, api: api) XCTAssertEqual(api.albumDeleteRequests.first?.idList, [11]) XCTAssertEqual(api.albumEditRequests[0].coverFileId, 11) XCTAssertEqual(api.albumEditRequests[1].name, "改名") XCTAssertEqual(api.albumEditRequests[2].remark, "新备注") } /// 测试相册预览上传会先上传 OSS,再登记相册文件。 func testAlbumTrailerUploadUploadsOSSBeforeRegisteringFile() async { let api = MockAssetsService() api.albumFolderPages = [ListPayload(total: 1, list: [AlbumFolderItem.fixture(id: 3, name: "旅拍")])] let uploader = MockAssetsUploader() let viewModel = AlbumTrailerViewModel() viewModel.addLocalFiles([AlbumLocalUploadFile(data: Data([1]), fileName: "a.jpg", fileType: 2)]) await viewModel.loadFolders(api: api, scenicId: 9) let success = await viewModel.submit(scenicId: 9, api: api, uploadService: uploader) XCTAssertTrue(success) XCTAssertEqual(uploader.uploadedFiles.map(\.fileName), ["a.jpg"]) XCTAssertEqual(api.albumUploadURLRequests.first?.fileURL, "https://cdn.example.com/a.jpg") XCTAssertTrue(viewModel.localFiles.isEmpty) XCTAssertEqual(viewModel.uploadProgress, 100) } /// 测试相册上传失败时不会登记文件入库。 func testAlbumTrailerUploadFailureDoesNotRegisterFile() async { let api = MockAssetsService() let uploader = MockAssetsUploader() uploader.shouldFail = true let viewModel = AlbumTrailerViewModel() viewModel.selectedFolderId = 3 viewModel.addLocalFiles([AlbumLocalUploadFile(data: Data([1]), fileName: "a.jpg", fileType: 2)]) let success = await viewModel.submit(scenicId: 9, api: api, uploadService: uploader) XCTAssertFalse(success) XCTAssertEqual(api.albumUploadURLRequests.count, 0) } } /// 资产服务测试替身,记录请求并返回预设数据。 @MainActor private final class MockAssetsService: AssetsServing { struct CloudListRequest: Equatable { let parentFolderId: Int let name: String let type: Int let orderBy: Int let page: Int let pageSize: Int } struct MediaListRequest: Equatable { let kind: MediaLibraryKind let keyword: String let status: Int? let page: Int } struct ProjectListRequest: Equatable { let scenicId: Int let name: String? let page: Int let pageSize: Int } struct AlbumFolderListRequest: Equatable { let scenicId: Int let page: Int let pageSize: Int let name: String let startTime: String? let endTime: String? } struct AlbumUploadURLRequest: Equatable { let scenicId: Int let fileURL: String let folderId: Int } var cloudPages: [ListPayload] = [ListPayload(total: 0, list: [])] var mediaListResponses: [MediaLibraryListResponse] = [MediaLibraryListResponse.fixture(total: 0, ids: [])] var projectPages: [ListPayload] = [ListPayload(total: 0, list: [])] var albumFolderPages: [ListPayload] = [ListPayload(total: 0, list: [])] var albumFilePages: [ListPayload] = [ListPayload(total: 0, list: [])] var uploadPermission = true var uploadReason = "" var cloudListRequests: [CloudListRequest] = [] var cloudUploadRequests: [CloudFileUploadRequest] = [] var mediaListRequests: [MediaListRequest] = [] var mediaOperationRequests: [MediaAlbumOperationRequest] = [] var mediaUploadRequests: [MediaAlbumUploadRequest] = [] var mediaEditRequests: [MediaAlbumEditRequest] = [] var projectListRequests: [ProjectListRequest] = [] var albumFolderListRequests: [AlbumFolderListRequest] = [] var albumInfoRequests: [Int] = [] var albumFileListRequests: [(scenicId: Int, folderId: Int, fileType: Int, page: Int)] = [] var albumAddRequests: [(scenicId: Int, name: String, remark: String)] = [] var albumEditRequests: [AlbumFolderEditRequest] = [] var albumDeleteRequests: [AlbumFileDeleteRequest] = [] var albumUploadURLRequests: [AlbumUploadURLRequest] = [] /// 返回云盘文件列表。 func cloudFileList(parentFolderId: Int, name: String, type: Int, orderBy: Int, page: Int, pageSize: Int) async throws -> ListPayload { cloudListRequests.append(CloudListRequest(parentFolderId: parentFolderId, name: name, type: type, orderBy: orderBy, page: page, pageSize: pageSize)) return cloudPages.isEmpty ? ListPayload(total: 0, list: []) : cloudPages.removeFirst() } /// 创建文件夹。 func cloudFolderCreate(_ request: CloudFolderCreateRequest) async throws {} /// 登记云盘文件。 func cloudFileUpload(_ request: CloudFileUploadRequest) async throws { cloudUploadRequests.append(request) } /// 删除云盘文件。 func cloudFileDelete(_ request: CloudFileDeleteRequest) async throws {} /// 移动云盘文件。 func cloudFileMove(_ request: CloudFileMoveRequest) async throws {} /// 重命名文件夹。 func cloudFolderEdit(_ request: CloudFolderModifyRequest) async throws {} /// 重命名文件。 func cloudFileEdit(_ request: CloudFileModifyRequest) async throws {} /// 检查上传权限。 func checkCloudUploadPermission() async throws -> CheckCloudUploadResponse { try JSONDecoder().decode(CheckCloudUploadResponse.self, from: #"{"can_upload":\#(uploadPermission ? 1 : 0),"reason":"\#(uploadReason)"}"#.data(using: .utf8)!) } /// 返回素材列表。 func mediaAlbumList(kind: MediaLibraryKind, keyword: String, status: Int?, page: Int, pageSize: Int) async throws -> MediaLibraryListResponse { mediaListRequests.append(MediaListRequest(kind: kind, keyword: keyword, status: status, page: page)) return mediaListResponses.isEmpty ? MediaLibraryListResponse.fixture(total: 0, ids: []) : mediaListResponses.removeFirst() } /// 返回素材详情。 func mediaAlbumDetail(id: Int, kind: MediaLibraryKind) async throws -> MediaLibraryDetail { MediaLibraryDetail.fixture(id: id) } /// 更新上下架。 func mediaAlbumOperation(_ request: MediaAlbumOperationRequest) async throws { mediaOperationRequests.append(request) } /// 删除素材。 func mediaAlbumDelete(_ request: MediaAlbumDeleteRequest) async throws {} /// 新增标签。 func mediaAlbumAddTag(_ request: MediaAlbumAddTagRequest) async throws {} /// 上传素材。 func mediaAlbumUpload(_ request: MediaAlbumUploadRequest) async throws { mediaUploadRequests.append(request) } /// 编辑素材。 func mediaAlbumEdit(_ request: MediaAlbumEditRequest) async throws { mediaEditRequests.append(request) } /// 返回样片可关联项目列表。 func projectList(scenicId: Int, name: String?, page: Int, pageSize: Int) async throws -> ListPayload { projectListRequests.append(ProjectListRequest(scenicId: scenicId, name: name, page: page, pageSize: pageSize)) return projectPages.isEmpty ? ListPayload(total: 0, list: []) : projectPages.removeFirst() } /// 返回相册文件夹列表。 func albumFolderList(scenicId: Int, page: Int, pageSize: Int, name: String, startTime: String?, endTime: String?) async throws -> ListPayload { albumFolderListRequests.append(AlbumFolderListRequest(scenicId: scenicId, page: page, pageSize: pageSize, name: name, startTime: startTime, endTime: endTime)) return albumFolderPages.isEmpty ? ListPayload(total: 0, list: []) : albumFolderPages.removeFirst() } /// 返回相册文件夹详情。 func albumFolderInfo(id: Int) async throws -> AlbumFolderItem { albumInfoRequests.append(id) return AlbumFolderItem.fixture(id: id, name: "相册\(id)") } /// 返回相册文件列表。 func albumFileList(scenicId: Int, folderId: Int, fileType: Int, page: Int, pageSize: Int) async throws -> ListPayload { albumFileListRequests.append((scenicId: scenicId, folderId: folderId, fileType: fileType, page: page)) return albumFilePages.isEmpty ? ListPayload(total: 0, list: []) : albumFilePages.removeFirst() } /// 新建相册。 func addAlbumFolder(scenicId: Int, name: String, remark: String) async throws { albumAddRequests.append((scenicId: scenicId, name: name, remark: remark)) } /// 编辑相册。 func editAlbumFolder(folderId: Int, coverFileId: Int?, name: String?, remark: String?) async throws { albumEditRequests.append(AlbumFolderEditRequest(folderId: folderId, coverFileId: coverFileId, name: name, remark: remark)) } /// 删除相册文件。 func deleteAlbumFiles(folderId: Int, idList: [Int]) async throws { albumDeleteRequests.append(AlbumFileDeleteRequest(idList: idList, folderId: folderId)) } /// 登记相册上传文件。 func albumFileUploadURL(scenicId: Int, fileURL: String, folderId: Int) async throws { albumUploadURLRequests.append(AlbumUploadURLRequest(scenicId: scenicId, fileURL: fileURL, folderId: folderId)) } } /// OSS 上传测试替身,记录上传文件并返回固定 CDN URL。 @MainActor private final class MockAssetsUploader: OSSUploadServing { struct UploadedFile: Equatable { let fileName: String let fileType: Int let scenicId: Int } var uploadedFiles: [UploadedFile] = [] var shouldFail = false /// 上传用户头像。 func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress) } /// 上传实名认证图片。 func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress) } /// 上传云盘文件。 func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { try await upload(fileName: fileName, fileType: fileType, scenicId: scenicId, onProgress: onProgress) } /// 上传相册文件。 func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { try await upload(fileName: fileName, fileType: fileType, scenicId: scenicId, onProgress: onProgress) } /// 上传任务附件。 func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { try await upload(fileName: fileName, fileType: fileType, scenicId: scenicId, onProgress: onProgress) } /// 上传打卡点图片。 func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress) } /// 上传景区申请图片。 func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress) } /// 上传银行卡图片。 func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress) } /// 通用测试上传流程。 private func upload(fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { if shouldFail { throw APIError.networkFailed("上传失败") } uploadedFiles.append(UploadedFile(fileName: fileName, fileType: fileType, scenicId: scenicId)) onProgress(100) return "https://cdn.example.com/\(fileName)" } } private extension MediaLibraryListResponse { /// 创建素材列表测试数据。 static func fixture(total: Int, ids: [Int]) -> MediaLibraryListResponse { let list = ids.map { MediaLibraryItem.fixture(id: $0, status: 1, listingStatus: 0) } let payload = [ "total": total, "list": list.map { ["id": $0.id, "name": $0.name, "status": $0.status, "listing_status": $0.listingStatus] }, "order": ["total_num": 0, "avg_order_amount": 0, "refund_total": 0, "avg_change": 0] ] as [String: Any] let data = try! JSONSerialization.data(withJSONObject: payload) return try! JSONDecoder().decode(MediaLibraryListResponse.self, from: data) } } private extension MediaLibraryItem { /// 创建素材列表项测试数据。 static func fixture(id: Int, status: Int, listingStatus: Int) -> MediaLibraryItem { let payload: [String: Any] = [ "id": id, "name": "素材\(id)", "cover_url": "", "created_at": "", "status": status, "download_count": 0, "likes_count": 0, "collect_count": 0, "share_count": 0, "scenic_spot_name": "", "project_name": "", "listing_status": listingStatus ] let data = try! JSONSerialization.data(withJSONObject: payload) return try! JSONDecoder().decode(MediaLibraryItem.self, from: data) } } private extension MediaLibraryDetail { /// 创建素材详情测试数据。 static func fixture(id: Int) -> MediaLibraryDetail { let payload: [String: Any] = [ "id": id, "name": "素材\(id)", "cover_url": "", "description": "", "status": 1, "listing_status": 0, "likes_count": 0, "download_count": 0, "collect_count": 0, "share_count": 0, "uploader_name": "", "created_at": "", "media_list": [], "scenic_name": "", "project_name": "" ] let data = try! JSONSerialization.data(withJSONObject: payload) return try! JSONDecoder().decode(MediaLibraryDetail.self, from: data) } } private extension AlbumFolderItem { /// 创建相册文件夹测试数据。 static func fixture(id: Int, name: String) -> AlbumFolderItem { let payload: [String: Any] = [ "id": id, "name": name, "cover_file_id": 0, "count_video": 1, "count_image": 2, "created_at": "2026", "remark": "备注" ] let data = try! JSONSerialization.data(withJSONObject: payload) return try! JSONDecoder().decode(AlbumFolderItem.self, from: data) } } private extension AlbumFileItem { /// 创建相册文件测试数据。 static func fixture(id: Int, fileType: Int) -> AlbumFileItem { let fileName = fileType == 1 ? "v.mp4" : "a.jpg" let payload: [String: Any] = [ "id": id, "file_name": fileName, "file_type": fileType, "file_url": "https://cdn.example.com/\(fileName)", "cover_url": "", "file_size": "2048", "file_size_human": "2KB", "remark": "" ] let data = try! JSONSerialization.data(withJSONObject: payload) return try! JSONDecoder().decode(AlbumFileItem.self, from: data) } }