新增运营区域与飞手认证模块,并完善直播推流就绪流程

将运营区域与飞手认证从首页占位页迁移为完整模块,扩展 Live 播放与推流就绪流程,并新增飞手证书 OSS 上传。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-25 18:15:59 +08:00
parent fcb692b56a
commit a04168cf30
33 changed files with 3455 additions and 37 deletions

View File

@ -0,0 +1,38 @@
{
"code": 100000,
"msg": "success",
"data": {
"id": "801",
"flyer_nickname": "航拍摄影师",
"account_id": "42",
"realname_status": "2",
"status": "1",
"status_text": "审核中",
"created_at": "2026-05-23 04:11:00",
"updated_at": "2026-05-23 04:12:00",
"reviewer": "审核员",
"review_time": "2026-05-23 04:15:00",
"reject_reason": "",
"certificate_type": "2",
"certificate_no": "UAS-2026-001",
"certificate_start_date": "2026-01-01",
"certificate_end_date": "2028-01-01",
"certificate_image": "https://cdn.example.com/flyer/cert.jpg",
"drone_model": "DJI Mini",
"drone_sn": "SN20260523001",
"contact_phone": "13800000000",
"realname_status_text": "已实名",
"flyers_certification_logs": [
{
"id": "9001",
"flyer_id": "801",
"operator": "审核员",
"action": "2",
"action_text": "通过",
"reject_reason": "",
"remark": "资料齐全",
"created_at": "2026-05-23 04:15:00"
}
]
}
}

View File

@ -0,0 +1,8 @@
{
"code": 100000,
"msg": "success",
"data": {
"total": 0,
"list": []
}
}

View File

@ -0,0 +1,46 @@
{
"code": 100000,
"msg": "success",
"data": {
"total": 4,
"list": [
{
"id": "101",
"name": "东门门店",
"business_map_area": [[[118.781, 32.041], [118.782, 32.041], [118.782, 32.042], [118.781, 32.041]]],
"status_text": "启用",
"type_text": "门店",
"audit_status_text": "通过"
},
{
"id": 102,
"name": "西门门店",
"business_map_area": "{\"type\":\"Polygon\",\"coordinates\":[[[118.791,32.051],[118.792,32.051],[118.792,32.052],[118.791,32.051]]]}",
"status_text": "启用",
"type_text": "门店",
"audit_status_text": "通过"
},
{
"id": 103,
"name": "北区",
"business_map_area": {
"type": "MultiPolygon",
"coordinates": [
[[[118.801, 32.061], [118.802, 32.061], [118.802, 32.062], [118.801, 32.061]]]
]
},
"status_text": "启用",
"type_text": "区域",
"audit_status_text": "通过"
},
{
"id": 104,
"name": "无效区域",
"business_map_area": [[118.811, 32.071], [118.812, 32.071]],
"status_text": "停用",
"type_text": "区域",
"audit_status_text": "待审核"
}
]
}
}

View File

@ -64,14 +64,8 @@ final class HomeMenuRouterTests: XCTestCase {
XCTAssertEqual(HomeMenuRouter.resolve(uri: "queue_management", title: ""), .destination(.queueManagement))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_stream_management", title: ""), .destination(.liveManagement))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_album", title: ""), .destination(.liveAlbum))
}
///
func testKnownUnmigratedRoutesResolveToPlaceholders() {
XCTAssertEqual(
HomeMenuRouter.resolve(uri: "operating-area", title: ""),
.destination(.modulePlaceholder(uri: "operating-area", title: "运营区域"))
)
XCTAssertEqual(HomeMenuRouter.resolve(uri: "operating-area", title: ""), .destination(.operatingArea))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "pilot_cert", title: ""), .destination(.pilotCertification))
}
/// Tab HomeMenuRouter

View File

@ -192,6 +192,107 @@ final class LiveViewModelTests: XCTestCase {
XCTAssertEqual(api.albumDeleteFileRequests.first?.fileIds, [11])
XCTAssertEqual(viewModel.files.map(\.id), [12])
}
func testLiveEntityDecodesPlaybackURLsAndResolverExcludesRTMP() throws {
let json = """
{
"id": "1",
"title": "",
"cover_img": "https://cdn.example.com/c.jpg",
"push_url": "rtmp://push.example.com/live/1",
"play_url": "rtmp://pull.example.com/live/1",
"pull_url": "https://cdn.example.com/live/1.flv",
"hls_url": "https://cdn.example.com/live/1.m3u8",
"live_url": "https://cdn.example.com/live/1.mp4"
}
""".data(using: .utf8)!
let live = try JSONDecoder().decode(LiveEntity.self, from: json)
XCTAssertEqual(live.playUrl, "rtmp://pull.example.com/live/1")
XCTAssertEqual(live.pullUrl, "https://cdn.example.com/live/1.flv")
XCTAssertEqual(live.hlsUrl, "https://cdn.example.com/live/1.m3u8")
XCTAssertNil(LivePlaybackURLResolver.playableURL(from: live.pushUrl))
XCTAssertNil(LivePlaybackURLResolver.playableURL(from: live.playUrl))
XCTAssertNil(LivePlaybackURLResolver.playableURL(from: live.pullUrl))
XCTAssertEqual(LivePlaybackURLResolver.playableURL(from: live)?.absoluteString, "https://cdn.example.com/live/1.m3u8")
}
func testLivePlaybackViewModelStateTransitions() {
let viewModel = LivePlaybackViewModel(urlString: "https://cdn.example.com/live/1.m3u8")
XCTAssertEqual(viewModel.playableURL?.absoluteString, "https://cdn.example.com/live/1.m3u8")
XCTAssertFalse(viewModel.isPlaying)
viewModel.play()
XCTAssertTrue(viewModel.isPlaying)
XCTAssertNotNil(viewModel.player)
viewModel.pause()
XCTAssertFalse(viewModel.isPlaying)
viewModel.release()
XCTAssertNil(viewModel.player)
XCTAssertEqual(viewModel.playableURL?.absoluteString, "https://cdn.example.com/live/1.m3u8")
viewModel.load(urlString: "rtmp://push.example.com/live/1")
XCTAssertNil(viewModel.playableURL)
XCTAssertEqual(viewModel.errorMessage, "暂无可播放地址")
}
func testLivePushReadinessPermissionsNetworkAndUnsupportedAdapter() async {
let permissions = LivePermissionMock(camera: .granted, microphone: .granted)
let network = LiveNetworkMock(state: .wifi)
let adapter = LivePushAdapterMock(available: false)
let viewModel = LivePushReadinessViewModel(permissionProvider: permissions, networkMonitor: network, adapter: adapter)
viewModel.configure(pushURL: "rtmp://push.example.com/live/1")
viewModel.startMonitoring()
await viewModel.refreshPermissions()
XCTAssertEqual(viewModel.cameraPermission, .granted)
XCTAssertEqual(viewModel.microphonePermission, .granted)
XCTAssertEqual(viewModel.networkState, .wifi)
XCTAssertEqual(viewModel.sdkStatusText, "未接入真推流 SDK")
await XCTAssertThrowsErrorAsync(try await viewModel.prepare())
XCTAssertEqual(viewModel.errorMessage, LivePushReadinessError.sdkUnavailable.localizedDescription)
}
func testLivePushReadinessDiagnosticsDoNotPrepareUnsupportedAdapter() async {
let permissions = LivePermissionMock(camera: .granted, microphone: .granted)
let network = LiveNetworkMock(state: .wifi)
let adapter = LivePushAdapterMock(available: false)
let viewModel = LivePushReadinessViewModel(permissionProvider: permissions, networkMonitor: network, adapter: adapter)
viewModel.configure(pushURL: "rtmp://push.example.com/live/1")
viewModel.startMonitoring()
await viewModel.refreshPermissions()
XCTAssertThrowsError(try viewModel.runDiagnostics())
XCTAssertFalse(adapter.prepareCalled)
XCTAssertFalse(adapter.startCalled)
XCTAssertEqual(viewModel.errorMessage, LivePushReadinessError.sdkUnavailable.localizedDescription)
}
func testLivePushReadinessBlocksDeniedPermissionAndUnavailableNetwork() async {
let permissions = LivePermissionMock(camera: .denied, microphone: .granted)
let network = LiveNetworkMock(state: .unavailable)
let adapter = LivePushAdapterMock(available: true)
let viewModel = LivePushReadinessViewModel(permissionProvider: permissions, networkMonitor: network, adapter: adapter)
viewModel.configure(pushURL: "rtmp://push.example.com/live/1")
viewModel.startMonitoring()
await viewModel.refreshPermissions()
await XCTAssertThrowsErrorAsync(try await viewModel.prepare())
XCTAssertFalse(adapter.prepareCalled)
let missingURLViewModel = LivePushReadinessViewModel(permissionProvider: permissions, networkMonitor: network, adapter: adapter)
missingURLViewModel.configure(pushURL: "")
await XCTAssertThrowsErrorAsync(try await missingURLViewModel.prepare())
XCTAssertEqual(missingURLViewModel.errorMessage, LivePushReadinessError.missingPushURL.localizedDescription)
}
}
@MainActor
@ -294,6 +395,69 @@ private final class LiveUploadMock: OSSUploadServing {
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
}
private struct LivePermissionMock: LivePermissionProviding {
let camera: LivePushPermissionState
let microphone: LivePushPermissionState
func cameraPermission() async -> LivePushPermissionState {
camera
}
func microphonePermission() async -> LivePushPermissionState {
microphone
}
}
private final class LiveNetworkMock: LiveNetworkMonitoring {
private(set) var currentState: LivePushNetworkState
private var onChange: (@Sendable (LivePushNetworkState) -> Void)?
init(state: LivePushNetworkState) {
currentState = state
}
func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void) {
self.onChange = onChange
onChange(currentState)
}
func stop() {
onChange = nil
}
}
private final class LivePushAdapterMock: LivePushAdapter {
let name = "Mock SDK"
let isAvailable: Bool
var prepareCalled = false
var startCalled = false
var stopCalled = false
init(available: Bool) {
isAvailable = available
}
func prepare(pushURL: URL) async throws {
prepareCalled = true
if !isAvailable {
throw LivePushReadinessError.sdkUnavailable
}
}
func start() async throws {
startCalled = true
if !isAvailable {
throw LivePushReadinessError.sdkUnavailable
}
}
func stop() async throws {
stopCalled = true
}
func dispose() async {}
}
private func live(id: Int, title: String = "直播", status: Int = 1, manualPushMode: Int = 1) -> LiveEntity {
LiveEntity(
id: id,

View File

@ -0,0 +1,171 @@
//
// OperatingAreaTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/25.
//
import XCTest
@testable import suixinkan
@MainActor
///
final class OperatingAreaTests: XCTestCase {
func testAPIsUseExpectedQueriesAndDecodeFixture() async throws {
let session = OperatingRecordingURLSession(data: try TestFixture.data(named: "operating_area_success"))
let api = OperatingAreaAPI(client: APIClient(session: session))
let storeResponse = try await api.storeBusinessArea(storeId: 101)
XCTAssertEqual(storeResponse.total, 4)
XCTAssertEqual(storeResponse.list.first?.id, 101)
var request = try XCTUnwrap(session.requests.last)
XCTAssertEqual(request.httpMethod, "GET")
XCTAssertEqual(request.url?.path, "/api/app/store/business-area")
XCTAssertEqual(queryItems(from: request)["store_id"], "101")
_ = try await api.scenicAdminBusinessArea(scenicId: 88)
request = try XCTUnwrap(session.requests.last)
XCTAssertEqual(request.url?.path, "/api/app/scenic-admin/business-area")
XCTAssertEqual(queryItems(from: request)["scenic_id"], "88")
}
func testViewModelUsesStoreAndScenicModes() async {
let api = OperatingMock()
api.response = ListPayload(total: 1, list: [area(id: 101)])
let storeViewModel = OperatingAreaViewModel()
await storeViewModel.reload(api: api, accountContext: accountContext(storeId: 101), permissionContext: permissionContext(roleName: "店铺管理员"))
XCTAssertEqual(api.storeIds, [101])
XCTAssertEqual(storeViewModel.fenceRings.first?.isCurrentStore, true)
let scenicViewModel = OperatingAreaViewModel()
await scenicViewModel.reload(api: api, accountContext: accountContext(scenicId: 88, storeId: nil), permissionContext: permissionContext(roleName: "景区管理员"))
XCTAssertEqual(api.scenicIds, [88])
XCTAssertEqual(scenicViewModel.mode, .scenicAdmin(scenicId: 88))
}
func testViewModelBlocksMissingContextAndFailureClearsData() async {
let api = OperatingMock()
let viewModel = OperatingAreaViewModel()
await viewModel.reload(api: api, accountContext: accountContext(scenicId: nil, storeId: nil), permissionContext: permissionContext(roleName: "店铺管理员"))
XCTAssertEqual(viewModel.blockReason, .missingStore)
XCTAssertTrue(api.storeIds.isEmpty)
api.response = ListPayload(total: 1, list: [area(id: 101)])
await viewModel.reload(api: api, accountContext: accountContext(storeId: 101), permissionContext: permissionContext(roleName: "店铺管理员"))
XCTAssertFalse(viewModel.fenceRings.isEmpty)
api.error = OperatingTestError.sample
await viewModel.reload(api: api, accountContext: accountContext(storeId: 101), permissionContext: permissionContext(roleName: "店铺管理员"))
XCTAssertTrue(viewModel.items.isEmpty)
XCTAssertTrue(viewModel.fenceRings.isEmpty)
XCTAssertEqual(viewModel.blockReason, .backendMessage("测试错误"))
}
func testParserSupportsGeoJSONStringArraysAndFiltersInvalidRings() throws {
let response = try TestFixture.payload(ListPayload<OperatingAreaItem>.self, named: "operating_area_success")
let rings = response.list.flatMap { OperatingAreaParser.parseToRings($0.businessMapArea) }
XCTAssertEqual(rings.count, 3)
XCTAssertEqual(rings.first?.first, OperatingGeoPoint(latitude: 32.041, longitude: 118.781))
XCTAssertEqual(rings[1].first, OperatingGeoPoint(latitude: 32.051, longitude: 118.791))
XCTAssertEqual(rings[2].first, OperatingGeoPoint(latitude: 32.061, longitude: 118.801))
}
func testEmptyAndUnparsableAreaStates() async {
let api = OperatingMock()
let viewModel = OperatingAreaViewModel()
api.response = ListPayload(total: 0, list: [])
await viewModel.reload(api: api, accountContext: accountContext(storeId: 101), permissionContext: permissionContext(roleName: "店铺管理员"))
XCTAssertEqual(viewModel.blockReason, .emptyAreaList)
api.response = ListPayload(total: 1, list: [OperatingAreaItem(id: 1, name: "bad", businessMapArea: .array([.array([.number(1), .number(2)])]))])
await viewModel.reload(api: api, accountContext: accountContext(storeId: 101), permissionContext: permissionContext(roleName: "店铺管理员"))
XCTAssertEqual(viewModel.blockReason, .noParsableFenceData)
}
}
@MainActor
private final class OperatingMock: OperatingAreaServing {
var response = ListPayload<OperatingAreaItem>(total: 0, list: [])
var error: Error?
var storeIds: [Int] = []
var scenicIds: [Int] = []
func storeBusinessArea(storeId: Int) async throws -> ListPayload<OperatingAreaItem> {
storeIds.append(storeId)
if let error { throw error }
return response
}
func scenicAdminBusinessArea(scenicId: Int) async throws -> ListPayload<OperatingAreaItem> {
scenicIds.append(scenicId)
if let error { throw error }
return response
}
}
private final class OperatingRecordingURLSession: URLSessionProtocol {
private let data: Data
private(set) var requests: [URLRequest] = []
init(data: Data) {
self.data = data
}
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
requests.append(request)
return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
}
}
private enum OperatingTestError: LocalizedError {
case sample
var errorDescription: String? { "测试错误" }
}
private func area(id: Int) -> OperatingAreaItem {
OperatingAreaItem(
id: id,
name: "区域\(id)",
businessMapArea: .array([
.array([
.array([.number(118.781), .number(32.041)]),
.array([.number(118.782), .number(32.041)]),
.array([.number(118.782), .number(32.042)])
])
]),
statusText: "启用",
typeText: "门店",
auditStatusText: "通过"
)
}
@MainActor
private func accountContext(scenicId: Int? = 88, storeId: Int? = 101) -> AccountContext {
let context = AccountContext()
let scenics = scenicId.map { [BusinessScope(id: $0, name: "测试景区", kind: .scenic)] } ?? []
let stores = storeId.map { [BusinessScope(id: $0, name: "测试门店", kind: .store, parentScenicId: scenicId)] } ?? []
context.replaceScopes(scenic: scenics, stores: stores, currentScenicId: scenicId, currentStoreId: storeId)
return context
}
@MainActor
private func permissionContext(roleName: String) -> PermissionContext {
let context = PermissionContext()
let role = RoleInfo(id: 1, name: roleName, permission: [PermissionItem(id: 1, name: "运营区域", uri: "operating-area")])
context.replaceRolePermissions([RolePermissionResponse(role: role, scenic: [])], currentRoleId: 1)
return context
}
private func queryItems(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) }
})
}

View File

@ -0,0 +1,277 @@
//
// PilotCertificationTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/25.
//
import XCTest
@testable import suixinkan
@MainActor
///
final class PilotCertificationTests: XCTestCase {
func testAPIsUseExpectedPathsAndBodies() async throws {
let session = PilotRecordingURLSession(responses: [
try TestFixture.data(named: "flyer_detail_success"),
try TestFixture.data(named: "empty_success"),
try TestFixture.data(named: "empty_success"),
try TestFixture.data(named: "empty_success")
])
let api = PilotCertificationAPI(client: APIClient(session: session))
let detail = try await api.flyerDetail()
XCTAssertEqual(detail.id, 801)
XCTAssertEqual(detail.certificateNo, "UAS-2026-001")
var request = try XCTUnwrap(session.requests.last)
XCTAssertEqual(request.httpMethod, "GET")
XCTAssertEqual(request.url?.path, "/api/app/flyer/detail")
try await api.flyerSendCode(phone: "13800000000")
request = try XCTUnwrap(session.requests.last)
XCTAssertEqual(request.url?.path, "/api/app/flyer/send-code")
XCTAssertEqual(try pilotJSONBody(from: request)["phone"] as? String, "13800000000")
try await api.flyerApply(applyRequest())
request = try XCTUnwrap(session.requests.last)
XCTAssertEqual(request.url?.path, "/api/app/flyer/apply")
var json = try pilotJSONBody(from: request)
XCTAssertEqual(json["name"] as? String, "航拍摄影师")
XCTAssertEqual(json["certificate_no"] as? String, "UAS-2026-001")
XCTAssertEqual(json["drone_sn"] as? String, "SN20260523001")
try await api.flyerEdit(editRequest())
request = try XCTUnwrap(session.requests.last)
XCTAssertEqual(request.url?.path, "/api/app/flyer/edit")
json = try pilotJSONBody(from: request)
XCTAssertEqual(json["id"] as? Int, 801)
XCTAssertEqual(json["contact_phone"] as? String, "13800000001")
}
func testViewModelLoadSendCodeAndCountdown() async throws {
let api = PilotMock()
api.detailResponses = [try TestFixture.payload(FlyerDetailResponse.self, named: "flyer_detail_success")]
let realName = PilotRealNameMock(response: try TestFixture.payload(RealNameInfoResponse.self, named: "real_name_info_success"))
let viewModel = PilotCertificationViewModel()
await viewModel.load(api: api, realNameAPI: realName)
XCTAssertEqual(viewModel.name, "航拍摄影师")
XCTAssertEqual(viewModel.certType, .caac)
XCTAssertEqual(viewModel.droneSerialNo, "SN20260523001")
XCTAssertTrue(viewModel.isRealNameVerified)
XCTAssertEqual(viewModel.latestAuditLog?.operatorName, "审核员")
let codeViewModel = PilotCertificationViewModel()
fillValidForm(codeViewModel)
try await codeViewModel.sendCode(api: api)
XCTAssertEqual(api.sendCodePhones, ["13800000000"])
XCTAssertEqual(codeViewModel.countdown, 60)
codeViewModel.tickCountdown()
XCTAssertEqual(codeViewModel.countdown, 59)
api.sendCodeError = PilotTestError.sample
codeViewModel.phone = "13800000001"
while codeViewModel.countdown > 0 { codeViewModel.tickCountdown() }
await XCTAssertThrowsErrorAsync(try await codeViewModel.sendCode(api: api))
XCTAssertEqual(codeViewModel.countdown, 0)
}
func testValidationCoversRequiredFieldsPhoneImageAndDates() {
let viewModel = PilotCertificationViewModel()
XCTAssertEqual(viewModel.validationMessage, "请输入飞手昵称")
fillValidForm(viewModel)
viewModel.phone = "123"
XCTAssertEqual(viewModel.validationMessage, "请输入有效的手机号码")
fillValidForm(viewModel)
viewModel.certImageUrl = ""
XCTAssertEqual(viewModel.validationMessage, "请选择证件图片")
fillValidForm(viewModel)
viewModel.endDate = Date(timeInterval: -86_400, since: viewModel.startDate)
XCTAssertEqual(viewModel.validationMessage, "截至日期不能早于起始日期")
}
func testSubmitUploadsAndAppliesThenClearsPendingImage() async throws {
let api = PilotMock()
let uploader = PilotUploadMock()
let viewModel = PilotCertificationViewModel()
fillValidForm(viewModel)
viewModel.certImageUrl = ""
viewModel.prepareCertificateImage(data: Data([1, 2, 3]), fileName: "cert.jpg")
uploader.url = "https://cdn.example.com/pilot/cert.jpg"
try await viewModel.submit(api: api, uploader: uploader, scenicId: 88)
XCTAssertEqual(uploader.uploads.first?.fileName, "cert.jpg")
XCTAssertEqual(uploader.uploads.first?.scenicId, 88)
XCTAssertEqual(api.applyRequests.first?.certificateImage, "https://cdn.example.com/pilot/cert.jpg")
XCTAssertNil(viewModel.pendingCertificateImageData)
}
func testRejectedSubmitUsesEditAndFailureKeepsForm() async throws {
let api = PilotMock()
api.detailResponses = [
FlyerDetailResponse(id: 801, name: "旧资料", status: 3, certificateImage: "https://cdn.example.com/old.jpg")
]
let viewModel = PilotCertificationViewModel()
await viewModel.load(api: api, realNameAPI: PilotRealNameMock())
fillValidForm(viewModel)
try await viewModel.submit(api: api, uploader: PilotUploadMock(), scenicId: 88)
XCTAssertEqual(api.editRequests.first?.id, 801)
let failingUploader = PilotUploadMock()
failingUploader.error = PilotTestError.sample
viewModel.certImageUrl = ""
viewModel.prepareCertificateImage(data: Data([9]), fileName: "new.jpg")
await XCTAssertThrowsErrorAsync(try await viewModel.submit(api: api, uploader: failingUploader, scenicId: 88))
XCTAssertEqual(viewModel.pendingCertificateFileName, "new.jpg")
}
}
@MainActor
private final class PilotMock: PilotCertificationServing {
var detailResponses: [FlyerDetailResponse] = []
var sendCodePhones: [String] = []
var applyRequests: [FlyerApplyRequest] = []
var editRequests: [FlyerEditRequest] = []
var sendCodeError: Error?
func flyerDetail() async throws -> FlyerDetailResponse {
detailResponses.isEmpty ? FlyerDetailResponse() : detailResponses.removeFirst()
}
func flyerSendCode(phone: String) async throws {
if let sendCodeError { throw sendCodeError }
sendCodePhones.append(phone)
}
func flyerApply(_ request: FlyerApplyRequest) async throws {
applyRequests.append(request)
}
func flyerEdit(_ request: FlyerEditRequest) async throws {
editRequests.append(request)
}
}
private struct PilotRealNameMock: PilotRealNameServing {
var response = RealNameInfoResponse(realNameInfo: nil)
func realNameInfo() async throws -> RealNameInfoResponse {
response
}
}
@MainActor
private final class PilotUploadMock: OSSUploadServing {
var url = "https://cdn.example.com/pilot/cert.jpg"
var error: Error?
var uploads: [(fileName: String, scenicId: Int)] = []
func uploadPilotCertificateImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
if let error { throw error }
uploads.append((fileName, scenicId))
onProgress(100)
return url
}
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 uploadAliveAlbumFile(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 uploadProjectImage(data: Data, fileName: String, 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 final class PilotRecordingURLSession: URLSessionProtocol {
private var responses: [Data]
private(set) var requests: [URLRequest] = []
init(responses: [Data]) {
self.responses = responses
}
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
requests.append(request)
let data = responses.isEmpty ? try TestFixture.data(named: "empty_success") : responses.removeFirst()
return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
}
}
private enum PilotTestError: LocalizedError {
case sample
var errorDescription: String? { "测试错误" }
}
private func fillValidForm(_ viewModel: PilotCertificationViewModel) {
viewModel.name = "航拍摄影师"
viewModel.certType = .caac
viewModel.certNo = "UAS-2026-001"
viewModel.certImageUrl = "https://cdn.example.com/flyer/cert.jpg"
viewModel.startDate = Date(timeIntervalSince1970: 1_767_225_600)
viewModel.endDate = Date(timeIntervalSince1970: 1_830_384_000)
viewModel.droneModel = "DJI Mini"
viewModel.droneSerialNo = "SN20260523001"
viewModel.phone = "13800000000"
viewModel.verifyCode = "123456"
}
private func applyRequest() -> FlyerApplyRequest {
FlyerApplyRequest(
name: "航拍摄影师",
realnameStatus: 1,
certificateType: 2,
certificateNo: "UAS-2026-001",
certificateStartDate: "2026-01-01",
certificateEndDate: "2028-01-01",
certificateImage: "https://cdn.example.com/flyer/cert.jpg",
droneModel: "DJI Mini",
droneSn: "SN20260523001",
contactPhone: "13800000000",
code: "123456"
)
}
private func editRequest() -> FlyerEditRequest {
FlyerEditRequest(
id: 801,
name: "航拍摄影师",
realnameStatus: 1,
certificateType: 2,
certificateNo: "UAS-2026-001",
certificateStartDate: "2026-01-01",
certificateEndDate: "2028-01-01",
certificateImage: "https://cdn.example.com/flyer/cert.jpg",
droneModel: "DJI Mini",
droneSn: "SN20260523002",
contactPhone: "13800000001",
code: "654321"
)
}
private func pilotJSONBody(from request: URLRequest) throws -> [String: Any] {
let body = try XCTUnwrap(request.httpBody)
return try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any])
}
private func XCTAssertThrowsErrorAsync<T>(
_ expression: @autoclosure () async throws -> T,
file: StaticString = #filePath,
line: UInt = #line
) async {
do {
_ = try await expression()
XCTFail("Expected async expression to throw", file: file, line: line)
} catch {
// Expected path.
}
}

View File

@ -55,4 +55,14 @@ extension OSSUploadServing {
) async throws -> String {
""
}
///
func uploadPilotCertificateImage(
data: Data,
fileName: String,
scenicId: Int,
onProgress: @escaping (Int) -> Void
) async throws -> String {
""
}
}