Initial commit: suixinkan_ios UIKit rewrite project.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 14:33:31 +08:00
commit 9edf993432
297 changed files with 47151 additions and 0 deletions

View File

@ -0,0 +1,588 @@
//
// LiveViewModelTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/25.
//
import XCTest
@testable import suixinkan_ios
@MainActor
/// ViewModel
final class LiveViewModelTests: XCTestCase {
func testLiveManagementReloadLoadMoreAndMissingScenicReset() async {
let api = LiveMock()
api.liveListResponses = [
LiveListResponse(items: [live(id: 1, status: 2), live(id: 2, status: 3)], total: 3, page: 1, pageSize: 2),
LiveListResponse(items: [live(id: 3, status: 1)], total: 3, page: 2, pageSize: 2)
]
let viewModel = LiveManagementViewModel()
await viewModel.reload(api: api, scenicId: 88)
await viewModel.loadMore(api: api, scenicId: 88)
await viewModel.loadMore(api: api, scenicId: 88)
XCTAssertEqual(api.liveListRequests.map(\.page), [1, 2])
XCTAssertEqual(viewModel.items.map(\.id), [1, 2, 3])
XCTAssertEqual(viewModel.liveRunningCount, 1)
XCTAssertEqual(viewModel.liveFinishedCount, 1)
XCTAssertFalse(viewModel.hasMore)
await viewModel.reload(api: api, scenicId: nil)
XCTAssertTrue(viewModel.items.isEmpty)
XCTAssertNil(viewModel.detail)
XCTAssertFalse(viewModel.hasMore)
}
func testLiveManagementReloadFailureAndDetailFailureClearStaleState() async {
let api = LiveMock()
api.liveListResponses = [LiveListResponse(items: [live(id: 1)], total: 1)]
api.liveDetailResponses = [live(id: 1, title: "旧详情")]
let viewModel = LiveManagementViewModel()
await viewModel.reload(api: api, scenicId: 88)
await viewModel.loadDetail(api: api, liveId: 1)
XCTAssertEqual(viewModel.items.map(\.id), [1])
XCTAssertEqual(viewModel.detail?.title, "旧详情")
api.liveListError = LiveTestError.sample
await viewModel.reload(api: api, scenicId: 88)
XCTAssertTrue(viewModel.items.isEmpty)
XCTAssertNil(viewModel.detail)
XCTAssertFalse(viewModel.hasMore)
XCTAssertEqual(viewModel.errorMessage, "测试错误")
api.liveListError = nil
api.liveDetailError = LiveTestError.sample
await viewModel.loadDetail(api: api, liveId: 1)
XCTAssertNil(viewModel.detail)
}
func testLiveManagementActionsRefreshAfterSuccess() async throws {
let api = LiveMock()
api.liveListResponses = [
LiveListResponse(items: [live(id: 1, status: 1)], total: 1),
LiveListResponse(items: [live(id: 1, status: 2)], total: 1),
LiveListResponse(items: [live(id: 1, status: 3)], total: 1),
LiveListResponse(items: [live(id: 4, status: 1)], total: 1)
]
let viewModel = LiveManagementViewModel()
await viewModel.reload(api: api, scenicId: 88)
try await viewModel.control(api: api, item: live(id: 1, status: 1), scenicId: 88)
XCTAssertEqual(api.startIds, [1])
XCTAssertEqual(viewModel.items.first?.status, 2)
try await viewModel.finish(api: api, item: live(id: 1, status: 2), scenicId: 88)
XCTAssertEqual(api.finishIds, [1])
XCTAssertEqual(viewModel.items.first?.status, 3)
try await viewModel.create(api: api, scenicId: 88, title: " 新直播 ", coverURL: " https://cdn.example.com/c.jpg ")
XCTAssertEqual(api.createRequests.first?.title, "新直播")
XCTAssertEqual(api.createRequests.first?.coverImg, "https://cdn.example.com/c.jpg")
XCTAssertEqual(viewModel.items.first?.id, 4)
}
func testLiveDetailActionsRefreshDetail() async throws {
let api = LiveMock()
api.liveDetailResponses = [
live(id: 1, status: 2, manualPushMode: 1),
live(id: 1, status: 3, manualPushMode: 1),
live(id: 1, status: 3, manualPushMode: 2)
]
let viewModel = LiveDetailViewModel(detail: live(id: 1, status: 1, manualPushMode: 1))
try await viewModel.control(api: api)
XCTAssertEqual(api.startIds, [1])
XCTAssertEqual(viewModel.detail.status, 2)
try await viewModel.finish(api: api)
XCTAssertEqual(api.finishIds, [1])
XCTAssertEqual(viewModel.detail.status, 3)
try await viewModel.setPushMode(api: api, mode: 2)
XCTAssertEqual(api.pushModeRequests.map(\.mode), [2])
XCTAssertEqual(viewModel.detail.manualPushMode, 2)
}
func testLiveAlbumDateValidationReloadLoadMoreAndFailureBehavior() async {
let api = LiveMock()
api.albumListResponses = [
LiveAlbumFolderListResponse(items: [folder(id: 1)], page: 1, pageSize: 1, total: 2),
LiveAlbumFolderListResponse(items: [folder(id: 2)], page: 2, pageSize: 1, total: 2)
]
let viewModel = LiveAlbumViewModel()
XCTAssertNoThrow(try viewModel.setStartDate(date("2026-05-01")))
XCTAssertNoThrow(try viewModel.setEndDate(date("2026-05-23")))
await viewModel.reload(api: api, scenicId: 88)
await viewModel.loadMore(api: api, scenicId: 88)
XCTAssertEqual(api.albumListRequests.map(\.page), [1, 2])
XCTAssertEqual(api.albumListRequests.first?.startTime, "2026-05-01")
XCTAssertEqual(api.albumListRequests.first?.endTime, "2026-05-23")
XCTAssertEqual(viewModel.folders.map(\.id), [1, 2])
XCTAssertFalse(viewModel.hasMore)
XCTAssertThrowsError(try viewModel.setStartDate(date("2026-06-01")))
api.albumListError = LiveTestError.sample
await viewModel.reload(api: api, scenicId: 88)
XCTAssertTrue(viewModel.folders.isEmpty)
XCTAssertFalse(viewModel.hasMore)
await viewModel.reload(api: api, scenicId: nil)
XCTAssertTrue(viewModel.folders.isEmpty)
}
func testLiveAlbumCreateUploadsFilesAndClearsForm() async throws {
let api = LiveMock()
let uploader = LiveUploadMock()
let uploadedURLs = ["https://cdn.example.com/live_albums/a.jpg", "https://cdn.example.com/live_albums/b.mp4"]
uploader.urls = uploadedURLs
let viewModel = LiveAlbumCreateViewModel()
viewModel.name = " 五一相册 "
viewModel.addLocalFiles([
LiveAlbumLocalUploadFile(data: Data([1, 2, 3]), fileName: "a.jpg", fileType: 1),
LiveAlbumLocalUploadFile(data: Data([4, 5, 6]), fileName: "b.mp4", fileType: 2)
])
try await viewModel.submit(scenicId: 88, api: api, uploader: uploader)
XCTAssertEqual(uploader.uploads.map(\.fileName), ["a.jpg", "b.mp4"])
let request = try XCTUnwrap(api.albumCreateRequests.first)
XCTAssertEqual(request.scenicId, "88")
XCTAssertEqual(request.name, "五一相册")
XCTAssertEqual(request.items.map(\.url), uploadedURLs)
XCTAssertEqual(request.items.map(\.type), [1, 2])
XCTAssertTrue(viewModel.name.isEmpty)
XCTAssertTrue(viewModel.localFiles.isEmpty)
XCTAssertEqual(viewModel.uploadProgress, 100)
}
func testLiveAlbumCreateFailureKeepsForm() async {
let api = LiveMock()
let uploader = LiveUploadMock()
uploader.error = LiveTestError.sample
let viewModel = LiveAlbumCreateViewModel()
viewModel.name = "相册"
viewModel.addLocalFiles([LiveAlbumLocalUploadFile(data: Data([1]), fileName: "a.jpg", fileType: 1)])
await XCTAssertThrowsErrorAsync(try await viewModel.submit(scenicId: 88, api: api, uploader: uploader))
XCTAssertEqual(viewModel.name, "相册")
XCTAssertEqual(viewModel.localFiles.count, 1)
XCTAssertTrue(api.albumCreateRequests.isEmpty)
}
func testLiveAlbumPreviewLoadAndDeleteRefresh() async throws {
let api = LiveMock()
api.albumDetailResponses = [
folder(id: 1, items: [albumFile(id: 11), albumFile(id: 12)]),
folder(id: 1, items: [albumFile(id: 12)])
]
let viewModel = LiveAlbumPreviewViewModel(folderId: 1, startIndex: 0, summary: nil)
await viewModel.load(api: api)
XCTAssertEqual(viewModel.files.map(\.id), [11, 12])
try await viewModel.deleteCurrentFile(api: api)
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)
}
/// RTMP/FLV HTTP
func testLivePlaybackResolverCandidatePriority() {
let live = LiveEntity(
playUrl: "rtmp://pull.example.com/live/1",
pullUrl: "https://cdn.example.com/live/1.flv",
hlsUrl: "https://cdn.example.com/live/1.m3u8",
liveUrl: "https://cdn.example.com/live/1.mp4"
)
XCTAssertEqual(
LivePlaybackURLResolver.playableURL(from: live)?.absoluteString,
"https://cdn.example.com/live/1.m3u8"
)
XCTAssertEqual(
LivePlaybackURLResolver.playableURL(from: ["rtmp://a", "https://cdn.example.com/fallback.mp4"])?.absoluteString,
"https://cdn.example.com/fallback.mp4"
)
}
/// LiveEntity reload
func testLivePlaybackViewModelLoadLiveEntityAndReload() {
let live = LiveEntity(hlsUrl: "https://cdn.example.com/live/1.m3u8")
let viewModel = LivePlaybackViewModel(live: live)
XCTAssertEqual(viewModel.playableURL?.absoluteString, "https://cdn.example.com/live/1.m3u8")
XCTAssertNotNil(viewModel.player)
viewModel.play()
XCTAssertTrue(viewModel.isPlaying)
viewModel.reload()
XCTAssertFalse(viewModel.isPlaying)
XCTAssertNotNil(viewModel.player)
XCTAssertEqual(viewModel.playableURL?.absoluteString, "https://cdn.example.com/live/1.m3u8")
}
///
func testLivePushReadinessInvalidPushURL() {
let viewModel = LivePushReadinessViewModel(
permissionProvider: LivePermissionMock(camera: .granted, microphone: .granted),
networkMonitor: LiveNetworkMock(state: .wifi),
adapter: LivePushAdapterMock(available: true)
)
viewModel.configure(pushURL: "not-a-valid-url")
XCTAssertEqual(viewModel.errorMessage, LivePushReadinessError.invalidPushURL.localizedDescription)
}
/// SDK preparestartstop dispose
func testLivePushReadinessStartStopAndDispose() async throws {
let permissions = LivePermissionMock(camera: .granted, microphone: .granted)
let network = LiveNetworkMock(state: .wifi)
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()
try viewModel.runDiagnostics()
try await viewModel.prepare()
try await viewModel.startPush()
XCTAssertTrue(adapter.prepareCalled)
XCTAssertTrue(adapter.startCalled)
XCTAssertTrue(viewModel.running)
XCTAssertTrue(viewModel.prepared)
await viewModel.stopPush()
XCTAssertTrue(adapter.stopCalled)
XCTAssertFalse(viewModel.running)
await viewModel.dispose()
XCTAssertFalse(viewModel.prepared)
}
///
func testLivePushStateDisplayText() {
XCTAssertEqual(LivePushPermissionState.granted.displayText, "已授权")
XCTAssertEqual(LivePushNetworkState.wifi.displayText, "Wi-Fi")
XCTAssertEqual(LivePushNetworkState.unavailable.displayText, "网络不可用")
}
}
@MainActor
private final class LiveMock: LiveServing {
var liveListResponses: [LiveListResponse] = []
var liveDetailResponses: [LiveEntity] = []
var albumListResponses: [LiveAlbumFolderListResponse] = []
var albumDetailResponses: [LiveAlbumFolderItem] = []
var liveListError: Error?
var liveDetailError: Error?
var albumListError: Error?
var albumDetailError: Error?
var createRequests: [LiveCreateRequest] = []
var startIds: [Int] = []
var stopIds: [Int] = []
var finishIds: [Int] = []
var pushModeRequests: [(liveId: Int, mode: Int)] = []
var albumCreateRequests: [LiveAlbumCreateFolderRequest] = []
var albumDeleteFolderIds: [Int] = []
var albumDeleteFileRequests: [(folderId: Int, fileIds: [Int])] = []
private(set) var liveListRequests: [(scenicId: Int, page: Int, pageSize: Int)] = []
private(set) var albumListRequests: [(scenicId: Int, startTime: String?, endTime: String?, page: Int, pageSize: Int)] = []
func liveList(scenicId: Int, page: Int, pageSize: Int) async throws -> LiveListResponse {
liveListRequests.append((scenicId, page, pageSize))
if let liveListError { throw liveListError }
return liveListResponses.isEmpty ? LiveListResponse() : liveListResponses.removeFirst()
}
func liveDetail(liveId: Int) async throws -> LiveEntity {
if let liveDetailError { throw liveDetailError }
return liveDetailResponses.isEmpty ? live(id: liveId) : liveDetailResponses.removeFirst()
}
func liveCreate(_ request: LiveCreateRequest) async throws {
createRequests.append(request)
}
func liveStart(liveId: Int) async throws {
startIds.append(liveId)
}
func liveStop(liveId: Int) async throws {
stopIds.append(liveId)
}
func liveFinish(liveId: Int) async throws {
finishIds.append(liveId)
}
func liveSetPushMode(liveId: Int, mode: Int) async throws {
pushModeRequests.append((liveId, mode))
}
func liveAlbumList(scenicId: Int, startTime: String?, endTime: String?, page: Int, pageSize: Int) async throws -> LiveAlbumFolderListResponse {
albumListRequests.append((scenicId, startTime, endTime, page, pageSize))
if let albumListError { throw albumListError }
return albumListResponses.isEmpty ? LiveAlbumFolderListResponse() : albumListResponses.removeFirst()
}
func liveAlbumCreateFolder(_ request: LiveAlbumCreateFolderRequest) async throws {
albumCreateRequests.append(request)
}
func liveAlbumDeleteFolder(folderId: Int) async throws {
albumDeleteFolderIds.append(folderId)
}
func liveAlbumFolderDetail(folderId: Int) async throws -> LiveAlbumFolderItem {
if let albumDetailError { throw albumDetailError }
return albumDetailResponses.isEmpty ? folder(id: folderId) : albumDetailResponses.removeFirst()
}
func liveAlbumDeleteFiles(folderId: Int, fileIds: [Int]) async throws {
albumDeleteFileRequests.append((folderId, fileIds))
}
}
@MainActor
private final class LiveUploadMock: OSSUploadServing {
var urls: [String] = []
var error: Error?
private(set) var uploads: [(data: Data, fileName: String, fileType: Int, scenicId: Int)] = []
func uploadAliveAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
if let error { throw error }
uploads.append((data, fileName, fileType, scenicId))
onProgress(100)
return urls.isEmpty ? "https://cdn.example.com/\(fileName)" : urls.removeFirst()
}
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 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 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,
title: title,
coverImg: "https://cdn.example.com/\(id).jpg",
pushUrl: "rtmp://push.example.com/live/\(id)",
duration: 3600,
status: status,
statusLabel: status == 2 ? "直播中" : (status == 3 ? "已结束" : "待开始"),
manualPushMode: manualPushMode,
viewsCount: 10
)
}
private func folder(id: Int, items: [LiveAlbumFileItem] = [albumFile(id: 1)]) -> LiveAlbumFolderItem {
LiveAlbumFolderItem(id: id, albumId: id + 100, name: "相册\(id)", creator: "摄影师", items: items)
}
private func albumFile(id: Int) -> LiveAlbumFileItem {
LiveAlbumFileItem(id: id, url: "https://cdn.example.com/\(id).jpg", type: 1, size: 100)
}
private func date(_ value: String) -> Date {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.timeZone = TimeZone(secondsFromGMT: 0)
return formatter.date(from: value)!
}
private enum LiveTestError: LocalizedError {
case sample
var errorDescription: String? { "测试错误" }
}
private func XCTAssertThrowsErrorAsync<T>(
_ expression: @autoclosure () async throws -> T,
file: StaticString = #filePath,
line: UInt = #line
) async {
do {
_ = try await expression()
XCTFail("Expected error", file: file, line: line)
} catch {}
}