Files
suixinkan_ios_new/suixinkanTests/Assets/AssetsViewModelTests.swift
汉秋 a48eb76ba4 Add Assets module with cloud storage and media library flows.
Wire home routing for cloud and material management pages, move shared cloud models into Assets, and add API/view model tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 16:10:20 +08:00

365 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// AssetsViewModelTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/23.
//
import XCTest
@testable import suixinkan
@MainActor
/// ViewModel
final class AssetsViewModelTests: XCTestCase {
/// OSS
func testCloudUploadStopsWhenPermissionDenied() async {
let api = MockAssetsService()
api.uploadPermission = false
api.uploadReason = "空间不足"
let uploader = MockAssetsUploader()
let viewModel = CloudStorageViewModel()
let success = await viewModel.upload(
localFiles: [CloudLocalUploadFile(data: Data([1]), fileName: "a.jpg", fileType: 2)],
scenicId: 9,
api: api,
uploadService: uploader,
transferStore: CloudTransferStore()
)
XCTAssertFalse(success)
XCTAssertEqual(viewModel.errorMessage, "空间不足")
XCTAssertEqual(uploader.uploadedFiles.count, 0)
XCTAssertEqual(api.cloudUploadRequests.count, 0)
}
/// OSS
func testCloudUploadUploadsOSSBeforeCloudRecord() async {
let api = MockAssetsService()
let uploader = MockAssetsUploader()
let transferStore = CloudTransferStore()
let viewModel = CloudStorageViewModel()
let success = await viewModel.upload(
localFiles: [CloudLocalUploadFile(data: Data([1, 2]), fileName: "a.jpg", fileType: 2)],
scenicId: 9,
api: api,
uploadService: uploader,
transferStore: transferStore
)
XCTAssertTrue(success)
XCTAssertEqual(uploader.uploadedFiles.map(\.fileName), ["a.jpg"])
XCTAssertEqual(api.cloudUploadRequests.first?.fileUrl, "https://cdn.example.com/a.jpg")
XCTAssertEqual(transferStore.records.first?.status, .success)
}
///
func testCloudPaginationAndFolderNavigation() async {
let folder = CloudDriveFile(id: 2, parentFolderId: 0, fileUrl: "", name: "子目录", type: 99)
let api = MockAssetsService()
api.cloudPages = [
ListPayload(total: 2, list: [folder]),
ListPayload(total: 2, list: [CloudDriveFile(id: 3, parentFolderId: 0, fileUrl: "https://cdn/a.jpg", name: "a.jpg", type: 2)])
]
let viewModel = CloudStorageViewModel()
await viewModel.reload(api: api)
await viewModel.loadMore(api: api)
await viewModel.enterFolder(folder, api: api)
await viewModel.popToFolder(at: 0, api: api)
XCTAssertEqual(api.cloudListRequests.first?.parentFolderId, 0)
XCTAssertEqual(api.cloudListRequests[1].page, 2)
XCTAssertEqual(api.cloudListRequests[2].parentFolderId, 2)
XCTAssertEqual(viewModel.currentFolderId, 0)
}
///
func testMediaListFilterAndPagination() async {
let api = MockAssetsService()
api.mediaListResponses = [
MediaLibraryListResponse.fixture(total: 2, ids: [1]),
MediaLibraryListResponse.fixture(total: 2, ids: [2])
]
let viewModel = MediaLibraryViewModel(kind: .material)
viewModel.keyword = " 航拍 "
viewModel.selectedAuditFilter = .approved
await viewModel.reload(api: api)
await viewModel.loadMore(api: api)
XCTAssertEqual(api.mediaListRequests.first?.keyword, "航拍")
XCTAssertEqual(api.mediaListRequests.first?.status, 1)
XCTAssertEqual(api.mediaListRequests.last?.page, 2)
XCTAssertEqual(viewModel.items.map(\.id), [1, 2])
}
///
func testRejectedMediaCannotToggleListing() async {
let api = MockAssetsService()
let viewModel = MediaLibraryViewModel()
let item = MediaLibraryItem.fixture(id: 1, status: 2, listingStatus: 0)
let success = await viewModel.toggleListing(item, api: api)
XCTAssertFalse(success)
XCTAssertEqual(api.mediaOperationRequests.count, 0)
XCTAssertEqual(viewModel.errorMessage, "审核通过后才能上下架")
}
/// OSS URL
func testMediaUploadUploadsFilesBeforeSubmit() async {
let api = MockAssetsService()
let uploader = MockAssetsUploader()
let viewModel = MediaLibraryEditorViewModel()
viewModel.name = "航拍素材"
viewModel.selectedSpotId = 6
viewModel.tagsText = "航拍,夜景"
viewModel.setCover(MediaLocalUploadFile(data: Data([1]), fileName: "cover.jpg", fileType: 2, width: 100, height: 80))
viewModel.addMediaFiles([MediaLocalUploadFile(data: Data([2]), fileName: "a.jpg", fileType: 2, width: 120, height: 90)])
let success = await viewModel.submit(scenicId: 9, api: api, uploadService: uploader)
XCTAssertTrue(success)
XCTAssertEqual(uploader.uploadedFiles.map(\.fileName), ["cover.jpg", "a.jpg"])
XCTAssertEqual(api.mediaUploadRequests.first?.coverUrl, "https://cdn.example.com/cover.jpg")
XCTAssertEqual(api.mediaUploadRequests.first?.mediaList.first?.ossUrl, "https://cdn.example.com/a.jpg")
XCTAssertEqual(api.mediaUploadRequests.first?.materialTag, "航拍,夜景")
}
///
func testMediaUploadFailureDoesNotSubmit() async {
let api = MockAssetsService()
let uploader = MockAssetsUploader()
uploader.shouldFail = true
let viewModel = MediaLibraryEditorViewModel()
viewModel.name = "航拍素材"
viewModel.selectedSpotId = 6
viewModel.setCover(MediaLocalUploadFile(data: Data([1]), fileName: "cover.jpg", fileType: 2, width: 100, height: 80))
viewModel.addMediaFiles([MediaLocalUploadFile(data: Data([2]), fileName: "a.jpg", fileType: 2, width: 120, height: 90)])
let success = await viewModel.submit(scenicId: 9, api: api, uploadService: uploader)
XCTAssertFalse(success)
XCTAssertEqual(api.mediaUploadRequests.count, 0)
}
}
///
@MainActor
private final class MockAssetsService: AssetsServing {
struct CloudListRequest: Equatable {
let parentFolderId: Int
let name: String
let type: Int
let orderBy: Int
let page: Int
let pageSize: Int
}
struct MediaListRequest: Equatable {
let keyword: String
let status: Int?
let page: Int
}
var cloudPages: [ListPayload<CloudDriveFile>] = [ListPayload(total: 0, list: [])]
var mediaListResponses: [MediaLibraryListResponse] = [MediaLibraryListResponse.fixture(total: 0, ids: [])]
var uploadPermission = true
var uploadReason = ""
var cloudListRequests: [CloudListRequest] = []
var cloudUploadRequests: [CloudFileUploadRequest] = []
var mediaListRequests: [MediaListRequest] = []
var mediaOperationRequests: [MediaAlbumOperationRequest] = []
var mediaUploadRequests: [MediaAlbumUploadRequest] = []
var mediaEditRequests: [MediaAlbumEditRequest] = []
///
func cloudFileList(parentFolderId: Int, name: String, type: Int, orderBy: Int, page: Int, pageSize: Int) async throws -> ListPayload<CloudDriveFile> {
cloudListRequests.append(CloudListRequest(parentFolderId: parentFolderId, name: name, type: type, orderBy: orderBy, page: page, pageSize: pageSize))
return cloudPages.isEmpty ? ListPayload(total: 0, list: []) : cloudPages.removeFirst()
}
///
func cloudFolderCreate(_ request: CloudFolderCreateRequest) async throws {}
///
func cloudFileUpload(_ request: CloudFileUploadRequest) async throws {
cloudUploadRequests.append(request)
}
///
func cloudFileDelete(_ request: CloudFileDeleteRequest) async throws {}
///
func cloudFileMove(_ request: CloudFileMoveRequest) async throws {}
///
func cloudFolderEdit(_ request: CloudFolderModifyRequest) async throws {}
///
func cloudFileEdit(_ request: CloudFileModifyRequest) async throws {}
///
func checkCloudUploadPermission() async throws -> CheckCloudUploadResponse {
try JSONDecoder().decode(CheckCloudUploadResponse.self, from: #"{"can_upload":\#(uploadPermission ? 1 : 0),"reason":"\#(uploadReason)"}"#.data(using: .utf8)!)
}
///
func mediaAlbumList(kind: MediaLibraryKind, keyword: String, status: Int?, page: Int, pageSize: Int) async throws -> MediaLibraryListResponse {
mediaListRequests.append(MediaListRequest(keyword: keyword, status: status, page: page))
return mediaListResponses.isEmpty ? MediaLibraryListResponse.fixture(total: 0, ids: []) : mediaListResponses.removeFirst()
}
///
func mediaAlbumDetail(id: Int) async throws -> MediaLibraryDetail {
MediaLibraryDetail.fixture(id: id)
}
///
func mediaAlbumOperation(_ request: MediaAlbumOperationRequest) async throws {
mediaOperationRequests.append(request)
}
///
func mediaAlbumDelete(_ request: MediaAlbumDeleteRequest) async throws {}
///
func mediaAlbumAddTag(_ request: MediaAlbumAddTagRequest) async throws {}
///
func mediaAlbumUpload(_ request: MediaAlbumUploadRequest) async throws {
mediaUploadRequests.append(request)
}
///
func mediaAlbumEdit(_ request: MediaAlbumEditRequest) async throws {
mediaEditRequests.append(request)
}
}
/// OSS CDN URL
@MainActor
private final class MockAssetsUploader: OSSUploadServing {
struct UploadedFile: Equatable {
let fileName: String
let fileType: Int
let scenicId: Int
}
var uploadedFiles: [UploadedFile] = []
var shouldFail = false
///
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress)
}
///
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress)
}
///
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await upload(fileName: fileName, fileType: fileType, scenicId: scenicId, onProgress: onProgress)
}
///
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await upload(fileName: fileName, fileType: fileType, scenicId: scenicId, onProgress: onProgress)
}
///
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await upload(fileName: fileName, fileType: fileType, scenicId: scenicId, onProgress: onProgress)
}
///
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress)
}
///
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress)
}
///
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress)
}
///
private func upload(fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
if shouldFail {
throw APIError.networkFailed("上传失败")
}
uploadedFiles.append(UploadedFile(fileName: fileName, fileType: fileType, scenicId: scenicId))
onProgress(100)
return "https://cdn.example.com/\(fileName)"
}
}
private extension MediaLibraryListResponse {
///
static func fixture(total: Int, ids: [Int]) -> MediaLibraryListResponse {
let list = ids.map { MediaLibraryItem.fixture(id: $0, status: 1, listingStatus: 0) }
let payload = [
"total": total,
"list": list.map { ["id": $0.id, "name": $0.name, "status": $0.status, "listing_status": $0.listingStatus] },
"order": ["total_num": 0, "avg_order_amount": 0, "refund_total": 0, "avg_change": 0]
] as [String: Any]
let data = try! JSONSerialization.data(withJSONObject: payload)
return try! JSONDecoder().decode(MediaLibraryListResponse.self, from: data)
}
}
private extension MediaLibraryItem {
///
static func fixture(id: Int, status: Int, listingStatus: Int) -> MediaLibraryItem {
let payload: [String: Any] = [
"id": id,
"name": "素材\(id)",
"cover_url": "",
"created_at": "",
"status": status,
"download_count": 0,
"likes_count": 0,
"collect_count": 0,
"share_count": 0,
"scenic_spot_name": "",
"project_name": "",
"listing_status": listingStatus
]
let data = try! JSONSerialization.data(withJSONObject: payload)
return try! JSONDecoder().decode(MediaLibraryItem.self, from: data)
}
}
private extension MediaLibraryDetail {
///
static func fixture(id: Int) -> MediaLibraryDetail {
let payload: [String: Any] = [
"id": id,
"name": "素材\(id)",
"cover_url": "",
"description": "",
"status": 1,
"listing_status": 0,
"likes_count": 0,
"download_count": 0,
"collect_count": 0,
"share_count": 0,
"uploader_name": "",
"created_at": "",
"media_list": [],
"scenic_name": "",
"project_name": ""
]
let data = try! JSONSerialization.data(withJSONObject: payload)
return try! JSONDecoder().decode(MediaLibraryDetail.self, from: data)
}
}