模块化 AppStore 并完善素材管理与个人空间设置。
将会话、权限、位置、收款与排队存储拆分为独立模块,同步更新各 ViewModel 与单元测试,并补充素材管理 UI 与个人空间设置交互。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -10,17 +10,27 @@ import XCTest
|
||||
/// 素材管理 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: 10), makeItem(id: 11)]),
|
||||
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])
|
||||
@ -118,43 +128,69 @@ final class MaterialManagementViewModelTests: XCTestCase {
|
||||
XCTAssertNil(api.lastUploadRequest?.id)
|
||||
}
|
||||
|
||||
func testCloudMediaImportBuildsMaterialRequestWithoutUploadingAgain() async {
|
||||
let api = MockMaterialAPI()
|
||||
api.spotResponse = ScenicSpotListResponse(list: [ScenicSpotItem(id: 9, name: "打卡点")], total: 1)
|
||||
func testChangingMediaTypeClearsSelectedMedia() async {
|
||||
let viewModel = MaterialFormViewModel(mode: .create, currentScenicIdProvider: { 3 })
|
||||
var didSucceed = false
|
||||
viewModel.onSubmitSuccess = { didSucceed = true }
|
||||
await viewModel.addLocalMedia(
|
||||
[makeUploadItem(fileName: "image.jpg")],
|
||||
uploader: MockMaterialUploader(urls: ["https://cdn/image.jpg"])
|
||||
)
|
||||
|
||||
await viewModel.loadInitial(api: api)
|
||||
viewModel.updateMaterialName("云盘素材")
|
||||
viewModel.selectScenicSpot(ScenicSpotItem(id: 9, name: "打卡点"))
|
||||
viewModel.addCloudMediaFiles([
|
||||
CloudFile(id: 31, fileUrl: "https://cdn/cloud.jpg", coverUrl: "https://cdn/cover-thumb.jpg", name: "cloud.jpg", type: 2, fileSize: 88),
|
||||
])
|
||||
await viewModel.setCoverImage(makeUploadItem(fileName: "cover.jpg"), uploader: MockMaterialUploader(urls: ["https://cdn/cover.jpg"]))
|
||||
await viewModel.submit(api: api)
|
||||
viewModel.selectMediaType(.video)
|
||||
|
||||
XCTAssertTrue(didSucceed)
|
||||
XCTAssertEqual(viewModel.importedCloudFileIDs, Set([31]))
|
||||
XCTAssertEqual(api.lastUploadRequest?.mediaList.first?.originalName, "cloud.jpg")
|
||||
XCTAssertEqual(api.lastUploadRequest?.mediaList.first?.ossUrl, "https://cdn/cloud.jpg")
|
||||
XCTAssertEqual(api.lastUploadRequest?.mediaList.first?.size, 88)
|
||||
XCTAssertEqual(viewModel.mediaType, .video)
|
||||
XCTAssertTrue(viewModel.uploadedMediaList.isEmpty)
|
||||
}
|
||||
|
||||
func testCloudMediaImportFiltersByCurrentMaterialTypeAndDeduplicates() {
|
||||
func testMediaLimitsAndSequentialUploadMatchAndroid() async {
|
||||
let viewModel = MaterialFormViewModel(mode: .create, currentScenicIdProvider: { 3 })
|
||||
var messages: [String] = []
|
||||
viewModel.onShowMessage = { messages.append($0) }
|
||||
let imageItems = (0 ..< 11).map { makeUploadItem(fileName: "\($0).jpg") }
|
||||
let uploader = MockMaterialUploader(urls: (0 ..< 11).map { "https://cdn/\($0).jpg" })
|
||||
|
||||
viewModel.addCloudMediaFiles([
|
||||
CloudFile(id: 1, fileUrl: "https://cdn/video.mp4", name: "video.mp4", type: 1),
|
||||
CloudFile(id: 2, fileUrl: "https://cdn/image.jpg", name: "image.jpg", type: 2),
|
||||
CloudFile(id: 2, fileUrl: "https://cdn/image.jpg", name: "image.jpg", type: 2),
|
||||
])
|
||||
await viewModel.addLocalMedia(imageItems, uploader: uploader)
|
||||
|
||||
XCTAssertEqual(viewModel.uploadedMediaList.map(\.ossUrl), ["https://cdn/image.jpg"])
|
||||
XCTAssertEqual(viewModel.importedCloudFileIDs, Set([2]))
|
||||
XCTAssertTrue(messages.isEmpty)
|
||||
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 {
|
||||
@ -261,6 +297,7 @@ private final class MockMaterialAPI: MaterialManagementServing {
|
||||
@MainActor
|
||||
private final class MockMaterialUploader: MaterialOSSUploading {
|
||||
private var urls: [String]
|
||||
private(set) var uploadedFileNames: [String] = []
|
||||
|
||||
init(urls: [String]) {
|
||||
self.urls = urls
|
||||
@ -273,6 +310,7 @@ private final class MockMaterialUploader: MaterialOSSUploading {
|
||||
scenicId: Int,
|
||||
onProgress: @escaping (Int) -> Void
|
||||
) async throws -> String {
|
||||
uploadedFileNames.append(fileName)
|
||||
onProgress(100)
|
||||
return urls.isEmpty ? "https://cdn/default.jpg" : urls.removeFirst()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user