Add submit-task flow aligned with Android and extract profile edit page.
Implement task creation with cloud/local media, order linking, and OSS upload; wire home entry and move profile nickname/avatar editing to ProfileEditViewController. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
108
suixinkanTests/CloudStoragePickForTaskViewModelTests.swift
Normal file
108
suixinkanTests/CloudStoragePickForTaskViewModelTests.swift
Normal file
@ -0,0 +1,108 @@
|
||||
//
|
||||
// CloudStoragePickForTaskViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 任务云盘多选 ViewModel 测试。
|
||||
final class CloudStoragePickForTaskViewModelTests: XCTestCase {
|
||||
|
||||
func testHandleItemTapTogglesMediaSelection() async throws {
|
||||
let listJSON = try makeListJSON(files: [
|
||||
makeFileJSON(id: 5, name: "图片", type: 2),
|
||||
])
|
||||
let api = makeAPI(responses: [listJSON, listJSON])
|
||||
let viewModel = CloudStoragePickForTaskViewModel()
|
||||
await viewModel.refresh(api: api)
|
||||
let file = try XCTUnwrap(viewModel.files.first)
|
||||
|
||||
await viewModel.handleItemTap(file, api: api)
|
||||
XCTAssertTrue(viewModel.isSelected(5))
|
||||
|
||||
await viewModel.handleItemTap(file, api: api)
|
||||
XCTAssertFalse(viewModel.isSelected(5))
|
||||
}
|
||||
|
||||
func testHandleItemTapShowsImportedMessage() async throws {
|
||||
let listJSON = try makeListJSON(files: [
|
||||
makeFileJSON(id: 5, name: "图片", type: 2),
|
||||
])
|
||||
let api = makeAPI(responses: [listJSON])
|
||||
let viewModel = CloudStoragePickForTaskViewModel(importedFileIDs: [5])
|
||||
var message: String?
|
||||
viewModel.onShowMessage = { message = $0 }
|
||||
await viewModel.refresh(api: api)
|
||||
let file = try XCTUnwrap(viewModel.files.first)
|
||||
|
||||
await viewModel.handleItemTap(file, api: api)
|
||||
|
||||
XCTAssertEqual(message, "已选择")
|
||||
XCTAssertFalse(viewModel.isSelected(5))
|
||||
}
|
||||
|
||||
func testNavigateToFolderUpdatesPathStack() async throws {
|
||||
let rootJSON = try makeListJSON(files: [
|
||||
makeFileJSON(id: 8, name: "文件夹", type: 99),
|
||||
])
|
||||
let childJSON = try makeListJSON(files: [])
|
||||
let api = makeAPI(responses: [rootJSON, childJSON])
|
||||
let viewModel = CloudStoragePickForTaskViewModel()
|
||||
await viewModel.refresh(api: api)
|
||||
let folder = try XCTUnwrap(viewModel.files.first)
|
||||
|
||||
await viewModel.handleItemTap(folder, api: api)
|
||||
|
||||
XCTAssertEqual(viewModel.pathStack.map(\.id), [0, 8])
|
||||
}
|
||||
|
||||
func testSelectedFileListReturnsAllChosenFiles() async throws {
|
||||
let listJSON = try makeListJSON(files: [
|
||||
makeFileJSON(id: 1, name: "A", type: 2),
|
||||
makeFileJSON(id: 2, name: "B", type: 2),
|
||||
])
|
||||
let api = makeAPI(responses: [listJSON, listJSON, listJSON])
|
||||
let viewModel = CloudStoragePickForTaskViewModel()
|
||||
await viewModel.refresh(api: api)
|
||||
|
||||
await viewModel.handleItemTap(viewModel.files[0], api: api)
|
||||
await viewModel.handleItemTap(viewModel.files[1], api: api)
|
||||
|
||||
XCTAssertEqual(Set(viewModel.selectedFileList.map(\.id)), Set([1, 2]))
|
||||
}
|
||||
|
||||
private func makeAPI(responses: [Data]) -> TaskAPI {
|
||||
TaskAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: responses)))
|
||||
}
|
||||
|
||||
private func makeListJSON(files: [[String: Any]]) throws -> Data {
|
||||
let listData = try JSONSerialization.data(withJSONObject: files)
|
||||
let listObject = try JSONSerialization.jsonObject(with: listData)
|
||||
let envelope: [String: Any] = [
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": [
|
||||
"total": files.count,
|
||||
"list": listObject,
|
||||
],
|
||||
]
|
||||
return try JSONSerialization.data(withJSONObject: envelope)
|
||||
}
|
||||
|
||||
private func makeFileJSON(id: Int, name: String, type: Int) -> [String: Any] {
|
||||
[
|
||||
"id": id,
|
||||
"parent_folder_id": 0,
|
||||
"file_url": "https://cdn/\(id).jpg",
|
||||
"cover_url": "",
|
||||
"updated_at": "",
|
||||
"name": name,
|
||||
"created_at": "",
|
||||
"child_num": 0,
|
||||
"type": type,
|
||||
"file_size": 100,
|
||||
]
|
||||
}
|
||||
}
|
||||
96
suixinkanTests/ProfileEditViewModelTests.swift
Normal file
96
suixinkanTests/ProfileEditViewModelTests.swift
Normal file
@ -0,0 +1,96 @@
|
||||
//
|
||||
// ProfileEditViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 资料编辑页 ViewModel 测试。
|
||||
final class ProfileEditViewModelTests: XCTestCase {
|
||||
|
||||
func testInitPrefillsNicknameWhenSet() {
|
||||
let viewModel = ProfileEditViewModel(nickname: "测试昵称", avatarURL: "https://example.com/a.jpg")
|
||||
XCTAssertEqual(viewModel.editingNickname, "测试昵称")
|
||||
}
|
||||
|
||||
func testInitClearsPlaceholderNickname() {
|
||||
let viewModel = ProfileEditViewModel(nickname: "未设置昵称", avatarURL: "")
|
||||
XCTAssertEqual(viewModel.editingNickname, "")
|
||||
}
|
||||
|
||||
func testSaveProfileRejectsEmptyNickname() async {
|
||||
let viewModel = ProfileEditViewModel(nickname: "未设置昵称", avatarURL: "")
|
||||
let session = MockURLSession(responses: [])
|
||||
let api = ProfileAPI(client: APIClient(environment: .testing, session: session))
|
||||
|
||||
do {
|
||||
try await viewModel.saveProfile(
|
||||
api: api,
|
||||
uploader: MockOSSUploadService(),
|
||||
scenicId: 1
|
||||
)
|
||||
XCTFail("空昵称应抛出校验错误")
|
||||
} catch {
|
||||
guard let validationError = error as? ProfileValidationError else {
|
||||
return XCTFail("期望 ProfileValidationError,实际为 \(error)")
|
||||
}
|
||||
XCTAssertEqual(validationError, .emptyNickname)
|
||||
}
|
||||
}
|
||||
|
||||
func testSaveProfilePostsNotificationAndUpdatesAppStore() async throws {
|
||||
let viewModel = ProfileEditViewModel(nickname: "旧昵称", avatarURL: "")
|
||||
viewModel.updateEditingNickname("新昵称")
|
||||
|
||||
let expectation = expectation(forNotification: NotificationName.userProfileDidUpdate, object: nil)
|
||||
|
||||
let responseData = try TestJSON.envelope(data: EmptyPayload())
|
||||
let session = MockURLSession(responses: [responseData])
|
||||
let api = ProfileAPI(client: APIClient(environment: .testing, session: session))
|
||||
|
||||
try await viewModel.saveProfile(
|
||||
api: api,
|
||||
uploader: MockOSSUploadService(),
|
||||
scenicId: 1
|
||||
)
|
||||
|
||||
await fulfillment(of: [expectation], timeout: 1)
|
||||
XCTAssertEqual(AppStore.shared.userName, "新昵称")
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试用 OSS 上传替身,直接返回固定 URL。
|
||||
@MainActor
|
||||
private struct MockOSSUploadService: OSSUploadServing {
|
||||
func uploadUserAvatar(
|
||||
data: Data,
|
||||
fileName: String,
|
||||
scenicId: Int,
|
||||
onProgress: @escaping (Int) -> Void
|
||||
) async throws -> String {
|
||||
onProgress(100)
|
||||
return "https://cdn.example.com/avatar.jpg"
|
||||
}
|
||||
|
||||
func uploadRealNameImage(
|
||||
data: Data,
|
||||
fileName: String,
|
||||
scenicId: Int,
|
||||
onProgress: @escaping (Int) -> Void
|
||||
) async throws -> String {
|
||||
onProgress(100)
|
||||
return "https://cdn.example.com/realname.jpg"
|
||||
}
|
||||
|
||||
func uploadBankCardImage(
|
||||
data: Data,
|
||||
fileName: String,
|
||||
scenicId: Int,
|
||||
onProgress: @escaping (Int) -> Void
|
||||
) async throws -> String {
|
||||
onProgress(100)
|
||||
return "https://cdn.example.com/bank.jpg"
|
||||
}
|
||||
}
|
||||
@ -44,9 +44,17 @@ final class ProfileViewModelTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
func testBeginEditingPrefillsNickname() {
|
||||
func testApplyLocalProfileUpdateSyncsNicknameAndAvatar() {
|
||||
AppStore.shared.userName = "全局昵称"
|
||||
AppStore.shared.avatar = "https://cdn.example.com/new.jpg"
|
||||
|
||||
let viewModel = ProfileViewModel()
|
||||
viewModel.beginEditing()
|
||||
XCTAssertTrue(viewModel.isEditingProfile)
|
||||
viewModel.applyLocalProfileUpdate()
|
||||
|
||||
XCTAssertEqual(viewModel.displayNickname, "全局昵称")
|
||||
XCTAssertEqual(viewModel.displayAvatarURL, "https://cdn.example.com/new.jpg")
|
||||
|
||||
AppStore.shared.userName = ""
|
||||
AppStore.shared.avatar = ""
|
||||
}
|
||||
}
|
||||
|
||||
84
suixinkanTests/TaskAddViewModelTests.swift
Normal file
84
suixinkanTests/TaskAddViewModelTests.swift
Normal file
@ -0,0 +1,84 @@
|
||||
//
|
||||
// TaskAddViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 添加任务 ViewModel 测试。
|
||||
@MainActor
|
||||
final class TaskAddViewModelTests: XCTestCase {
|
||||
|
||||
private var appStore: AppStore!
|
||||
private var defaults: UserDefaults!
|
||||
|
||||
override func setUp() {
|
||||
defaults = UserDefaults(suiteName: "TaskAddViewModelTests")!
|
||||
defaults.removePersistentDomain(forName: "TaskAddViewModelTests")
|
||||
appStore = AppStore(defaults: defaults)
|
||||
appStore.currentScenicId = 10
|
||||
}
|
||||
|
||||
func testValidateRequiresTaskName() async {
|
||||
let viewModel = TaskAddViewModel(appStore: appStore)
|
||||
var message: String?
|
||||
viewModel.onShowMessage = { message = $0 }
|
||||
|
||||
await viewModel.validateAndSubmit(api: TaskAPI(client: makeClient()))
|
||||
|
||||
XCTAssertEqual(message, "请填写任务名称!")
|
||||
}
|
||||
|
||||
func testBuildAddTaskRequestSplitsCloudAndUploadFiles() {
|
||||
let viewModel = TaskAddViewModel(appStore: appStore)
|
||||
viewModel.updateTaskName("任务A")
|
||||
viewModel.updateTaskDetails("描述")
|
||||
viewModel.setSelectedOrderNumber("NO001")
|
||||
viewModel.setUrgentHour(2)
|
||||
viewModel.addCloudFiles([CloudFile(id: 1, name: "云文件", type: 2)])
|
||||
viewModel.addUploadPlaceholder(fileName: "a.jpg", fileType: 2, uploadTaskId: "task1")
|
||||
viewModel.markUploadSucceeded(uploadTaskId: "task1", fileURL: "https://cdn/a.jpg", autoRemarkWhenSingle: false)
|
||||
|
||||
let request = viewModel.buildAddTaskRequest()
|
||||
|
||||
XCTAssertEqual(request.name, "任务A")
|
||||
XCTAssertEqual(request.orderNumber, "NO001")
|
||||
XCTAssertEqual(request.urgentHour, 2)
|
||||
XCTAssertEqual(request.cloudFile.count, 1)
|
||||
XCTAssertEqual(request.uploadFile.count, 1)
|
||||
XCTAssertEqual(request.uploadFile[0].fileUrl, "https://cdn/a.jpg")
|
||||
}
|
||||
|
||||
func testAddCloudFilesDedupesAndAutoRemarkSingleFile() {
|
||||
let viewModel = TaskAddViewModel(appStore: appStore)
|
||||
viewModel.addCloudFiles([CloudFile(id: 1, name: "A", type: 2)])
|
||||
XCTAssertEqual(viewModel.selectedFiles.count, 1)
|
||||
XCTAssertEqual(viewModel.consumePendingRemarkFileID(), viewModel.selectedFiles[0].id)
|
||||
|
||||
viewModel.addCloudFiles([
|
||||
CloudFile(id: 1, name: "A", type: 2),
|
||||
CloudFile(id: 2, name: "B", type: 2),
|
||||
CloudFile(id: 3, name: "C", type: 2),
|
||||
])
|
||||
XCTAssertEqual(viewModel.selectedFiles.count, 3)
|
||||
XCTAssertNil(viewModel.consumePendingRemarkFileID())
|
||||
}
|
||||
|
||||
func testResetAllStateClearsForm() {
|
||||
let viewModel = TaskAddViewModel(appStore: appStore)
|
||||
viewModel.updateTaskName("任务")
|
||||
viewModel.setSelectedOrderNumber("NO001")
|
||||
viewModel.addCloudFiles([CloudFile(id: 1, type: 2)])
|
||||
|
||||
viewModel.resetAllState()
|
||||
|
||||
XCTAssertEqual(viewModel.taskName, "")
|
||||
XCTAssertNil(viewModel.selectedOrderNumber)
|
||||
XCTAssertTrue(viewModel.selectedFiles.isEmpty)
|
||||
}
|
||||
|
||||
private func makeClient() -> APIClient {
|
||||
APIClient(environment: .testing, session: MockURLSession(responses: []))
|
||||
}
|
||||
}
|
||||
77
suixinkanTests/TaskModelsTests.swift
Normal file
77
suixinkanTests/TaskModelsTests.swift
Normal file
@ -0,0 +1,77 @@
|
||||
//
|
||||
// TaskModelsTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 任务模型编解码测试。
|
||||
final class TaskModelsTests: XCTestCase {
|
||||
|
||||
func testAddTaskRequestEncodesSnakeCaseFields() throws {
|
||||
let request = AddTaskRequest(
|
||||
scenicId: 10,
|
||||
name: "测试任务",
|
||||
orderNumber: "NO001",
|
||||
remark: "描述",
|
||||
urgentHour: 2,
|
||||
cloudFile: [TaskCloudFileItem(fileId: 1, remark: "云备注")],
|
||||
uploadFile: [TaskUploadFileItem(fileUrl: "https://cdn/a.jpg", fileName: "a.jpg", remark: "")]
|
||||
)
|
||||
let data = try JSONEncoder().encode(request)
|
||||
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
|
||||
XCTAssertEqual(json?["scenic_id"] as? Int, 10)
|
||||
XCTAssertEqual(json?["name"] as? String, "测试任务")
|
||||
XCTAssertEqual(json?["order_number"] as? String, "NO001")
|
||||
XCTAssertEqual(json?["photog_remark"] as? String, "描述")
|
||||
XCTAssertEqual(json?["urgent_hour"] as? Int, 2)
|
||||
}
|
||||
|
||||
func testAvailableOrderDecodesSnakeCaseFields() throws {
|
||||
let json = """
|
||||
{
|
||||
"project_name": "航拍项目",
|
||||
"order_number": "NO001",
|
||||
"order_status": 1,
|
||||
"order_status_label": "已付款",
|
||||
"pay_time": "2024-01-01 10:00:00",
|
||||
"user_id": 100,
|
||||
"user_phone": "13800138000"
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let order = try JSONDecoder().decode(AvailableOrder.self, from: json)
|
||||
XCTAssertEqual(order.projectName, "航拍项目")
|
||||
XCTAssertEqual(order.orderNumber, "NO001")
|
||||
XCTAssertEqual(order.orderStatusLabel, "已付款")
|
||||
}
|
||||
|
||||
func testCloudFileListResponseDecodesList() throws {
|
||||
let json = """
|
||||
{
|
||||
"total": 1,
|
||||
"list": [
|
||||
{
|
||||
"id": 5,
|
||||
"parent_folder_id": 0,
|
||||
"file_url": "https://cdn/a.jpg",
|
||||
"cover_url": "",
|
||||
"updated_at": "",
|
||||
"name": "图片1",
|
||||
"created_at": "",
|
||||
"child_num": 0,
|
||||
"type": 2,
|
||||
"file_size": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let response = try JSONDecoder().decode(CloudFileListResponse.self, from: json)
|
||||
XCTAssertEqual(response.total, 1)
|
||||
XCTAssertEqual(response.list[0].id, 5)
|
||||
XCTAssertTrue(response.list[0].isImage)
|
||||
}
|
||||
}
|
||||
58
suixinkanTests/TaskOrderSelectViewModelTests.swift
Normal file
58
suixinkanTests/TaskOrderSelectViewModelTests.swift
Normal file
@ -0,0 +1,58 @@
|
||||
//
|
||||
// TaskOrderSelectViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 任务订单选择 ViewModel 测试。
|
||||
final class TaskOrderSelectViewModelTests: XCTestCase {
|
||||
|
||||
private var appStore: AppStore!
|
||||
private var defaults: UserDefaults!
|
||||
|
||||
override func setUp() {
|
||||
defaults = UserDefaults(suiteName: "TaskOrderSelectViewModelTests")!
|
||||
defaults.removePersistentDomain(forName: "TaskOrderSelectViewModelTests")
|
||||
appStore = AppStore(defaults: defaults)
|
||||
appStore.currentScenicId = 10
|
||||
}
|
||||
|
||||
func testToggleSelectionSelectsAndDeselects() {
|
||||
let viewModel = TaskOrderSelectViewModel(appStore: appStore)
|
||||
viewModel.toggleSelection(orderNumber: "NO001")
|
||||
XCTAssertEqual(viewModel.selectedOrderNumber, "NO001")
|
||||
viewModel.toggleSelection(orderNumber: "NO001")
|
||||
XCTAssertNil(viewModel.selectedOrderNumber)
|
||||
}
|
||||
|
||||
func testLoadOrdersPopulatesList() async throws {
|
||||
let json = """
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": [
|
||||
{
|
||||
"project_name": "项目A",
|
||||
"order_number": "NO001",
|
||||
"order_status": 1,
|
||||
"order_status_label": "已付款",
|
||||
"pay_time": "2024-01-01",
|
||||
"user_id": 1,
|
||||
"user_phone": "13800138000"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
let session = MockURLSession(responses: [json])
|
||||
let api = TaskAPI(client: APIClient(environment: .testing, session: session))
|
||||
let viewModel = TaskOrderSelectViewModel(appStore: appStore)
|
||||
|
||||
await viewModel.loadOrders(api: api)
|
||||
|
||||
XCTAssertEqual(viewModel.orders.count, 1)
|
||||
XCTAssertEqual(viewModel.orders[0].orderNumber, "NO001")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user