// // SampleManagementViewModelTests.swift // suixinkanTests // import Foundation import XCTest @testable import suixinkan @MainActor /// 样片管理 ViewModel 测试。 final class SampleManagementViewModelTests: XCTestCase { func testAllFilterStatusesMatchAndroidValues() { XCTAssertEqual(SampleFilterStatus.all.apiStatus, 4) XCTAssertEqual(SampleFilterStatus.approved.apiStatus, 1) XCTAssertEqual(SampleFilterStatus.pending.apiStatus, 0) XCTAssertEqual(SampleFilterStatus.rejected.apiStatus, 2) } func testListLoadsRefreshesAndMapsFilterStatus() async { let api = MockSampleAPI() api.listResponses = [ SampleMaterialListResponse(total: 12, list: (0 ..< 10).map { makeItem(id: $0) }), SampleMaterialListResponse(total: 12, list: [makeItem(id: 10), makeItem(id: 11)]), SampleMaterialListResponse(total: 1, list: [makeItem(id: 99)]), ] let viewModel = SampleListViewModel() await viewModel.loadInitial(api: api) await viewModel.loadMoreIfNeeded(lastVisibleIndex: 8, api: api) await viewModel.selectFilter(.pending, api: api) XCTAssertEqual(api.listCalls.map(\.status), [4, 4, 0]) XCTAssertEqual(api.listCalls.map(\.page), [1, 2, 1]) XCTAssertEqual(viewModel.items.map(\.id), [99]) } func testRejectedSampleCannotToggleListing() async { let api = MockSampleAPI() api.listResponses = [SampleMaterialListResponse(total: 1, list: [makeItem(id: 1, status: 0, listingStatus: 0)])] let viewModel = SampleListViewModel() var messages: [String] = [] viewModel.onShowMessage = { messages.append($0) } await viewModel.loadInitial(api: api) let resolved = await viewModel.toggleListing(sampleId: 1, checked: true, api: api) XCTAssertEqual(messages.last, "审核通过才可以操作上下架") XCTAssertTrue(api.operationCalls.isEmpty) XCTAssertFalse(resolved) XCTAssertFalse(viewModel.isOperating) } func testApprovedSampleToggleUpdatesLocalItem() async { let api = MockSampleAPI() api.listResponses = [SampleMaterialListResponse(total: 1, list: [makeItem(id: 2, status: 1, listingStatus: 0)])] let viewModel = SampleListViewModel() await viewModel.loadInitial(api: api) let resolved = await viewModel.toggleListing(sampleId: 2, checked: true, api: api) XCTAssertEqual(api.operationCalls.first?.id, 2) XCTAssertEqual(api.operationCalls.first?.listingStatus, 1) XCTAssertEqual(viewModel.items.first?.listingStatus, 1) XCTAssertTrue(resolved) XCTAssertFalse(viewModel.isOperating) } func testApprovedSampleExposesOperatingStateDuringRequest() async { let api = MockSampleAPI() api.listResponses = [SampleMaterialListResponse(total: 1, list: [makeItem(id: 2, status: 1, listingStatus: 0)])] api.operationDelayNanoseconds = 50_000_000 let viewModel = SampleListViewModel() await viewModel.loadInitial(api: api) let operation = Task { await viewModel.toggleListing(sampleId: 2, checked: true, api: api) } await Task.yield() XCTAssertTrue(viewModel.operatingSampleIDs.contains(2)) XCTAssertTrue(viewModel.isOperating) let resolved = await operation.value XCTAssertTrue(resolved) XCTAssertFalse(viewModel.isOperating) } func testListingFailureReturnsAuthoritativePreviousState() async { let api = MockSampleAPI() api.listResponses = [SampleMaterialListResponse(total: 1, list: [makeItem(id: 3, status: 1, listingStatus: 0)])] api.operationError = APIError.emptyData let viewModel = SampleListViewModel() await viewModel.loadInitial(api: api) let resolved = await viewModel.toggleListing(sampleId: 3, checked: true, api: api) XCTAssertFalse(resolved) XCTAssertEqual(viewModel.items.first?.listingStatus, 0) XCTAssertFalse(viewModel.isOperating) } func testSearchAndDuplicateLoadingProtection() async { let api = MockSampleAPI() api.listDelayNanoseconds = 50_000_000 api.listResponses = [SampleMaterialListResponse(total: 1, list: [makeItem(id: 1)])] let viewModel = SampleListViewModel() let first = Task { await viewModel.updateSearchText(" 雪山 ", api: api) } await Task.yield() await viewModel.loadInitial(api: api) await first.value XCTAssertEqual(api.listCalls.count, 1) XCTAssertEqual(api.listCalls.first?.keyword, "雪山") XCTAssertEqual(api.listCalls.first?.pageSize, 10) } func testChangingMediaTypeClearsMaterialsButKeepsCover() async { let viewModel = UploadSampleViewModel(currentScenicIdProvider: { 3 }) let uploader = MockSampleUploader(urls: ["https://cdn/a.jpg", "https://cdn/cover.jpg"]) await viewModel.addLocalMedia([makeUploadItem(fileName: "a.jpg")], uploader: uploader) await viewModel.setCoverImage(makeUploadItem(fileName: "cover.jpg"), uploader: uploader) viewModel.selectMediaType(.video) XCTAssertTrue(viewModel.uploadedMediaList.isEmpty) XCTAssertEqual(viewModel.coverImage?.ossUrl, "https://cdn/cover.jpg") XCTAssertEqual(viewModel.mediaType, .video) } func testScenicCascadeClearsSelectionsAndProjectPagination() async { let firstScenic = ScenicInfo(id: 3, name: "景区A", status: 1, coverImg: nil) let secondScenic = ScenicInfo(id: 4, name: "景区B", status: 1, coverImg: nil) let api = MockSampleAPI() api.projectResponses = [ SampleProjectListResponse(list: (0 ..< 10).map { SampleProjectItem(id: $0, name: "项目\($0)") }, total: 11), SampleProjectListResponse(list: [SampleProjectItem(id: 10, name: "项目10")], total: 11), ] let viewModel = UploadSampleViewModel(currentScenicIdProvider: { 3 }) await viewModel.selectScenic(firstScenic, api: api) await viewModel.loadProjects(refresh: true, api: api) await viewModel.loadProjects(refresh: false, api: api) viewModel.selectScenicSpot(ScenicSpotItem(id: 9, name: "打卡点")) viewModel.selectProject(SampleProjectItem(id: 10, name: "项目10")) XCTAssertEqual(api.projectCalls.map(\.page), [1, 2]) XCTAssertEqual(viewModel.projectList.count, 11) await viewModel.selectScenic(secondScenic, api: api) XCTAssertNil(viewModel.selectedScenicSpot) XCTAssertNil(viewModel.selectedProject) XCTAssertTrue(viewModel.projectList.isEmpty) XCTAssertFalse(viewModel.projectCanLoadMore) } func testMaterialCountLimitsFollowMediaType() async { let imageViewModel = UploadSampleViewModel(currentScenicIdProvider: { 3 }) await imageViewModel.addLocalMedia( (0 ..< 10).map { makeUploadItem(fileName: "\($0).jpg") }, uploader: MockSampleUploader(urls: (0 ..< 9).map { "https://cdn/\($0).jpg" }) ) XCTAssertEqual(imageViewModel.uploadedMediaList.count, 9) let videoViewModel = UploadSampleViewModel(currentScenicIdProvider: { 3 }) videoViewModel.selectMediaType(.video) await videoViewModel.addLocalMedia( [makeUploadItem(fileName: "a.mp4"), makeUploadItem(fileName: "b.mp4")], uploader: MockSampleUploader(urls: ["https://cdn/a.mp4"]) ) XCTAssertEqual(videoViewModel.uploadedMediaList.count, 1) } func testSequentialUploadContinuesAfterOneFailure() async { let viewModel = UploadSampleViewModel(currentScenicIdProvider: { 3 }) let uploader = MockSampleUploader(results: [ .failure(APIError.emptyData), .success("https://cdn/b.jpg"), ]) await viewModel.addLocalMedia( [makeUploadItem(fileName: "a.jpg"), makeUploadItem(fileName: "b.jpg")], uploader: uploader ) XCTAssertEqual(viewModel.uploadedMediaList[0].ossUrl, "") XCTAssertEqual(viewModel.uploadedMediaList[1].ossUrl, "https://cdn/b.jpg") XCTAssertNil(viewModel.uploadDialogState) } func testLateProgressCallbackCannotReopenCompletedUploadDialog() async { let viewModel = UploadSampleViewModel(currentScenicIdProvider: { 3 }) let uploader = MockSampleUploader(urls: ["https://cdn/a.jpg"]) uploader.emitLateProgress = true await viewModel.addLocalMedia([makeUploadItem(fileName: "a.jpg")], uploader: uploader) try? await Task.sleep(nanoseconds: 1_000_000) XCTAssertEqual(viewModel.uploadedMediaList.first?.ossUrl, "https://cdn/a.jpg") XCTAssertFalse(viewModel.uploadedMediaList.first?.isUploading ?? true) XCTAssertNil(viewModel.uploadDialogState) } func testUploadValidationMessages() async { let api = MockSampleAPI() let viewModel = UploadSampleViewModel( scenicListProvider: { [ScenicInfo(id: 3, name: "景区", status: 1, coverImg: nil)] }, currentScenicIdProvider: { 3 } ) var messages: [String] = [] viewModel.onShowMessage = { messages.append($0) } await viewModel.uploadSample(api: api) viewModel.updateAlbumName("样片") await viewModel.uploadSample(api: api) await viewModel.addLocalMedia([makeUploadItem(fileName: "a.jpg", ossUrl: "")], uploader: MockSampleUploader(urls: ["https://cdn/a.jpg"])) await viewModel.uploadSample(api: api) await viewModel.setCoverImage(makeUploadItem(fileName: "c.jpg", ossUrl: ""), uploader: MockSampleUploader(urls: ["https://cdn/c.jpg"])) await viewModel.uploadSample(api: api) XCTAssertEqual(messages.prefix(4), [ "请输入相册名称", "请至少上传一个素材", "请上传封面图片", "请选择关联景区", ]) XCTAssertNil(api.lastUploadRequest) } func testUploadValidationRequiresScenicSpotThenProject() async { let scenic = ScenicInfo(id: 3, name: "景区", status: 1, coverImg: nil) let api = MockSampleAPI() let viewModel = UploadSampleViewModel( scenicListProvider: { [scenic] }, currentScenicIdProvider: { 3 } ) var messages: [String] = [] viewModel.onShowMessage = { messages.append($0) } viewModel.updateAlbumName("样片") await viewModel.addLocalMedia( [makeUploadItem(fileName: "a.jpg")], uploader: MockSampleUploader(urls: ["https://cdn/a.jpg"]) ) await viewModel.setCoverImage( makeUploadItem(fileName: "cover.jpg"), uploader: MockSampleUploader(urls: ["https://cdn/cover.jpg"]) ) await viewModel.selectScenic(scenic, api: api) await viewModel.uploadSample(api: api) viewModel.selectScenicSpot(ScenicSpotItem(id: 9, name: "打卡点")) await viewModel.uploadSample(api: api) XCTAssertEqual(Array(messages.suffix(2)), ["请选择关联打卡点", "请选择关联项目"]) XCTAssertNil(api.lastUploadRequest) } func testUploadSuccessBuildsSampleRequest() async { let scenic = ScenicInfo(id: 3, name: "景区", status: 1, coverImg: nil) let api = MockSampleAPI() api.spotResponse = ScenicSpotListResponse(list: [ScenicSpotItem(id: 9, name: "打卡点")], total: 1) let viewModel = UploadSampleViewModel( scenicListProvider: { [scenic] }, currentScenicIdProvider: { 3 } ) let uploader = MockSampleUploader(urls: ["https://cdn/a.jpg", "https://cdn/c.jpg"]) var didSucceed = false viewModel.onUploadSuccess = { didSucceed = true } viewModel.updateAlbumName("样片") await viewModel.selectScenic(scenic, api: api) viewModel.selectScenicSpot(ScenicSpotItem(id: 9, name: "打卡点")) viewModel.selectProject(SampleProjectItem(id: 7, name: "项目")) await viewModel.addLocalMedia([makeUploadItem(fileName: "a.jpg")], uploader: uploader) await viewModel.setCoverImage(makeUploadItem(fileName: "c.jpg"), uploader: uploader) await viewModel.uploadSample(api: api) XCTAssertTrue(didSucceed) XCTAssertEqual(api.lastUploadRequest?.name, "样片") XCTAssertEqual(api.lastUploadRequest?.type, "2") XCTAssertEqual(api.lastUploadRequest?.mediaType, "1") XCTAssertEqual(api.lastUploadRequest?.mediaList.first?.ossUrl, "https://cdn/a.jpg") XCTAssertEqual(api.lastUploadRequest?.coverUrl, "https://cdn/c.jpg") XCTAssertEqual(api.lastUploadRequest?.scenicSpotId, 9) XCTAssertEqual(api.lastUploadRequest?.projectId, 7) } } @MainActor private final class MockSampleAPI: SampleManagementServing { var listResponses: [SampleMaterialListResponse] = [] var listCalls: [(status: Int, keyword: String?, page: Int, pageSize: Int)] = [] var operationCalls: [(id: Int, listingStatus: Int)] = [] var lastUploadRequest: SampleUploadMaterialRequest? var spotResponse = ScenicSpotListResponse(list: [], total: 0) var projectResponses: [SampleProjectListResponse] = [] var operationError: Error? var listDelayNanoseconds: UInt64 = 0 var operationDelayNanoseconds: UInt64 = 0 var projectCalls: [(scenicId: String, page: Int, pageSize: Int)] = [] func list(status: Int, keyword: String?, page: Int, pageSize: Int) async throws -> SampleMaterialListResponse { listCalls.append((status, keyword, page, pageSize)) if listDelayNanoseconds > 0 { try await Task.sleep(nanoseconds: listDelayNanoseconds) } guard !listResponses.isEmpty else { return SampleMaterialListResponse() } return listResponses.removeFirst() } func detail(id: Int) async throws -> SampleDetail { throw APIError.emptyData } func setListingStatus(id: Int, listingStatus: Int) async throws { operationCalls.append((id, listingStatus)) if operationDelayNanoseconds > 0 { try await Task.sleep(nanoseconds: operationDelayNanoseconds) } if let operationError { throw operationError } } func upload(_ request: SampleUploadMaterialRequest) async throws { lastUploadRequest = request } func scenicSpotListAll(scenicId: String) async throws -> ScenicSpotListResponse { spotResponse } func projectList(scenicId: String, name: String?, page: Int, pageSize: Int) async throws -> SampleProjectListResponse { projectCalls.append((scenicId, page, pageSize)) guard !projectResponses.isEmpty else { return SampleProjectListResponse() } return projectResponses.removeFirst() } } @MainActor private final class MockSampleUploader: SampleOSSUploading { private var results: [Result] var emitLateProgress = false init(urls: [String]) { results = urls.map(Result.success) } init(results: [Result]) { self.results = results } func uploadSampleFile( data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void ) async throws -> String { if emitLateProgress { onProgress(20) Task { @MainActor in await Task.yield() onProgress(100) } } else { onProgress(100) } guard !results.isEmpty else { return "https://cdn/default.jpg" } return try results.removeFirst().get() } } private func makeItem(id: Int, status: Int = 1, listingStatus: Int = 0) -> SampleMaterialItem { SampleMaterialItem( id: id, name: "样片\(id)", coverUrl: "", createdAt: "", status: status, downloadCount: 0, likesCount: 0, collectCount: 0, shareCount: 0, scenicSpotName: "打卡点", projectName: "项目", listingStatus: listingStatus ) } private func makeUploadItem(fileName: String, ossUrl: String = "") -> SampleUploadedMediaItem { SampleUploadedMediaItem( data: Data(repeating: 1, count: 10), fileName: fileName, size: 10, width: 1920, height: 1080, ossUrl: ossUrl ) }