新增项目、排期与邀请模块,并接入首页路由
将项目管理、排期管理与摄影师邀请流程从占位页迁移为完整实现,包含项目图片 OSS 上传与共享业务模型。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
184
suixinkanTests/Schedule/ScheduleTests.swift
Normal file
184
suixinkanTests/Schedule/ScheduleTests.swift
Normal file
@ -0,0 +1,184 @@
|
||||
//
|
||||
// ScheduleTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 排班 API 和 ViewModel 测试。
|
||||
final class ScheduleTests: XCTestCase {
|
||||
/// 测试排班 API path 和 query/body。
|
||||
func testScheduleAPIUsesExpectedRequests() async throws {
|
||||
let session = ScheduleRecordingURLSession(responses: [
|
||||
Data(#"{"code":100000,"message":"ok","data":["2026-06-24"]}"#.utf8),
|
||||
Data(#"{"code":100000,"message":"ok","data":[{"id":"1","name":"拍摄","start_time":"09:00","end_time":"10:00","schedule_date":"2026-06-24"}]}"#.utf8),
|
||||
Data(#"{"code":100000,"message":"ok","data":{}}"#.utf8),
|
||||
Data(#"{"code":100000,"message":"ok","data":{}}"#.utf8)
|
||||
])
|
||||
let api = ScheduleAPI(client: APIClient(session: session))
|
||||
|
||||
let days = try await api.monthScheduleDays(scenicId: 9, yearMonth: "2026-06")
|
||||
let list = try await api.dayScheduleList(scenicId: 9, date: "2026-06-24")
|
||||
try await api.addSchedule(AddScheduleRequest(scenicId: "9", name: "拍摄", remark: "备注", startTime: "09:00", endTime: "10:00", scheduleDate: "2026-06-24", orderNumber: "ORD001"))
|
||||
try await api.deleteSchedule(id: 1)
|
||||
|
||||
XCTAssertEqual(days, ["2026-06-24"])
|
||||
XCTAssertEqual(list.first?.id, 1)
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||||
"/api/yf-handset-app/photog/schedule/list-date",
|
||||
"/api/yf-handset-app/photog/schedule/list",
|
||||
"/api/yf-handset-app/photog/schedule/add",
|
||||
"/api/yf-handset-app/photog/schedule/delete"
|
||||
])
|
||||
XCTAssertEqual(scheduleQueryItems(from: session.requests[0])["date"], "2026-06")
|
||||
XCTAssertEqual(scheduleQueryItems(from: session.requests[1])["date"], "2026-06-24")
|
||||
XCTAssertEqual((try scheduleBodyObject(from: session.requests[2]))["order_number"] as? String, "ORD001")
|
||||
XCTAssertEqual((try scheduleBodyObject(from: session.requests[3]))["id"] as? Int, 1)
|
||||
}
|
||||
|
||||
/// 测试无景区时排班管理清空状态且不请求接口。
|
||||
func testScheduleManagementWithoutScenicClearsState() async {
|
||||
let api = FakeScheduleService()
|
||||
let viewModel = ScheduleManagementViewModel()
|
||||
|
||||
await viewModel.reload(api: api, scenicId: nil)
|
||||
|
||||
XCTAssertTrue(viewModel.items.isEmpty)
|
||||
XCTAssertTrue(viewModel.markedDays.isEmpty)
|
||||
XCTAssertEqual(api.monthCalls.count, 0)
|
||||
}
|
||||
|
||||
/// 测试排班管理加载月份和当天列表。
|
||||
func testScheduleManagementReloadLoadsMonthAndDay() async {
|
||||
let api = FakeScheduleService()
|
||||
api.monthDays = ["2026-06-24"]
|
||||
api.dayItems = [.fixture(id: 1)]
|
||||
let viewModel = ScheduleManagementViewModel()
|
||||
|
||||
await viewModel.reload(api: api, scenicId: 9)
|
||||
|
||||
XCTAssertEqual(api.monthCalls.first?.scenicId, 9)
|
||||
XCTAssertEqual(api.dayCalls.first?.scenicId, 9)
|
||||
XCTAssertEqual(viewModel.markedDays, ["2026-06-24"])
|
||||
XCTAssertEqual(viewModel.items.first?.id, 1)
|
||||
}
|
||||
|
||||
/// 测试新增排班优先使用选择的订单号。
|
||||
func testScheduleAddUsesSelectedOrderBeforeManualOrder() async {
|
||||
let api = FakeScheduleService()
|
||||
let viewModel = ScheduleAddViewModel()
|
||||
viewModel.draft.name = "拍摄"
|
||||
viewModel.draft.remark = ""
|
||||
viewModel.draft.manualOrderNumber = "MANUAL"
|
||||
viewModel.draft.selectedOrder = AvailableOrderResponse.fixture(orderNumber: "PICKED")
|
||||
let start = fixedDate(hour: 9)
|
||||
let end = fixedDate(hour: 10)
|
||||
|
||||
let success = await viewModel.submit(api: api, scenicId: 9, startDate: start, endDate: end)
|
||||
|
||||
XCTAssertTrue(success)
|
||||
XCTAssertEqual(api.addedRequests.first?.orderNumber, "PICKED")
|
||||
XCTAssertEqual(api.addedRequests.first?.remark, "拍摄")
|
||||
}
|
||||
|
||||
/// 测试结束时间早于开始时间时禁止提交。
|
||||
func testScheduleAddRejectsInvalidTime() async {
|
||||
let api = FakeScheduleService()
|
||||
let viewModel = ScheduleAddViewModel()
|
||||
viewModel.draft.name = "拍摄"
|
||||
|
||||
let success = await viewModel.submit(api: api, scenicId: 9, startDate: fixedDate(hour: 10), endDate: fixedDate(hour: 9))
|
||||
|
||||
XCTAssertFalse(success)
|
||||
XCTAssertTrue(api.addedRequests.isEmpty)
|
||||
XCTAssertEqual(viewModel.errorMessage, ScheduleValidationError.invalidTime.localizedDescription)
|
||||
}
|
||||
|
||||
private func fixedDate(hour: Int) -> Date {
|
||||
Calendar.current.date(from: DateComponents(year: 2026, month: 6, day: 24, hour: hour, minute: 0)) ?? Date()
|
||||
}
|
||||
}
|
||||
|
||||
private extension ScheduleItem {
|
||||
/// 创建排班测试项。
|
||||
static func fixture(id: Int) -> ScheduleItem {
|
||||
let data = Data(#"{"id":\#(id),"name":"拍摄","start_time":"09:00","end_time":"10:00","schedule_date":"2026-06-24"}"#.utf8)
|
||||
return try! JSONDecoder().decode(ScheduleItem.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
private extension AvailableOrderResponse {
|
||||
/// 创建可关联订单测试项。
|
||||
static func fixture(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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 排班 API 测试 URLSession。
|
||||
private final class ScheduleRecordingURLSession: URLSessionProtocol {
|
||||
private var responses: [Data]
|
||||
private(set) var requests: [URLRequest] = []
|
||||
|
||||
/// 初始化测试 Session。
|
||||
init(responses: [Data]) {
|
||||
self.responses = responses
|
||||
}
|
||||
|
||||
/// 记录请求并返回响应。
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
requests.append(request)
|
||||
let data = responses.removeFirst()
|
||||
return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
|
||||
}
|
||||
}
|
||||
|
||||
/// 排班服务测试替身。
|
||||
@MainActor
|
||||
private final class FakeScheduleService: ScheduleServing {
|
||||
var monthDays: [String] = []
|
||||
var dayItems: [ScheduleItem] = []
|
||||
var monthCalls: [(scenicId: Int, yearMonth: String)] = []
|
||||
var dayCalls: [(scenicId: Int, date: String)] = []
|
||||
var addedRequests: [AddScheduleRequest] = []
|
||||
|
||||
func monthScheduleDays(scenicId: Int, yearMonth: String) async throws -> [String] {
|
||||
monthCalls.append((scenicId, yearMonth))
|
||||
return monthDays
|
||||
}
|
||||
|
||||
func dayScheduleList(scenicId: Int, date: String) async throws -> [ScheduleItem] {
|
||||
dayCalls.append((scenicId, date))
|
||||
return dayItems
|
||||
}
|
||||
|
||||
func addSchedule(_ request: AddScheduleRequest) async throws {
|
||||
addedRequests.append(request)
|
||||
}
|
||||
|
||||
func deleteSchedule(id: Int) async throws {}
|
||||
|
||||
func availableOrderList(scenicId: Int) async throws -> [AvailableOrderResponse] {
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
/// 从请求中提取 query 字典。
|
||||
private func scheduleQueryItems(from request: URLRequest) -> [String: String] {
|
||||
guard let url = request.url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
return [:]
|
||||
}
|
||||
return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in
|
||||
item.value.map { (item.name, $0) }
|
||||
})
|
||||
}
|
||||
|
||||
/// 将请求体解析为字典。
|
||||
private func scheduleBodyObject(from request: URLRequest) throws -> [String: Any] {
|
||||
let body = try XCTUnwrap(request.httpBody)
|
||||
return try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
}
|
||||
Reference in New Issue
Block a user