Files
suixinkan_ios_new/suixinkanTests/TaskViewModelTests.swift
汉秋 ffb16eca29 新增任务模块,并接入首页任务管理路由
实现任务列表、详情与创建页面,对接 API,支持云端/本地附件,并添加单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 15:26:13 +08:00

324 lines
12 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.

//
// TaskViewModelTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/23.
//
import Foundation
import XCTest
@testable import suixinkan
@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])
}
///
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 cloudFileResponses: [ListPayload<CloudDriveFile>] = []
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)
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
)
)
return cloudFileResponses.isEmpty ? ListPayload(total: 0, list: []) : cloudFileResponses.removeFirst()
}
}
@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 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 { "" }
}