feat: add live module and optimize report list

This commit is contained in:
2026-07-08 14:25:14 +08:00
parent 8ca8bcf948
commit 4a78a0c21a
14 changed files with 3712 additions and 102 deletions

View File

@ -0,0 +1,101 @@
//
// LiveAPITests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
@MainActor
/// API
final class LiveAPITests: XCTestCase {
func testManualLiveListAndDetailBuildAndroidQueries() async throws {
let session = MockURLSession(responses: [
envelopeJSON(#"{"page":1,"page_size":10,"total":0,"total_pages":0,"items":[]}"#),
envelopeJSON(#"{"id":7,"title":""}"#),
])
let api = LiveAPI(client: APIClient(environment: .testing, session: session))
_ = try await api.liveList(scenicId: "12", page: 2, pageSize: 10)
_ = try await api.liveDetail(liveId: 7)
XCTAssertEqual(session.requests[0].httpMethod, "GET")
XCTAssertEqual(session.requests[0].url?.path, "/api/app/manual-live/list")
let listQuery = URLComponents(url: session.requests[0].url!, resolvingAgainstBaseURL: false)?.queryItems
XCTAssertEqual(listQuery?.first { $0.name == "scenic_id" }?.value, "12")
XCTAssertEqual(listQuery?.first { $0.name == "page" }?.value, "2")
XCTAssertEqual(listQuery?.first { $0.name == "page_size" }?.value, "10")
XCTAssertEqual(session.requests[1].httpMethod, "GET")
XCTAssertEqual(session.requests[1].url?.path, "/api/app/manual-live/detail")
let detailQuery = URLComponents(url: session.requests[1].url!, resolvingAgainstBaseURL: false)?.queryItems
XCTAssertEqual(detailQuery?.first { $0.name == "live_id" }?.value, "7")
}
func testManualLiveControlBodies() async throws {
let session = MockURLSession(responses: [envelopeJSON("{}"), envelopeJSON("{}"), envelopeJSON("{}"), envelopeJSON("{}")])
let api = LiveAPI(client: APIClient(environment: .testing, session: session))
try await api.createLive(scenicId: "3", title: "直播A", coverImg: "https://cdn/c.jpg")
try await api.startLive(liveId: 8)
try await api.finishLive(liveId: 8)
try await api.setPushMode(liveId: 8, mode: 2)
XCTAssertEqual(session.requests[0].url?.path, "/api/app/manual-live/create")
let createBody = try JSONSerialization.jsonObject(with: XCTUnwrap(session.requests[0].httpBody)) as? [String: Any]
XCTAssertEqual(createBody?["scenic_id"] as? String, "3")
XCTAssertEqual(createBody?["title"] as? String, "直播A")
XCTAssertEqual(createBody?["cover_img"] as? String, "https://cdn/c.jpg")
XCTAssertEqual(session.requests[1].url?.path, "/api/app/manual-live/start")
let startBody = try JSONSerialization.jsonObject(with: XCTUnwrap(session.requests[1].httpBody)) as? [String: Any]
XCTAssertEqual(startBody?["live_id"] as? Int, 8)
XCTAssertEqual(session.requests[2].url?.path, "/api/app/manual-live/finish")
XCTAssertEqual(session.requests[3].url?.path, "/api/app/manual-live/set-push-mode")
let modeBody = try JSONSerialization.jsonObject(with: XCTUnwrap(session.requests[3].httpBody)) as? [String: Any]
XCTAssertEqual(modeBody?["live_id"] as? Int, 8)
XCTAssertEqual(modeBody?["manual_push_mode"] as? Int, 2)
}
func testAlbumRequestsMatchAndroidPaths() async throws {
let session = MockURLSession(responses: [
envelopeJSON(#"{"items":[],"page":1,"page_size":10,"total":0,"total_pages":0}"#),
envelopeJSON("{}"),
envelopeJSON(#"{"id":4,"items":[]}"#),
envelopeJSON("{}"),
])
let api = LiveAPI(client: APIClient(environment: .testing, session: session))
_ = try await api.albumList(scenicId: "9", startTime: "2026-07-01", endTime: "2026-07-08", page: 3, pageSize: 10)
try await api.createAlbumFolder(scenicId: "9", name: "作品", items: [LiveAlbumFile(url: "https://cdn/a.jpg", type: 2, size: 10)])
_ = try await api.albumFolderDetail(folderId: 4)
try await api.deleteAlbumFile(folderId: 4, fileId: 11)
XCTAssertEqual(session.requests[0].url?.path, "/api/app/view-album/folders")
let query = URLComponents(url: session.requests[0].url!, resolvingAgainstBaseURL: false)?.queryItems
XCTAssertEqual(query?.first { $0.name == "scenic_id" }?.value, "9")
XCTAssertEqual(query?.first { $0.name == "start_time" }?.value, "2026-07-01")
XCTAssertEqual(query?.first { $0.name == "end_time" }?.value, "2026-07-08")
XCTAssertEqual(query?.first { $0.name == "page" }?.value, "3")
XCTAssertEqual(query?.first { $0.name == "page_size" }?.value, "10")
XCTAssertEqual(session.requests[1].url?.path, "/api/app/view-album/create-folder")
let createBody = try JSONSerialization.jsonObject(with: XCTUnwrap(session.requests[1].httpBody)) as? [String: Any]
XCTAssertEqual(createBody?["scenic_id"] as? String, "9")
XCTAssertEqual(createBody?["name"] as? String, "作品")
XCTAssertEqual((createBody?["items"] as? [[String: Any]])?.first?["url"] as? String, "https://cdn/a.jpg")
XCTAssertEqual(session.requests[2].url?.path, "/api/app/view-album/folder-detail")
XCTAssertEqual(session.requests[3].url?.path, "/api/app/view-album/delete-files")
let deleteBody = try JSONSerialization.jsonObject(with: XCTUnwrap(session.requests[3].httpBody)) as? [String: Any]
XCTAssertEqual(deleteBody?["folder_id"] as? Int, 4)
XCTAssertEqual(deleteBody?["file_ids"] as? [Int], [11])
}
private func envelopeJSON(_ dataJSON: String) -> Data {
"""
{"code":100000,"msg":"success","data":\(dataJSON)}
""".data(using: .utf8)!
}
}

View File

@ -0,0 +1,46 @@
//
// LiveModelsTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
///
final class LiveModelsTests: XCTestCase {
func testLiveItemDecodesLegacyAndCorrectManualPushKeys() throws {
let legacyJSON = """
{"id":"3","title":"","cover_img":"https:\\/\\/cdn\\/c.jpg","push_url":"rtmp:\\/\\/push","status":2,"status_label":"","manaul_push_mode":2,"manaul_push_state":"1","views_count":"9"}
""".data(using: .utf8)!
let legacy = try JSONDecoder().decode(LiveItem.self, from: legacyJSON)
XCTAssertEqual(legacy.id, 3)
XCTAssertEqual(legacy.coverImg, "https://cdn/c.jpg")
XCTAssertEqual(legacy.pushUrl, "rtmp://push")
XCTAssertEqual(legacy.manualPushMode, 2)
XCTAssertEqual(legacy.manualPushState, 1)
XCTAssertEqual(legacy.viewsCount, 9)
XCTAssertEqual(legacy.liveStatus, .living)
let correctJSON = """
{"id":4,"manual_push_mode":1,"manual_push_state":0}
""".data(using: .utf8)!
let correct = try JSONDecoder().decode(LiveItem.self, from: correctJSON)
XCTAssertEqual(correct.manualPushMode, 1)
XCTAssertEqual(correct.manualPushState, 0)
XCTAssertEqual(correct.displayTitle, "暂无标题")
}
func testAlbumFolderDecodesItemsAndDefaults() throws {
let json = """
{"id":6,"album_id":5,"name":"","creator":"A","items":[{"id":"7","url":"https:\\/\\/cdn\\/a.jpg","type":"2","size":"1024","cover_img":""}]}
""".data(using: .utf8)!
let folder = try JSONDecoder().decode(LiveAlbumFolder.self, from: json)
XCTAssertEqual(folder.id, 6)
XCTAssertEqual(folder.displayName, "暂无标题")
XCTAssertEqual(folder.items.first?.id, 7)
XCTAssertEqual(folder.items.first?.url, "https://cdn/a.jpg")
XCTAssertEqual(folder.items.first?.size, 1024)
}
}

View File

@ -0,0 +1,212 @@
//
// LiveViewModelTests.swift
// suixinkanTests
//
import Foundation
import XCTest
@testable import suixinkan
@MainActor
/// ViewModel
final class LiveViewModelTests: XCTestCase {
func testLiveManageLoadsMoreCreatesAndControls() async {
let api = MockLiveAPI()
api.liveListResponses = [
LiveListResponse(total: 12, items: (0 ..< 10).map { liveItem(id: $0) }),
LiveListResponse(total: 12, items: [liveItem(id: 10), liveItem(id: 11)]),
LiveListResponse(total: 1, items: [liveItem(id: 99)]),
LiveListResponse(total: 1, items: [liveItem(id: 99, status: 2)]),
LiveListResponse(total: 1, items: [liveItem(id: 99, status: 4)]),
]
let viewModel = LiveManageViewModel(currentScenicIdProvider: { 3 })
await viewModel.loadInitial(api: api)
await viewModel.loadMoreIfNeeded(lastVisibleIndex: 8, api: api)
await viewModel.createLive(title: " 新直播 ", coverUrl: "https://cdn/c.jpg", api: api)
await viewModel.controlLive(liveItem(id: 99, status: 1), api: api)
await viewModel.finishLive(liveItem(id: 99, status: 2), api: api)
XCTAssertEqual(api.liveListCalls.map(\.page), [1, 2, 1, 1, 1])
XCTAssertEqual(api.createLiveCalls.first?.title, "新直播")
XCTAssertEqual(api.startCalls, [99])
XCTAssertEqual(api.finishCalls, [99])
}
func testLiveDetailChangesModeAndStopsLiving() async {
let api = MockLiveAPI()
api.detailResponses = [
liveItem(id: 8, status: 2, mode: 1),
liveItem(id: 8, status: 2, mode: 2),
liveItem(id: 8, status: 3, mode: 2),
]
let viewModel = LiveDetailViewModel(liveId: 8)
await viewModel.load(api: api)
await viewModel.changeMode(.smoothFirst, api: api)
await viewModel.controlLive(api: api)
XCTAssertEqual(viewModel.pushMode, .smoothFirst)
XCTAssertEqual(api.modeCalls.first?.mode, 2)
XCTAssertEqual(api.stopCalls, [8])
}
func testAlbumDateValidationAndDeleteReload() async {
let api = MockLiveAPI()
api.albumListResponses = [
LiveAlbumFolderListResponse(items: [folder(id: 1)], total: 1),
LiveAlbumFolderListResponse(items: [folder(id: 2)], total: 1),
LiveAlbumFolderListResponse(items: [], total: 0),
]
let viewModel = LiveAlbumListViewModel(currentScenicIdProvider: { 5 })
var messages: [String] = []
viewModel.onShowMessage = { messages.append($0) }
let start = date("2026-07-08")
let before = date("2026-07-01")
await viewModel.loadInitial(api: api)
await viewModel.setStartDate(start, api: api)
await viewModel.setEndDate(before, api: api)
await viewModel.deleteFolder(folder(id: 2), api: api)
XCTAssertEqual(messages.last, "结束时间不能小于开始时间")
XCTAssertEqual(api.albumListCalls.map(\.startTime), [nil, "2026-07-08", "2026-07-08"])
XCTAssertEqual(api.deleteFolderCalls, [2])
}
func testAlbumAddValidationUploadsAndCreates() async {
let api = MockLiveAPI()
let uploader = MockLiveUploader(urls: ["https://cdn/a.jpg"])
let viewModel = LiveAlbumAddViewModel(currentScenicIdProvider: { 6 })
var messages: [String] = []
viewModel.onShowMessage = { messages.append($0) }
await viewModel.create(api: api)
viewModel.updateTitle("作品")
await viewModel.create(api: api)
await viewModel.addLocalMedia(LivePickedMedia(data: Data(repeating: 1, count: 10), fileName: "a.jpg", size: 10, width: 1, height: 1), uploader: uploader)
await viewModel.create(api: api)
XCTAssertEqual(messages.prefix(2), ["请输入作品名称", "请上传素材"])
XCTAssertEqual(api.createAlbumCalls.first?.name, "作品")
XCTAssertEqual(api.createAlbumCalls.first?.items.first?.url, "https://cdn/a.jpg")
}
func testPreviewDeleteReloadsFolderDetail() async {
let api = MockLiveAPI()
api.folderDetailResponses = [
folder(id: 4, items: [LiveAlbumFile(url: "a", type: 2, size: 1, id: 10)]),
folder(id: 4, items: []),
]
let viewModel = LiveAlbumPreviewViewModel(folderId: 4, startIndex: 0)
await viewModel.load(api: api)
await viewModel.deleteCurrentFile(api: api)
XCTAssertEqual(api.deleteFileCalls.first?.folderId, 4)
XCTAssertEqual(api.deleteFileCalls.first?.fileId, 10)
XCTAssertTrue(viewModel.files.isEmpty)
}
}
@MainActor
private final class MockLiveAPI: LiveServing {
var liveListResponses: [LiveListResponse] = []
var detailResponses: [LiveItem] = []
var albumListResponses: [LiveAlbumFolderListResponse] = []
var folderDetailResponses: [LiveAlbumFolder] = []
var liveListCalls: [(scenicId: String, page: Int, pageSize: Int)] = []
var createLiveCalls: [(scenicId: String, title: String, coverImg: String)] = []
var startCalls: [Int] = []
var stopCalls: [Int] = []
var finishCalls: [Int] = []
var modeCalls: [(liveId: Int, mode: Int)] = []
var albumListCalls: [(scenicId: String, startTime: String?, endTime: String?, page: Int, pageSize: Int)] = []
var createAlbumCalls: [(scenicId: String, name: String, items: [LiveAlbumFile])] = []
var deleteFolderCalls: [Int] = []
var deleteFileCalls: [(folderId: Int, fileId: Int)] = []
func liveList(scenicId: String, page: Int, pageSize: Int) async throws -> LiveListResponse {
liveListCalls.append((scenicId, page, pageSize))
return liveListResponses.isEmpty ? LiveListResponse() : liveListResponses.removeFirst()
}
func liveDetail(liveId: Int) async throws -> LiveItem {
detailResponses.isEmpty ? liveItem(id: liveId) : detailResponses.removeFirst()
}
func createLive(scenicId: String, title: String, coverImg: String) async throws {
createLiveCalls.append((scenicId, title, coverImg))
}
func startLive(liveId: Int) async throws {
startCalls.append(liveId)
}
func stopLive(liveId: Int) async throws {
stopCalls.append(liveId)
}
func finishLive(liveId: Int) async throws {
finishCalls.append(liveId)
}
func setPushMode(liveId: Int, mode: Int) async throws {
modeCalls.append((liveId, mode))
}
func albumList(scenicId: String, startTime: String?, endTime: String?, page: Int, pageSize: Int) async throws -> LiveAlbumFolderListResponse {
albumListCalls.append((scenicId, startTime, endTime, page, pageSize))
return albumListResponses.isEmpty ? LiveAlbumFolderListResponse() : albumListResponses.removeFirst()
}
func createAlbumFolder(scenicId: String, name: String, items: [LiveAlbumFile]) async throws {
createAlbumCalls.append((scenicId, name, items))
}
func deleteAlbumFolder(folderId: Int) async throws {
deleteFolderCalls.append(folderId)
}
func albumFolderDetail(folderId: Int) async throws -> LiveAlbumFolder {
folderDetailResponses.isEmpty ? folder(id: folderId) : folderDetailResponses.removeFirst()
}
func deleteAlbumFile(folderId: Int, fileId: Int) async throws {
deleteFileCalls.append((folderId, fileId))
}
}
@MainActor
private final class MockLiveUploader: LiveOSSUploading {
private var urls: [String]
init(urls: [String]) {
self.urls = urls
}
func uploadLiveCover(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
urls.isEmpty ? "https://cdn/default.jpg" : urls.removeFirst()
}
func uploadLiveAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
urls.isEmpty ? "https://cdn/default.jpg" : urls.removeFirst()
}
}
private func liveItem(id: Int, status: Int = 1, mode: Int = 1) -> LiveItem {
LiveItem(id: id, title: "直播\(id)", coverImg: "", pushUrl: "rtmp://push/\(id)", duration: 60, status: status, statusLabel: "", manualPushMode: mode)
}
private func folder(id: Int, items: [LiveAlbumFile] = []) -> LiveAlbumFolder {
LiveAlbumFolder(id: id, albumId: id, name: "相册\(id)", creator: "", items: items)
}
private func date(_ text: String) -> Date {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "zh_CN")
return formatter.date(from: text)!
}