Files
suixinkan_uikit/suixinkanTests/MaterialManagementViewModelTests.swift
汉秋 005349f8e6 模块化 AppStore 并完善素材管理与个人空间设置。
将会话、权限、位置、收款与排队存储拆分为独立模块,同步更新各 ViewModel 与单元测试,并补充素材管理 UI 与个人空间设置交互。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 11:01:08 +08:00

348 lines
13 KiB
Swift

//
// 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))
}