// // ScheduleTests.swift // suixinkanTests // // Created by Codex on 2026/6/24. // import XCTest @testable import suixinkan_ios @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]) }