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:
2026-07-07 10:12:20 +08:00
parent 715393e78c
commit 3c4ca7eefb
23 changed files with 3186 additions and 170 deletions

View 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,
]
}
}

View 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"
}
}

View File

@ -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 = ""
}
}

View 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: []))
}
}

View 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)
}
}

View 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")
}
}