新增项目、排期与邀请模块,并接入首页路由

将项目管理、排期管理与摄影师邀请流程从占位页迁移为完整实现,包含项目图片 OSS 上传与共享业务模型。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-24 15:57:15 +08:00
parent abcac9bfdf
commit 311a70d610
40 changed files with 4496 additions and 108 deletions

View File

@ -0,0 +1,195 @@
//
// ProjectViewModelTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/24.
//
import XCTest
@testable import suixinkan
@MainActor
/// ViewModel
final class ProjectViewModelTests: XCTestCase {
///
func testProjectReloadWithoutScenicClearsListAndSkipsRequest() async {
let api = FakeProjectService()
let viewModel = ProjectManagementViewModel()
await viewModel.reload(api: api, scenicId: nil)
XCTAssertTrue(viewModel.items.isEmpty)
XCTAssertEqual(api.projectListCalls.count, 0)
}
///
func testProjectListReloadAndLoadMore() async {
let api = FakeProjectService()
api.projectPages = [
ListPayload(total: 2, list: [.fixture(id: 1, name: "A")]),
ListPayload(total: 2, list: [.fixture(id: 2, name: "B")])
]
let viewModel = ProjectManagementViewModel()
await viewModel.reload(api: api, scenicId: 9)
await viewModel.loadMore(api: api, scenicId: 9)
XCTAssertEqual(api.projectListCalls.map(\.page), [1, 2])
XCTAssertEqual(viewModel.items.map(\.id).sorted(), [1, 2])
XCTAssertFalse(viewModel.hasMore)
}
///
func testProjectEditorUploadsImagesBeforeCreate() async {
let api = FakeProjectService()
let uploader = FakeProjectUploader()
let viewModel = ProjectEditorViewModel(mode: .create)
viewModel.name = "新项目"
viewModel.descriptionText = "描述"
viewModel.price = "99"
viewModel.otPrice = "199"
viewModel.deposit = "10"
viewModel.selectedSpotIds = [6]
viewModel.coverImage = ProjectLocalImage(data: Data([1]), fileName: "cover.jpg", previewURL: nil)
viewModel.carouselImages = [ProjectLocalImage(data: Data([2]), fileName: "banner.jpg", previewURL: nil)]
let success = await viewModel.submit(scenicId: 9, userId: 100, api: api, uploadService: uploader)
XCTAssertTrue(success)
XCTAssertEqual(uploader.uploadedFileNames, ["cover.jpg", "banner.jpg"])
XCTAssertEqual(api.createdProjects.first?.coverProject, "https://cdn/cover.jpg")
XCTAssertEqual(api.createdProjects.first?.coverCarousel, ["https://cdn/banner.jpg"])
}
///
func testProjectEditorUploadFailureDoesNotCreateProject() async {
let api = FakeProjectService()
let uploader = FakeProjectUploader()
uploader.shouldFail = true
let viewModel = ProjectEditorViewModel(mode: .create)
viewModel.name = "新项目"
viewModel.descriptionText = "描述"
viewModel.price = "99"
viewModel.selectedSpotIds = [6]
viewModel.coverImage = ProjectLocalImage(data: Data([1]), fileName: "cover.jpg", previewURL: nil)
let success = await viewModel.submit(scenicId: 9, userId: 100, api: api, uploadService: uploader)
XCTAssertFalse(success)
XCTAssertTrue(api.createdProjects.isEmpty)
}
/// ID
func testStoreProjectEditorRequiresUserId() async {
let viewModel = StoreProjectEditorViewModel(mode: .create)
let success = await viewModel.submit(userId: 0, api: FakeProjectService(), uploadService: FakeProjectUploader())
XCTAssertFalse(success)
XCTAssertEqual(viewModel.errorMessage, ProjectEditorError.missingUser.localizedDescription)
}
}
private extension PhotographerProjectItem {
///
static func fixture(id: Int, name: String) -> PhotographerProjectItem {
PhotographerProjectItem(id: id, name: name, price: "99.00")
}
}
///
@MainActor
private final class FakeProjectService: ProjectServing {
var projectPages: [ListPayload<PhotographerProjectItem>] = []
var projectListCalls: [(scenicId: Int, name: String?, page: Int, pageSize: Int)] = []
var createdProjects: [ProjectCreateRequest] = []
///
func projectList(scenicId: Int, name: String?, page: Int, pageSize: Int) async throws -> ListPayload<PhotographerProjectItem> {
projectListCalls.append((scenicId, name, page, pageSize))
return projectPages.isEmpty ? ListPayload(total: 0, list: []) : projectPages.removeFirst()
}
///
func projectDetail(id: Int) async throws -> PhotographerProjectDetailResponse {
try Self.detail()
}
///
func createProject(_ request: ProjectCreateRequest) async throws {
createdProjects.append(request)
}
///
func editProject(_ request: ProjectCreateRequest) async throws {}
///
func deleteProject(id: Int) async throws {}
///
func storeManagerScenicList(userId: String) async throws -> ListPayload<StoreManagerScenicItem> {
ListPayload(total: 0, list: [])
}
///
func storeManagerProjectList(userId: String?, page: Int, pageSize: Int) async throws -> ListPayload<PhotographerProjectItem> {
ListPayload(total: 0, list: [])
}
///
func storeManagerProjectDetail(id: Int) async throws -> PhotographerProjectDetailResponse {
try Self.detail()
}
///
func storeManagerCreate(_ request: StoreManagerCreateRequest) async throws {}
///
func storeManagerOfflineCreate(_ request: StoreManagerOfflineCreateRequest) async throws {}
///
func storeManagerUpdate(_ request: StoreManagerUpdateRequest) async throws {}
///
func storeManagerOfflineUpdate(_ request: StoreManagerOfflineUpdateRequest) async throws {}
///
func storeManagerDeleteProject(id: Int) async throws {}
///
func storeAll() async throws -> ListPayload<StoreItem> {
ListPayload(total: 0, list: [])
}
private static func detail() throws -> PhotographerProjectDetailResponse {
let data = Data(#"{"id":1,"name":"","type":11,"status":1,"price":"99","description":"","material_num":1,"photo_num":1,"video_num":1}"#.utf8)
return try JSONDecoder().decode(PhotographerProjectDetailResponse.self, from: data)
}
}
///
@MainActor
private final class FakeProjectUploader: OSSUploadServing {
var uploadedFileNames: [String] = []
var shouldFail = false
///
func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
if shouldFail { throw TestUploadError.failed }
uploadedFileNames.append(fileName)
return "https://cdn/\(fileName)"
}
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 uploadTaskFile(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 { "" }
}
///
private enum TestUploadError: Error {
case failed
}