新增直播模块,包含推流管理与相册流程
将直播推流管理与直播相册从首页占位页迁移为完整实现,包含 alive album OSS 上传、首页路由与单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -0,0 +1,26 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"id": "6101",
|
||||
"album_id": "5101",
|
||||
"name": "五一直播相册",
|
||||
"creator": "摄影师小苏",
|
||||
"items": [
|
||||
{
|
||||
"id": "7101",
|
||||
"url": "https://cdn.example.com/alive/photo.jpg",
|
||||
"type": "1",
|
||||
"size": "204800",
|
||||
"cover_img": ""
|
||||
},
|
||||
{
|
||||
"id": 7102,
|
||||
"url": "https://cdn.example.com/alive/video.mp4",
|
||||
"type": 2,
|
||||
"size": 10485760,
|
||||
"cover_img": "https://cdn.example.com/alive/video-cover.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
34
suixinkanTests/Fixtures/alive_album_list_success.json
Normal file
34
suixinkanTests/Fixtures/alive_album_list_success.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": "6101",
|
||||
"album_id": "5101",
|
||||
"name": "五一直播相册",
|
||||
"creator": "摄影师小苏",
|
||||
"items": [
|
||||
{
|
||||
"id": "7101",
|
||||
"url": "https://cdn.example.com/alive/photo.jpg",
|
||||
"type": "1",
|
||||
"size": "204800",
|
||||
"cover_img": ""
|
||||
},
|
||||
{
|
||||
"id": 7102,
|
||||
"url": "https://cdn.example.com/alive/video.mp4",
|
||||
"type": 2,
|
||||
"size": 10485760,
|
||||
"cover_img": "https://cdn.example.com/alive/video-cover.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"page": "1",
|
||||
"page_size": "1",
|
||||
"total": "2",
|
||||
"total_pages": "2"
|
||||
}
|
||||
}
|
||||
18
suixinkanTests/Fixtures/alive_detail_success.json
Normal file
18
suixinkanTests/Fixtures/alive_detail_success.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"id": "8101",
|
||||
"title": "景区晨间直播",
|
||||
"cover_img": "https://cdn.example.com/alive/cover.jpg",
|
||||
"push_url": "rtmp://push.example.com/live/8101",
|
||||
"start_time": "1779505200",
|
||||
"end_time": "1779508800",
|
||||
"duration": "3600",
|
||||
"status": "2",
|
||||
"status_label": "直播中",
|
||||
"manaul_push_mode": "2",
|
||||
"manaul_push_state": "1",
|
||||
"views_count": "1234"
|
||||
}
|
||||
}
|
||||
25
suixinkanTests/Fixtures/alive_list_success.json
Normal file
25
suixinkanTests/Fixtures/alive_list_success.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": "8101",
|
||||
"title": "景区晨间直播",
|
||||
"cover_img": "https://cdn.example.com/alive/cover.jpg",
|
||||
"push_url": "rtmp://push.example.com/live/8101",
|
||||
"start_time": "1779505200",
|
||||
"end_time": "1779508800",
|
||||
"duration": "3600",
|
||||
"status": "2",
|
||||
"status_label": "直播中",
|
||||
"manaul_push_mode": "2",
|
||||
"manaul_push_state": "1",
|
||||
"views_count": "1234"
|
||||
}
|
||||
],
|
||||
"total": "2",
|
||||
"page": "1",
|
||||
"page_size": "1"
|
||||
}
|
||||
}
|
||||
@ -62,13 +62,15 @@ final class HomeMenuRouterTests: XCTestCase {
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "message_center", title: ""), .destination(.messageCenter))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "/scenic-queue", title: ""), .destination(.queueManagement))
|
||||
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: "live_stream_management", title: ""),
|
||||
.destination(.modulePlaceholder(uri: "live_stream_management", title: "直播管理"))
|
||||
HomeMenuRouter.resolve(uri: "operating-area", title: ""),
|
||||
.destination(.modulePlaceholder(uri: "operating-area", title: "运营区域"))
|
||||
)
|
||||
}
|
||||
|
||||
@ -195,6 +197,8 @@ final class HomeMenuRouterTests: XCTestCase {
|
||||
XCTAssertTrue(uris.contains("message_center"))
|
||||
XCTAssertTrue(uris.contains("/scenic-queue"))
|
||||
XCTAssertTrue(uris.contains("queue_management"))
|
||||
XCTAssertTrue(uris.contains("live_stream_management"))
|
||||
XCTAssertTrue(uris.contains("live_album"))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "album_list", title: ""), .destination(.albumList))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_management", title: ""), .destination(.sampleLibrary))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_upload", title: ""), .destination(.sampleUpload))
|
||||
@ -210,6 +214,8 @@ final class HomeMenuRouterTests: XCTestCase {
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "message_center", title: ""), .destination(.messageCenter))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "/scenic-queue", title: ""), .destination(.queueManagement))
|
||||
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))
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
168
suixinkanTests/Live/LiveAPITests.swift
Normal file
168
suixinkanTests/Live/LiveAPITests.swift
Normal file
@ -0,0 +1,168 @@
|
||||
//
|
||||
// LiveAPITests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 直播 API 测试,覆盖直播管理和直播相册接口编码。
|
||||
final class LiveAPITests: XCTestCase {
|
||||
func testLiveListAndDetailDecodeFixturesAndUseExpectedQueries() async throws {
|
||||
let listSession = LiveRecordingURLSession(data: try TestFixture.data(named: "alive_list_success"))
|
||||
let listAPI = LiveAPI(client: APIClient(session: listSession))
|
||||
|
||||
let list = try await listAPI.liveList(scenicId: 88, page: 0, pageSize: 0)
|
||||
|
||||
XCTAssertEqual(list.total, 2)
|
||||
XCTAssertEqual(list.page, 1)
|
||||
XCTAssertEqual(list.pageSize, 1)
|
||||
XCTAssertEqual(list.items.first?.id, 8101)
|
||||
XCTAssertEqual(list.items.first?.manualPushMode, 2)
|
||||
let listRequest = try XCTUnwrap(listSession.requests.first)
|
||||
XCTAssertEqual(listRequest.httpMethod, "GET")
|
||||
XCTAssertEqual(listRequest.url?.path, "/api/app/manual-live/list")
|
||||
XCTAssertEqual(liveQueryItems(from: listRequest)["scenic_id"], "88")
|
||||
XCTAssertEqual(liveQueryItems(from: listRequest)["page"], "1")
|
||||
XCTAssertEqual(liveQueryItems(from: listRequest)["page_size"], "1")
|
||||
|
||||
let detailSession = LiveRecordingURLSession(data: try TestFixture.data(named: "alive_detail_success"))
|
||||
let detailAPI = LiveAPI(client: APIClient(session: detailSession))
|
||||
|
||||
let detail = try await detailAPI.liveDetail(liveId: 8101)
|
||||
|
||||
XCTAssertEqual(detail.id, 8101)
|
||||
XCTAssertEqual(detail.pushUrl, "rtmp://push.example.com/live/8101")
|
||||
XCTAssertEqual(detail.status, 2)
|
||||
XCTAssertEqual(detail.duration, 3600)
|
||||
let detailRequest = try XCTUnwrap(detailSession.requests.first)
|
||||
XCTAssertEqual(detailRequest.url?.path, "/api/app/manual-live/detail")
|
||||
XCTAssertEqual(liveQueryItems(from: detailRequest)["live_id"], "8101")
|
||||
}
|
||||
|
||||
func testLiveCreateAndControlsUseExpectedBodies() async throws {
|
||||
let createSession = LiveRecordingURLSession(responses: Array(repeating: try TestFixture.data(named: "empty_success"), count: 5))
|
||||
let api = LiveAPI(client: APIClient(session: createSession))
|
||||
|
||||
try await api.liveCreate(LiveCreateRequest(scenicId: "88", title: "景区晨间直播", coverImg: "https://cdn.example.com/cover.jpg"))
|
||||
|
||||
var request = try XCTUnwrap(createSession.requests.last)
|
||||
XCTAssertEqual(request.url?.path, "/api/app/manual-live/create")
|
||||
var json = try liveJSONBody(from: request)
|
||||
XCTAssertEqual(json["scenic_id"] as? String, "88")
|
||||
XCTAssertEqual(json["title"] as? String, "景区晨间直播")
|
||||
XCTAssertEqual(json["cover_img"] as? String, "https://cdn.example.com/cover.jpg")
|
||||
|
||||
try await api.liveStart(liveId: 8101)
|
||||
request = try XCTUnwrap(createSession.requests.last)
|
||||
XCTAssertEqual(request.url?.path, "/api/app/manual-live/start")
|
||||
XCTAssertEqual(try liveJSONBody(from: request)["live_id"] as? Int, 8101)
|
||||
|
||||
try await api.liveStop(liveId: 8101)
|
||||
request = try XCTUnwrap(createSession.requests.last)
|
||||
XCTAssertEqual(request.url?.path, "/api/app/manual-live/stop")
|
||||
XCTAssertEqual(try liveJSONBody(from: request)["live_id"] as? Int, 8101)
|
||||
|
||||
try await api.liveFinish(liveId: 8101)
|
||||
request = try XCTUnwrap(createSession.requests.last)
|
||||
XCTAssertEqual(request.url?.path, "/api/app/manual-live/finish")
|
||||
XCTAssertEqual(try liveJSONBody(from: request)["live_id"] as? Int, 8101)
|
||||
|
||||
try await api.liveSetPushMode(liveId: 8101, mode: 2)
|
||||
request = try XCTUnwrap(createSession.requests.last)
|
||||
XCTAssertEqual(request.url?.path, "/api/app/manual-live/set-push-mode")
|
||||
json = try liveJSONBody(from: request)
|
||||
XCTAssertEqual(json["live_id"] as? Int, 8101)
|
||||
XCTAssertEqual(json["manual_push_mode"] as? Int, 2)
|
||||
}
|
||||
|
||||
func testLiveAlbumAPIsUseExpectedRequests() async throws {
|
||||
let listSession = LiveRecordingURLSession(data: try TestFixture.data(named: "alive_album_list_success"))
|
||||
let api = LiveAPI(client: APIClient(session: listSession))
|
||||
|
||||
let response = try await api.liveAlbumList(scenicId: 88, startTime: "2026-05-01", endTime: "2026-05-23", page: 0, pageSize: 0)
|
||||
|
||||
XCTAssertEqual(response.total, 2)
|
||||
XCTAssertEqual(response.items.first?.id, 6101)
|
||||
XCTAssertEqual(response.items.first?.items.last?.previewURL, "https://cdn.example.com/alive/video-cover.jpg")
|
||||
var request = try XCTUnwrap(listSession.requests.last)
|
||||
XCTAssertEqual(request.url?.path, "/api/app/view-album/folders")
|
||||
var query = liveQueryItems(from: request)
|
||||
XCTAssertEqual(query["scenic_id"], "88")
|
||||
XCTAssertEqual(query["start_time"], "2026-05-01")
|
||||
XCTAssertEqual(query["end_time"], "2026-05-23")
|
||||
XCTAssertEqual(query["page"], "1")
|
||||
XCTAssertEqual(query["page_size"], "1")
|
||||
|
||||
let mutationSession = LiveRecordingURLSession(data: try TestFixture.data(named: "empty_success"))
|
||||
let mutationAPI = LiveAPI(client: APIClient(session: mutationSession))
|
||||
try await mutationAPI.liveAlbumCreateFolder(
|
||||
LiveAlbumCreateFolderRequest(
|
||||
scenicId: "88",
|
||||
name: "五一直播相册",
|
||||
items: [LiveAlbumCreateFileItem(url: "https://cdn.example.com/a.jpg", type: 1, size: 12, coverImg: nil)]
|
||||
)
|
||||
)
|
||||
request = try XCTUnwrap(mutationSession.requests.last)
|
||||
XCTAssertEqual(request.url?.path, "/api/app/view-album/create-folder")
|
||||
var json = try liveJSONBody(from: request)
|
||||
XCTAssertEqual(json["scenic_id"] as? String, "88")
|
||||
XCTAssertEqual((json["items"] as? [[String: Any]])?.first?["url"] as? String, "https://cdn.example.com/a.jpg")
|
||||
|
||||
try await mutationAPI.liveAlbumDeleteFolder(folderId: 6101)
|
||||
request = try XCTUnwrap(mutationSession.requests.last)
|
||||
XCTAssertEqual(request.url?.path, "/api/app/view-album/delete-folder")
|
||||
XCTAssertEqual(try liveJSONBody(from: request)["folder_id"] as? Int, 6101)
|
||||
|
||||
let detailSession = LiveRecordingURLSession(data: try TestFixture.data(named: "alive_album_folder_detail_success"))
|
||||
let detailAPI = LiveAPI(client: APIClient(session: detailSession))
|
||||
let folder = try await detailAPI.liveAlbumFolderDetail(folderId: 6101)
|
||||
XCTAssertEqual(folder.items.count, 2)
|
||||
request = try XCTUnwrap(detailSession.requests.last)
|
||||
XCTAssertEqual(request.url?.path, "/api/app/view-album/folder-detail")
|
||||
XCTAssertEqual(liveQueryItems(from: request)["folder_id"], "6101")
|
||||
|
||||
try await mutationAPI.liveAlbumDeleteFiles(folderId: 6101, fileIds: [7101, 7102])
|
||||
request = try XCTUnwrap(mutationSession.requests.last)
|
||||
XCTAssertEqual(request.url?.path, "/api/app/view-album/delete-files")
|
||||
json = try liveJSONBody(from: request)
|
||||
XCTAssertEqual(json["folder_id"] as? Int, 6101)
|
||||
XCTAssertEqual(json["file_ids"] as? [Int], [7101, 7102])
|
||||
}
|
||||
}
|
||||
|
||||
private final class LiveRecordingURLSession: URLSessionProtocol {
|
||||
private var responses: [Data]
|
||||
private(set) var requests: [URLRequest] = []
|
||||
|
||||
init(data: Data) {
|
||||
responses = [data]
|
||||
}
|
||||
|
||||
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 func liveQueryItems(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) }
|
||||
})
|
||||
}
|
||||
|
||||
private func liveJSONBody(from request: URLRequest) throws -> [String: Any] {
|
||||
let body = try XCTUnwrap(request.httpBody)
|
||||
return try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
}
|
||||
340
suixinkanTests/Live/LiveViewModelTests.swift
Normal file
340
suixinkanTests/Live/LiveViewModelTests.swift
Normal 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 {}
|
||||
}
|
||||
@ -12,11 +12,21 @@ import Foundation
|
||||
enum TestFixture {
|
||||
/// 读取指定名称的 JSON fixture。
|
||||
static func data(named name: String, filePath: String = #filePath) throws -> Data {
|
||||
let url = URL(fileURLWithPath: filePath)
|
||||
var directory = URL(fileURLWithPath: filePath).deletingLastPathComponent()
|
||||
while directory.path != "/" {
|
||||
let url = directory
|
||||
.appendingPathComponent("Fixtures")
|
||||
.appendingPathComponent("\(name).json")
|
||||
if FileManager.default.fileExists(atPath: url.path) {
|
||||
return try Data(contentsOf: url)
|
||||
}
|
||||
directory.deleteLastPathComponent()
|
||||
}
|
||||
let fallback = URL(fileURLWithPath: filePath)
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Fixtures")
|
||||
.appendingPathComponent("\(name).json")
|
||||
return try Data(contentsOf: url)
|
||||
return try Data(contentsOf: fallback)
|
||||
}
|
||||
|
||||
/// 读取并解包后端统一 Envelope。
|
||||
@ -33,3 +43,16 @@ enum TestFixture {
|
||||
enum FixtureError: Error {
|
||||
case emptyPayload
|
||||
}
|
||||
|
||||
extension OSSUploadServing {
|
||||
/// 旧上传替身默认不关心直播相册入口;Live 专用测试会覆盖此方法记录调用。
|
||||
func uploadAliveAlbumFile(
|
||||
data: Data,
|
||||
fileName: String,
|
||||
fileType: Int,
|
||||
scenicId: Int,
|
||||
onProgress: @escaping (Int) -> Void
|
||||
) async throws -> String {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,6 +51,10 @@ final class UploadTests: XCTestCase {
|
||||
OSSUploadPolicy.objectKey(fileName: "cover.jpg", scenicId: 9, moduleType: "project", date: date, uuid: uuid, timeZone: timeZone),
|
||||
"project/20260101/9/123456781234123412341234567890AB_cover.jpg"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
OSSUploadPolicy.objectKey(fileName: "live.mp4", scenicId: 9, moduleType: "alive_album", date: date, uuid: uuid, timeZone: timeZone),
|
||||
"live_albums/20260101/9/123456781234123412341234567890AB_live.mp4"
|
||||
)
|
||||
}
|
||||
|
||||
/// 测试 MIME 类型推断和 URL 拼接规则。
|
||||
|
||||
Reference in New Issue
Block a user