兼容 Android 媒体项 size 字段格式,增强灵活解码容错,并统一样片列表开关确认逻辑与安全区布局。 Co-authored-by: Cursor <cursoragent@cursor.com>
398 lines
16 KiB
Swift
398 lines
16 KiB
Swift
//
|
|
// 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<String, Error>]
|
|
var emitLateProgress = false
|
|
|
|
init(urls: [String]) {
|
|
results = urls.map(Result.success)
|
|
}
|
|
|
|
init(results: [Result<String, Error>]) {
|
|
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
|
|
)
|
|
}
|