Files
suixinkan_ios_uikit/suixinkan_iosTests/TaskViewModelTests.swift
2026-06-26 14:33:31 +08:00

517 lines
21 KiB
Swift
Raw Permalink 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.

//
// TaskViewModelTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/23.
//
import Foundation
import XCTest
@testable import suixinkan_ios
@MainActor
/// ViewModel
final class TaskViewModelTests: XCTestCase {
///
func testTaskManagementClearsWithoutScenic() async throws {
let api = TaskMockService()
api.taskListResponses = [try TestFixture.payload(ListPayload<PhotographerTaskItem>.self, named: "task_list_success")]
let viewModel = TaskManagementViewModel()
try await viewModel.reload(api: api, scenicId: 88)
XCTAssertFalse(viewModel.tasks.isEmpty)
try await viewModel.reload(api: api, scenicId: nil)
XCTAssertTrue(viewModel.tasks.isEmpty)
XCTAssertEqual(viewModel.total, 0)
XCTAssertEqual(viewModel.page, 1)
XCTAssertEqual(api.taskListCalls.count, 1)
XCTAssertEqual(viewModel.errorMessage, "请先选择景区")
}
///
func testTaskManagementReloadAndLoadMore() async throws {
let api = TaskMockService()
api.taskListResponses = [
try TestFixture.payload(ListPayload<PhotographerTaskItem>.self, named: "task_list_success"),
try TestFixture.payload(ListPayload<PhotographerTaskItem>.self, named: "task_list_page2_success")
]
let viewModel = TaskManagementViewModel()
try await viewModel.reload(api: api, scenicId: 88)
viewModel.total = 3
try await viewModel.loadMore(api: api, scenicId: 88)
XCTAssertEqual(viewModel.tasks.count, 3)
XCTAssertEqual(viewModel.page, 2)
XCTAssertEqual(api.taskListCalls.map(\.page), [1, 2])
}
///
func testTaskManagementUsesFilters() async throws {
let api = TaskMockService()
api.taskListResponses = [try TestFixture.payload(ListPayload<PhotographerTaskItem>.self, named: "task_list_success")]
let viewModel = TaskManagementViewModel()
viewModel.selectedStatus = .accepted
viewModel.searchText = " 亲子 "
viewModel.startDate = Self.date("2026-06-01")
viewModel.endDate = Self.date("2026-06-30")
try await viewModel.reload(api: api, scenicId: 88)
let call = try XCTUnwrap(api.taskListCalls.first)
XCTAssertEqual(call.taskStatus, TaskStatusFilter.accepted.rawValue)
XCTAssertEqual(call.taskName, "亲子")
XCTAssertEqual(call.startTime, "2026-06-01")
XCTAssertEqual(call.endTime, "2026-06-30")
}
///
func testTaskDetailLoadsDetail() async throws {
let api = TaskMockService()
api.detailResponse = try TestFixture.payload(TaskDetailResponse.self, named: "task_detail_success")
let viewModel = TaskDetailViewModel()
await viewModel.load(api: api, taskId: 101)
XCTAssertEqual(api.detailIds, [101])
XCTAssertEqual(viewModel.detail?.name, "游客精修任务")
XCTAssertNil(viewModel.errorMessage)
}
///
func testTaskCreateValidation() async throws {
let api = TaskMockService()
let upload = TaskMockUploadService()
let viewModel = TaskCreateViewModel()
let noScenic = try await viewModel.submit(api: api, uploadService: upload, scenicId: nil)
XCTAssertFalse(noScenic)
XCTAssertEqual(viewModel.errorMessage, "请先选择景区")
viewModel.taskName = "任务"
let noRemark = try await viewModel.submit(api: api, uploadService: upload, scenicId: 88)
XCTAssertFalse(noRemark)
XCTAssertEqual(viewModel.errorMessage, "请输入任务备注")
}
///
func testTaskCreateUploadsLocalFilesBeforeSubmit() async throws {
let api = TaskMockService()
let upload = TaskMockUploadService()
upload.taskUploadURLs = ["https://cdn.example.com/task/a.jpg"]
let viewModel = TaskCreateViewModel()
viewModel.taskName = "任务"
viewModel.remark = "备注"
viewModel.urgentHourText = "6"
viewModel.mergeCloudFiles([TaskCloudSelectionItem(id: 501, fileName: "cloud.jpg", fileType: 2, remark: "")])
viewModel.addLocalFile(data: Data([1, 2, 3]), fileName: "local.jpg")
let success = try await viewModel.submit(api: api, uploadService: upload, scenicId: 88)
XCTAssertTrue(success)
XCTAssertEqual(upload.taskUploads.count, 1)
let request = try XCTUnwrap(api.addTaskRequests.first)
XCTAssertEqual(request.scenicId, "88")
XCTAssertEqual(request.name, "任务")
XCTAssertEqual(request.cloudFile.first?.fileId, 501)
XCTAssertEqual(request.uploadFile.first?.fileUrl, "https://cdn.example.com/task/a.jpg")
XCTAssertTrue(viewModel.selectedLocalFiles.isEmpty)
}
///
func testTaskCreateUploadFailureStopsSubmit() async {
let api = TaskMockService()
let upload = TaskMockUploadService()
upload.uploadError = APIError.networkFailed("上传失败")
let viewModel = TaskCreateViewModel()
viewModel.taskName = "任务"
viewModel.remark = "备注"
viewModel.addLocalFile(data: Data([1]), fileName: "local.jpg")
do {
_ = try await viewModel.submit(api: api, uploadService: upload, scenicId: 88)
XCTFail("Expected upload failure")
} catch APIError.networkFailed(let message) {
XCTAssertEqual(message, "上传失败")
} catch {
XCTFail("Unexpected error: \(error)")
}
XCTAssertTrue(api.addTaskRequests.isEmpty)
XCTAssertEqual(viewModel.selectedLocalFiles.first?.errorMessage, "网络请求失败:上传失败")
}
///
func testCloudSelectionNavigationAndSelection() async throws {
let api = TaskMockService()
let firstPage = try TestFixture.payload(ListPayload<CloudDriveFile>.self, named: "cloud_file_list_success")
let secondPage = ListPayload(total: 3, list: [firstPage.list[1]])
api.cloudFileResponses = [firstPage, secondPage, firstPage]
let viewModel = TaskCloudFileSelectionViewModel()
try await viewModel.reload(api: api)
viewModel.total = 3
try await viewModel.loadMore(api: api)
XCTAssertEqual(viewModel.files.count, 3)
let folder = firstPage.list[0]
try await viewModel.enterFolder(folder, api: api)
XCTAssertEqual(viewModel.currentFolderId, folder.id)
viewModel.toggleSelection(folder)
XCTAssertTrue(viewModel.selectedFiles.isEmpty)
let file = firstPage.list[1]
viewModel.toggleSelection(file)
XCTAssertEqual(viewModel.makeSelectionItems().first?.id, file.id)
XCTAssertEqual(api.cloudFileCalls.map(\.page), [1, 2, 1])
}
///
func testTaskCreateLoadAvailableOrders() async throws {
let api = TaskMockService()
api.availableOrders = try TestFixture.payload([AvailableOrderResponse].self, named: "available_order_success")
let viewModel = TaskCreateViewModel()
await viewModel.loadAvailableOrders(api: api, scenicId: nil)
XCTAssertTrue(viewModel.availableOrders.isEmpty)
XCTAssertTrue(api.availableOrderScenicIds.isEmpty)
await viewModel.loadAvailableOrders(api: api, scenicId: 88)
XCTAssertEqual(api.availableOrderScenicIds, [88])
XCTAssertEqual(viewModel.availableOrders.count, 1)
XCTAssertFalse(viewModel.isLoadingOrders)
}
///
func testTaskCreateLoadAvailableOrdersFailure() async {
let api = TaskMockService()
api.availableOrderError = APIError.networkFailed("订单加载失败")
let viewModel = TaskCreateViewModel()
await viewModel.loadAvailableOrders(api: api, scenicId: 88)
XCTAssertTrue(viewModel.availableOrders.isEmpty)
XCTAssertEqual(viewModel.errorMessage, "网络请求失败:订单加载失败")
}
///
func testTaskCreateCanSubmitBlocksWhileUploading() {
let viewModel = TaskCreateViewModel()
viewModel.taskName = "任务"
viewModel.remark = "备注"
viewModel.selectedLocalFiles = [
TaskLocalUploadItem(
id: UUID(),
data: Data([1]),
fileName: "local.jpg",
fileType: 2,
remark: "",
uploadedURL: nil,
progress: 50,
errorMessage: nil
)
]
XCTAssertFalse(viewModel.canSubmit)
XCTAssertTrue(viewModel.selectedLocalFiles.first?.isUploading == true)
XCTAssertEqual(viewModel.selectedLocalFiles.first?.statusText, "上传中 50%")
}
///
func testTaskCreateMergeAndRemoveAttachments() {
let viewModel = TaskCreateViewModel()
let file = TaskCloudSelectionItem(id: 501, fileName: "cloud.jpg", fileType: 2, remark: "备注")
viewModel.mergeCloudFiles([file, file])
viewModel.addLocalFile(data: Data([1]), fileName: "photo.jpg")
guard let localID = viewModel.selectedLocalFiles.first?.id else {
return XCTFail("应存在本地附件")
}
XCTAssertEqual(viewModel.selectedCloudFiles.count, 1)
viewModel.removeCloudFile(id: 501)
viewModel.removeLocalFile(id: localID)
XCTAssertTrue(viewModel.selectedCloudFiles.isEmpty)
XCTAssertTrue(viewModel.selectedLocalFiles.isEmpty)
}
///
func testTaskCreateSubmitIncludesSelectedOrderAndUrgentHour() async throws {
let api = TaskMockService()
let upload = TaskMockUploadService()
let viewModel = TaskCreateViewModel()
viewModel.taskName = " 任务 "
viewModel.remark = "备注"
viewModel.urgentHourText = " 12 "
viewModel.selectOrder(taskAvailableOrder(orderNumber: "ORD-1001"))
let success = try await viewModel.submit(api: api, uploadService: upload, scenicId: 88)
XCTAssertTrue(success)
let request = try XCTUnwrap(api.addTaskRequests.first)
XCTAssertEqual(request.name, "任务")
XCTAssertEqual(request.orderNumber, "ORD-1001")
XCTAssertEqual(request.urgentHour, 12)
}
///
func testTaskCreateValidationCoversTaskNameAndUrgentHour() async throws {
let api = TaskMockService()
let upload = TaskMockUploadService()
let viewModel = TaskCreateViewModel()
viewModel.remark = "备注"
let missingName = try await viewModel.submit(api: api, uploadService: upload, scenicId: 88)
XCTAssertFalse(missingName)
XCTAssertEqual(viewModel.errorMessage, "请输入任务名称")
viewModel.taskName = "任务"
viewModel.urgentHourText = "abc"
let invalidHour = try await viewModel.submit(api: api, uploadService: upload, scenicId: 88)
XCTAssertFalse(invalidHour)
XCTAssertEqual(viewModel.errorMessage, "请输入正确的紧急小时")
}
///
func testTaskCreateDetectsVideoFileType() {
let viewModel = TaskCreateViewModel()
viewModel.addLocalFile(data: Data([1]), fileName: "clip.mp4")
XCTAssertEqual(viewModel.selectedLocalFiles.first?.fileType, 1)
}
///
func testCloudSelectionPopToFolderSearchAndDeselect() async throws {
let api = TaskMockService()
let firstPage = try TestFixture.payload(ListPayload<CloudDriveFile>.self, named: "cloud_file_list_success")
api.cloudFileResponses = [firstPage, firstPage]
let viewModel = TaskCloudFileSelectionViewModel()
let folder = firstPage.list[0]
let file = firstPage.list[1]
try await viewModel.enterFolder(folder, api: api)
viewModel.searchText = " photo "
viewModel.selectedFilter = .image
try await viewModel.reload(api: api)
let call = try XCTUnwrap(api.cloudFileCalls.last)
XCTAssertEqual(call.parentFolderId, folder.id)
XCTAssertEqual(call.name, "photo")
XCTAssertEqual(call.type, TaskCloudFileFilter.image.rawValue)
viewModel.toggleSelection(file)
XCTAssertTrue(viewModel.isSelected(file))
viewModel.toggleSelection(file)
XCTAssertFalse(viewModel.isSelected(file))
try await viewModel.popToFolder(at: 0, api: api)
XCTAssertEqual(viewModel.currentFolderId, 0)
}
///
func testCloudSelectionReloadFailureClearsState() async {
let api = TaskMockService()
api.cloudFileError = APIError.networkFailed("云盘失败")
let viewModel = TaskCloudFileSelectionViewModel()
viewModel.files = [CloudDriveFile(id: 1, name: "占位")]
viewModel.total = 5
viewModel.page = 2
do {
try await viewModel.reload(api: api)
XCTFail("加载失败时应抛出错误")
} catch APIError.networkFailed(let message) {
XCTAssertEqual(message, "云盘失败")
} catch {
XCTFail("意外错误: \(error)")
}
XCTAssertTrue(viewModel.files.isEmpty)
XCTAssertEqual(viewModel.total, 0)
XCTAssertEqual(viewModel.page, 1)
XCTAssertEqual(viewModel.errorMessage, "网络请求失败:云盘失败")
}
///
func testCloudSelectionLoadMoreStopsWhenNoMore() async throws {
let api = TaskMockService()
let page = try TestFixture.payload(ListPayload<CloudDriveFile>.self, named: "cloud_file_list_success")
api.cloudFileResponses = [page]
let viewModel = TaskCloudFileSelectionViewModel()
try await viewModel.reload(api: api)
viewModel.total = viewModel.files.count
try await viewModel.loadMore(api: api)
XCTAssertEqual(api.cloudFileCalls.count, 1)
XCTAssertFalse(viewModel.isLoadingMore)
}
///
private static func date(_ text: String) -> Date {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd"
return formatter.date(from: text)!
}
}
@MainActor
///
private final class TaskMockService: TaskServing {
struct TaskListCall {
let scenicId: Int
let page: Int
let pageSize: Int
let taskStatus: Int
let taskName: String?
let startTime: String?
let endTime: String?
}
struct CloudFileCall {
let parentFolderId: Int
let name: String
let type: Int
let orderBy: Int
let page: Int
let pageSize: Int
}
var taskListResponses: [ListPayload<PhotographerTaskItem>] = []
var detailResponse: TaskDetailResponse?
var availableOrders: [AvailableOrderResponse] = []
var availableOrderError: Error?
var cloudFileResponses: [ListPayload<CloudDriveFile>] = []
var cloudFileError: Error?
private(set) var taskListCalls: [TaskListCall] = []
private(set) var detailIds: [Int] = []
private(set) var availableOrderScenicIds: [Int] = []
private(set) var addTaskRequests: [AddTaskRequest] = []
private(set) var cloudFileCalls: [CloudFileCall] = []
///
func taskList(
scenicId: Int,
page: Int,
pageSize: Int,
taskStatus: Int,
taskName: String?,
startTime: String?,
endTime: String?
) async throws -> ListPayload<PhotographerTaskItem> {
taskListCalls.append(
TaskListCall(
scenicId: scenicId,
page: page,
pageSize: pageSize,
taskStatus: taskStatus,
taskName: taskName,
startTime: startTime,
endTime: endTime
)
)
return taskListResponses.isEmpty ? ListPayload(total: 0, list: []) : taskListResponses.removeFirst()
}
///
func taskDetail(id: Int) async throws -> TaskDetailResponse {
detailIds.append(id)
return detailResponse!
}
///
func availableOrderList(scenicId: Int) async throws -> [AvailableOrderResponse] {
availableOrderScenicIds.append(scenicId)
if let availableOrderError { throw availableOrderError }
return availableOrders
}
///
func addTask(_ request: AddTaskRequest) async throws {
addTaskRequests.append(request)
}
///
func cloudFileList(
parentFolderId: Int,
name: String,
type: Int,
orderBy: Int,
page: Int,
pageSize: Int
) async throws -> ListPayload<CloudDriveFile> {
cloudFileCalls.append(
CloudFileCall(
parentFolderId: parentFolderId,
name: name,
type: type,
orderBy: orderBy,
page: page,
pageSize: pageSize
)
)
if let cloudFileError { throw cloudFileError }
return cloudFileResponses.isEmpty ? ListPayload(total: 0, list: []) : cloudFileResponses.removeFirst()
}
}
///
private func taskAvailableOrder(orderNumber: String) -> AvailableOrderResponse {
let data = Data(#"{"project_name":"","order_number":"\#(orderNumber)","order_status":"1","order_status_label":"","pay_time":"2026-06-24","user_phone":"13800000000"}"#.utf8)
return try! JSONDecoder().decode(AvailableOrderResponse.self, from: data)
}
@MainActor
/// OSS
private final class TaskMockUploadService: OSSUploadServing {
struct TaskUpload {
let data: Data
let fileName: String
let fileType: Int
let scenicId: Int
}
var taskUploadURLs: [String] = []
var uploadError: Error?
private(set) var taskUploads: [TaskUpload] = []
///
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
taskUploads.append(TaskUpload(data: data, fileName: fileName, fileType: fileType, scenicId: scenicId))
onProgress(50)
if let uploadError {
throw uploadError
}
onProgress(100)
return taskUploadURLs.isEmpty ? "https://cdn.example.com/task/default.jpg" : taskUploadURLs.removeFirst()
}
///
func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
///
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
///
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
///
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
///
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
///
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
///
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
///
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
}