// // MaterialManagementViewModelTests.swift // suixinkanTests // import Foundation import XCTest @testable import suixinkan /// 素材管理 ViewModel 测试。 @MainActor final class MaterialManagementViewModelTests: XCTestCase { func testDetailCarouselLoopsAcrossCopiedBoundaryPages() { XCTAssertEqual(MaterialCarouselIndexMapper.initialRenderedPage(mediaCount: 3), 1) XCTAssertEqual(MaterialCarouselIndexMapper.normalizedRenderedPage(0, mediaCount: 3), 3) XCTAssertEqual(MaterialCarouselIndexMapper.logicalPage(renderedPage: 3, mediaCount: 3), 2) XCTAssertEqual(MaterialCarouselIndexMapper.normalizedRenderedPage(4, mediaCount: 3), 1) XCTAssertEqual(MaterialCarouselIndexMapper.logicalPage(renderedPage: 1, mediaCount: 3), 0) XCTAssertEqual(MaterialCarouselIndexMapper.initialRenderedPage(mediaCount: 1), 0) } func testListLoadsMoreSearchesAndMapsFilterStatus() async { let api = MockMaterialAPI() api.listResponses = [ MaterialListResponse(total: 12, list: (0 ..< 10).map { makeItem(id: $0) }, order: MaterialOrderInfo(totalNum: 1386, avgOrderAmount: "12", refundTotal: "3")), MaterialListResponse(total: 12, list: [makeItem(id: 9), makeItem(id: 10), makeItem(id: 11)]), MaterialListResponse(total: 1, list: [makeItem(id: 99)]), ] let viewModel = MaterialListViewModel() await viewModel.loadInitial(api: api) await viewModel.loadMoreIfNeeded(lastVisibleIndex: 8, api: api) XCTAssertEqual(viewModel.items.map(\.id), Array(0 ... 11)) 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 testRejectedMaterialCannotToggleListing() async { let api = MockMaterialAPI() api.listResponses = [MaterialListResponse(total: 1, list: [makeItem(id: 1, status: 0, listingStatus: 0)])] let viewModel = MaterialListViewModel() var messages: [String] = [] viewModel.onShowMessage = { messages.append($0) } await viewModel.loadInitial(api: api) await viewModel.toggleListing(materialId: 1, checked: true, api: api) XCTAssertEqual(messages.last, "审核通过才可以操作上下架") XCTAssertTrue(api.operationCalls.isEmpty) } func testApprovedMaterialToggleUpdatesLocalItem() async { let api = MockMaterialAPI() api.listResponses = [MaterialListResponse(total: 1, list: [makeItem(id: 2, status: 1, listingStatus: 0)])] let viewModel = MaterialListViewModel() await viewModel.loadInitial(api: api) await viewModel.toggleListing(materialId: 2, checked: true, api: api) XCTAssertEqual(api.operationCalls.first?.id, 2) XCTAssertEqual(api.operationCalls.first?.listingStatus, 1) XCTAssertEqual(viewModel.items.first?.listingStatus, 1) } func testDetailDeleteCallsCallback() async { let api = MockMaterialAPI() let viewModel = MaterialDetailViewModel(materialId: 7) var deletedId: Int? viewModel.onDeleted = { deletedId = $0 } await viewModel.delete(api: api) XCTAssertEqual(api.deletedIds, [7]) XCTAssertEqual(deletedId, 7) } func testUploadValidationMessages() async { let api = MockMaterialAPI() let viewModel = MaterialFormViewModel(mode: .create, currentScenicIdProvider: { 3 }) var messages: [String] = [] viewModel.onShowMessage = { messages.append($0) } await viewModel.submit(api: api) viewModel.updateMaterialName("素材") await viewModel.submit(api: api) await viewModel.addLocalMedia([makeUploadItem(fileName: "a.jpg")], uploader: MockMaterialUploader(urls: ["https://cdn/a.jpg"])) await viewModel.submit(api: api) await viewModel.setCoverImage(makeUploadItem(fileName: "c.jpg"), uploader: MockMaterialUploader(urls: ["https://cdn/c.jpg"])) await viewModel.submit(api: api) XCTAssertEqual(messages.prefix(4), [ "请输入素材名称", "请至少上传一个素材", "请上传封面图片", "请选择关联打卡点", ]) XCTAssertNil(api.lastUploadRequest) } func testUploadSuccessBuildsMaterialRequestWithTags() async { let api = MockMaterialAPI() api.spotResponse = ScenicSpotListResponse(list: [ScenicSpotItem(id: 9, name: "打卡点")], total: 1) let viewModel = MaterialFormViewModel(mode: .create, currentScenicIdProvider: { 3 }) let uploader = MockMaterialUploader(urls: ["https://cdn/a.jpg", "https://cdn/c.jpg"]) var didSucceed = false viewModel.onSubmitSuccess = { didSucceed = true } await viewModel.loadInitial(api: api) viewModel.updateMaterialName("素材") viewModel.selectScenicSpot(ScenicSpotItem(id: 9, name: "打卡点")) viewModel.updateTagInput("snow-tag") await viewModel.addTag(api: api) await viewModel.addLocalMedia([makeUploadItem(fileName: "a.jpg")], uploader: uploader) await viewModel.setCoverImage(makeUploadItem(fileName: "c.jpg"), uploader: uploader) await viewModel.submit(api: api) XCTAssertTrue(didSucceed) XCTAssertEqual(api.lastUploadRequest?.name, "素材") XCTAssertEqual(api.lastUploadRequest?.type, "1") 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?.materialTag, "42") XCTAssertNil(api.lastUploadRequest?.id) } func testChangingMediaTypeClearsSelectedMedia() async { let viewModel = MaterialFormViewModel(mode: .create, currentScenicIdProvider: { 3 }) await viewModel.addLocalMedia( [makeUploadItem(fileName: "image.jpg")], uploader: MockMaterialUploader(urls: ["https://cdn/image.jpg"]) ) viewModel.selectMediaType(.video) XCTAssertEqual(viewModel.mediaType, .video) XCTAssertTrue(viewModel.uploadedMediaList.isEmpty) } func testMediaLimitsAndSequentialUploadMatchAndroid() async { let viewModel = MaterialFormViewModel(mode: .create, currentScenicIdProvider: { 3 }) let imageItems = (0 ..< 11).map { makeUploadItem(fileName: "\($0).jpg") } let uploader = MockMaterialUploader(urls: (0 ..< 11).map { "https://cdn/\($0).jpg" }) await viewModel.addLocalMedia(imageItems, uploader: uploader) XCTAssertEqual(viewModel.uploadedMediaList.count, 9) XCTAssertEqual(uploader.uploadedFileNames, (0 ..< 9).map { "\($0).jpg" }) viewModel.selectMediaType(.video) let videoUploader = MockMaterialUploader(urls: ["https://cdn/a.mp4", "https://cdn/b.mp4"]) await viewModel.addLocalMedia( [makeUploadItem(fileName: "a.mp4"), makeUploadItem(fileName: "b.mp4")], uploader: videoUploader ) XCTAssertEqual(viewModel.uploadedMediaList.map(\.fileName), ["a.mp4"]) XCTAssertEqual(videoUploader.uploadedFileNames, ["a.mp4"]) } func testTagDeletionRequiresConfirmation() async { let api = MockMaterialAPI() let viewModel = MaterialFormViewModel(mode: .create, currentScenicIdProvider: { 3 }) viewModel.updateTagInput("snow-tag") await viewModel.addTag(api: api) let tag = try! XCTUnwrap(viewModel.tagList.first) viewModel.requestDeleteTag(tag) XCTAssertEqual(viewModel.pendingDeleteTag, tag) XCTAssertEqual(viewModel.tagList, [tag]) viewModel.cancelDeleteTag() XCTAssertNil(viewModel.pendingDeleteTag) XCTAssertEqual(viewModel.tagList, [tag]) viewModel.requestDeleteTag(tag) viewModel.confirmDeleteTag() XCTAssertNil(viewModel.pendingDeleteTag) XCTAssertTrue(viewModel.tagList.isEmpty) } func testInputLengthsAreCappedLikeAndroid() { let viewModel = MaterialFormViewModel(mode: .create, currentScenicIdProvider: { 3 }) viewModel.updateMaterialName(String(repeating: "名", count: 35)) viewModel.updateTagInput(String(repeating: "标", count: 20)) XCTAssertEqual(viewModel.materialName.count, 30) XCTAssertEqual(viewModel.tagInput.count, 15) } func testUploadDialogStaysHiddenAfterCompletionProgressCallback() async { let viewModel = MaterialFormViewModel(mode: .create, currentScenicIdProvider: { 3 }) let uploader = MockMaterialUploader(urls: ["https://cdn/a.jpg"]) await viewModel.addLocalMedia([makeUploadItem(fileName: "a.jpg")], uploader: uploader) await Task.yield() XCTAssertNil(viewModel.uploadDialogState) XCTAssertFalse(viewModel.uploadedMediaList.first?.isUploading ?? true) XCTAssertEqual(viewModel.uploadedMediaList.first?.uploadProgress, 100) } func testEditLoadsDetailAndBuildsEditRequest() async { let api = MockMaterialAPI() api.spotResponse = ScenicSpotListResponse(list: [ScenicSpotItem(id: 9, name: "打卡点")], total: 1) api.detailResponse = try! decodeDetail(""" { "id": 11, "name": "旧素材", "status": 1, "listing_status": 1, "cover_url": "https://cdn/cover.jpg", "cover_size": 22, "scenic_spot_id": 9, "material_tag": [{"id": 3, "name": "cloudy"}], "media_list": [{ "id": 5, "original_name": "old.jpg", "oss_url": "https://cdn/old.jpg", "type": 1, "size": 10, "width": 100, "height": 80 }] } """) let viewModel = MaterialFormViewModel(mode: .edit(id: 11), currentScenicIdProvider: { 3 }) await viewModel.loadInitial(api: api) await viewModel.submit(api: api) XCTAssertEqual(viewModel.materialName, "旧素材") XCTAssertEqual(viewModel.selectedScenicSpot?.id, 9) XCTAssertEqual(viewModel.uploadedMediaList.first?.ossUrl, "https://cdn/old.jpg") XCTAssertEqual(api.lastEditRequest?.id, 11) XCTAssertEqual(api.lastEditRequest?.type, "1") XCTAssertEqual(api.lastEditRequest?.materialTag, "3") } } /// 素材管理 API mock。 /// 素材管理 API mock。 @MainActor private final class MockMaterialAPI: MaterialManagementServing { var listResponses: [MaterialListResponse] = [] var listCalls: [(status: Int, keyword: String?, page: Int, pageSize: Int)] = [] var operationCalls: [(id: Int, listingStatus: Int)] = [] var deletedIds: [Int] = [] var tagId = 42 var lastUploadRequest: MaterialUploadRequest? var lastEditRequest: MaterialUploadRequest? var spotResponse = ScenicSpotListResponse(list: [], total: 0) var detailResponse = try! decodeDetail(#"{"id":1,"status":1,"listing_status":1}"#) func list(status: Int, keyword: String?, page: Int, pageSize: Int) async throws -> MaterialListResponse { listCalls.append((status, keyword, page, pageSize)) guard !listResponses.isEmpty else { return MaterialListResponse() } return listResponses.removeFirst() } func detail(id: Int) async throws -> MaterialDetail { detailResponse } func setListingStatus(id: Int, listingStatus: Int) async throws { operationCalls.append((id, listingStatus)) } func delete(id: Int) async throws { deletedIds.append(id) } func addTag(name: String) async throws -> Int { tagId } func upload(_ request: MaterialUploadRequest) async throws { lastUploadRequest = request } func edit(_ request: MaterialUploadRequest) async throws { lastEditRequest = request } func scenicSpotListAll(scenicId: String) async throws -> ScenicSpotListResponse { spotResponse } } /// 素材管理 OSS 上传 mock。 /// 素材管理 OSS 上传 mock。 @MainActor private final class MockMaterialUploader: MaterialOSSUploading { private var urls: [String] private(set) var uploadedFileNames: [String] = [] init(urls: [String]) { self.urls = urls } func uploadMaterialFile( data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void ) async throws -> String { uploadedFileNames.append(fileName) onProgress(100) return urls.isEmpty ? "https://cdn/default.jpg" : urls.removeFirst() } } private func makeItem(id: Int, status: Int = 1, listingStatus: Int = 0) -> MaterialItem { MaterialItem( id: id, name: "素材\(id)", coverUrl: "", createdAt: "", status: status, downloadCount: 0, likesCount: 0, collectCount: 0, shareCount: 0, listingStatus: listingStatus ) } private func makeUploadItem(fileName: String, ossUrl: String = "") -> MaterialUploadedMediaItem { MaterialUploadedMediaItem( data: Data(repeating: 1, count: 10), fileName: fileName, size: 10, width: 1, height: 1, ossUrl: ossUrl ) } private func decodeDetail(_ json: String) throws -> MaterialDetail { try JSONDecoder().decode(MaterialDetail.self, from: Data(json.utf8)) }