Add OperatingArea and PilotCertification modules with live push readiness.

Migrate operating area and pilot certification from home placeholders, extend Live with playback and push readiness flows, and add pilot certificate OSS upload.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-25 18:15:59 +08:00
parent fdf4659048
commit 41dda3cc9b
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 {
""
}
}