Files
suixinkan_ios_new/suixinkanTests/Live/LiveViewModelTests.swift
汉秋 fdf4659048 Add Live module with stream management and album flows.
Migrate live stream management and live album from home placeholders, including alive album OSS upload, home routing, and unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 14:38:52 +08:00

341 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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 {}
}