新增直播模块,包含推流管理与相册流程

将直播推流管理与直播相册从首页占位页迁移为完整实现,包含 alive album OSS 上传、首页路由与单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-25 14:38:52 +08:00
parent c39c3d3c75
commit fcb692b56a
23 changed files with 2811 additions and 11 deletions

View File

@ -0,0 +1,340 @@
//
// LiveViewModelTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/25.
//
import XCTest
@testable import suixinkan
@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])
}
}
@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 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 {}
}