完善样片管理列表、详情与上传页 UI 交互。
对齐 Android 样片模块布局与加载状态,补充共享 UI 组件与图片资源,并增强列表解析容错与单元测试覆盖。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -69,7 +69,7 @@ final class HomeCommonMenuStoreTests: XCTestCase {
|
||||
func testHomeMenuCatalogUsesSyncedSymbols() {
|
||||
XCTAssertEqual(HomeMenuCatalog.item(for: "space_settings")?.iconName, "person.crop.square.fill")
|
||||
XCTAssertEqual(HomeMenuCatalog.item(for: "wallet")?.iconName, "creditcard.fill")
|
||||
XCTAssertEqual(HomeMenuCatalog.item(for: "sample_management")?.iconName, "point.3.connected.trianglepath.dotted")
|
||||
XCTAssertEqual(HomeMenuCatalog.item(for: "sample_management")?.iconName, "sample_ic_home_menu_route")
|
||||
XCTAssertEqual(HomeMenuCatalog.item(for: "/scenic-queue")?.iconName, "person.3.fill")
|
||||
XCTAssertEqual(HomeMenuItem.moreFunctions.iconName, "ellipsis")
|
||||
}
|
||||
|
||||
@ -60,6 +60,16 @@ final class SampleManagementAPITests: XCTestCase {
|
||||
XCTAssertEqual(operationQuery?.first { $0.name == "type" }?.value, "2")
|
||||
}
|
||||
|
||||
func testDetailMediaTypeZeroFallsBackToAlbumType() async throws {
|
||||
let detail = envelopeJSON(#"{"id":8,"type":2,"status":1,"listing_status":1,"media_list":[{"id":1,"type":0,"oss_url":"https://cdn/video.mp4"}]}"#)
|
||||
let session = MockURLSession(responses: [detail])
|
||||
let api = SampleManagementAPI(client: APIClient(environment: .testing, session: session))
|
||||
|
||||
let response = try await api.detail(id: 8)
|
||||
|
||||
XCTAssertEqual(response.mediaList?.first?.resolvedType(fallback: response.type ?? 1), 2)
|
||||
}
|
||||
|
||||
func testUploadEncodesAndroidBodyKeys() async throws {
|
||||
let session = MockURLSession(responses: [envelopeJSON("{}")])
|
||||
let api = SampleManagementAPI(client: APIClient(environment: .testing, session: session))
|
||||
@ -97,11 +107,45 @@ final class SampleManagementAPITests: XCTestCase {
|
||||
let media = (body?["media_list"] as? [[String: Any]])?.first
|
||||
XCTAssertEqual(media?["original_name"] as? String, "a.jpg")
|
||||
XCTAssertEqual(media?["oss_url"] as? String, "https://cdn/a.jpg")
|
||||
XCTAssertEqual(media?["size"] as? Int, 1024)
|
||||
let fileSize = media?["file_width_size"] as? [String: Any]
|
||||
XCTAssertEqual(fileSize?["width"] as? Int, 1920)
|
||||
XCTAssertEqual(fileSize?["height"] as? Int, 1080)
|
||||
}
|
||||
|
||||
func testUploadEncodesVideoMetadata() async throws {
|
||||
let session = MockURLSession(responses: [envelopeJSON("{}")])
|
||||
let api = SampleManagementAPI(client: APIClient(environment: .testing, session: session))
|
||||
let request = SampleUploadMaterialRequest(
|
||||
name: "视频样片",
|
||||
mediaType: .video,
|
||||
mediaList: [
|
||||
SampleUploadMediaItem(
|
||||
originalName: "rotated.mp4",
|
||||
ossUrl: "https://cdn/rotated.mp4",
|
||||
size: 4_096,
|
||||
fileWidthSize: SampleUploadFileSize(width: 1080, height: 1920)
|
||||
),
|
||||
],
|
||||
coverUrl: "https://cdn/cover.jpg",
|
||||
coverSize: "512",
|
||||
scenicSpotId: 9,
|
||||
projectId: 7
|
||||
)
|
||||
|
||||
try await api.upload(request)
|
||||
|
||||
let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(session.requests.first?.httpBody)) as? [String: Any]
|
||||
XCTAssertEqual(body?["type"] as? String, "2")
|
||||
XCTAssertEqual(body?["media_type"] as? String, "2")
|
||||
let media = (body?["media_list"] as? [[String: Any]])?.first
|
||||
XCTAssertEqual(media?["original_name"] as? String, "rotated.mp4")
|
||||
XCTAssertEqual(media?["size"] as? Int, 4_096)
|
||||
let fileSize = media?["file_width_size"] as? [String: Any]
|
||||
XCTAssertEqual(fileSize?["width"] as? Int, 1080)
|
||||
XCTAssertEqual(fileSize?["height"] as? Int, 1920)
|
||||
}
|
||||
|
||||
func testProjectAndScenicSpotPaths() async throws {
|
||||
let spots = envelopeJSON(#"{"total":1,"list":[]}"#)
|
||||
let projects = envelopeJSON(#"{"total":1,"list":[]}"#)
|
||||
|
||||
@ -10,6 +10,13 @@ import XCTest
|
||||
@MainActor
|
||||
/// 样片管理 ViewModel 测试。
|
||||
final class SampleManagementViewModelTests: XCTestCase {
|
||||
func testAllFilterStatusesMatchAndroidValues() {
|
||||
XCTAssertEqual(SampleFilterStatus.all.apiStatus, 4)
|
||||
XCTAssertEqual(SampleFilterStatus.approved.apiStatus, 1)
|
||||
XCTAssertEqual(SampleFilterStatus.pending.apiStatus, 0)
|
||||
XCTAssertEqual(SampleFilterStatus.rejected.apiStatus, 2)
|
||||
}
|
||||
|
||||
func testListLoadsRefreshesAndMapsFilterStatus() async {
|
||||
let api = MockSampleAPI()
|
||||
api.listResponses = [
|
||||
@ -36,10 +43,12 @@ final class SampleManagementViewModelTests: XCTestCase {
|
||||
viewModel.onShowMessage = { messages.append($0) }
|
||||
|
||||
await viewModel.loadInitial(api: api)
|
||||
await viewModel.toggleListing(sampleId: 1, checked: true, api: api)
|
||||
let resolved = await viewModel.toggleListing(sampleId: 1, checked: true, api: api)
|
||||
|
||||
XCTAssertEqual(messages.last, "审核通过才可以操作上下架")
|
||||
XCTAssertTrue(api.operationCalls.isEmpty)
|
||||
XCTAssertFalse(resolved)
|
||||
XCTAssertFalse(viewModel.isOperating)
|
||||
}
|
||||
|
||||
func testApprovedSampleToggleUpdatesLocalItem() async {
|
||||
@ -48,11 +57,134 @@ final class SampleManagementViewModelTests: XCTestCase {
|
||||
let viewModel = SampleListViewModel()
|
||||
|
||||
await viewModel.loadInitial(api: api)
|
||||
await viewModel.toggleListing(sampleId: 2, checked: true, api: api)
|
||||
let resolved = await viewModel.toggleListing(sampleId: 2, checked: true, api: api)
|
||||
|
||||
XCTAssertEqual(api.operationCalls.first?.id, 2)
|
||||
XCTAssertEqual(api.operationCalls.first?.listingStatus, 1)
|
||||
XCTAssertEqual(viewModel.items.first?.listingStatus, 1)
|
||||
XCTAssertTrue(resolved)
|
||||
XCTAssertFalse(viewModel.isOperating)
|
||||
}
|
||||
|
||||
func testApprovedSampleExposesOperatingStateDuringRequest() async {
|
||||
let api = MockSampleAPI()
|
||||
api.listResponses = [SampleMaterialListResponse(total: 1, list: [makeItem(id: 2, status: 1, listingStatus: 0)])]
|
||||
api.operationDelayNanoseconds = 50_000_000
|
||||
let viewModel = SampleListViewModel()
|
||||
await viewModel.loadInitial(api: api)
|
||||
|
||||
let operation = Task { await viewModel.toggleListing(sampleId: 2, checked: true, api: api) }
|
||||
await Task.yield()
|
||||
|
||||
XCTAssertTrue(viewModel.operatingSampleIDs.contains(2))
|
||||
XCTAssertTrue(viewModel.isOperating)
|
||||
let resolved = await operation.value
|
||||
XCTAssertTrue(resolved)
|
||||
XCTAssertFalse(viewModel.isOperating)
|
||||
}
|
||||
|
||||
func testListingFailureReturnsAuthoritativePreviousState() async {
|
||||
let api = MockSampleAPI()
|
||||
api.listResponses = [SampleMaterialListResponse(total: 1, list: [makeItem(id: 3, status: 1, listingStatus: 0)])]
|
||||
api.operationError = APIError.emptyData
|
||||
let viewModel = SampleListViewModel()
|
||||
|
||||
await viewModel.loadInitial(api: api)
|
||||
let resolved = await viewModel.toggleListing(sampleId: 3, checked: true, api: api)
|
||||
|
||||
XCTAssertFalse(resolved)
|
||||
XCTAssertEqual(viewModel.items.first?.listingStatus, 0)
|
||||
XCTAssertFalse(viewModel.isOperating)
|
||||
}
|
||||
|
||||
func testSearchAndDuplicateLoadingProtection() async {
|
||||
let api = MockSampleAPI()
|
||||
api.listDelayNanoseconds = 50_000_000
|
||||
api.listResponses = [SampleMaterialListResponse(total: 1, list: [makeItem(id: 1)])]
|
||||
let viewModel = SampleListViewModel()
|
||||
|
||||
let first = Task { await viewModel.updateSearchText(" 雪山 ", api: api) }
|
||||
await Task.yield()
|
||||
await viewModel.loadInitial(api: api)
|
||||
await first.value
|
||||
|
||||
XCTAssertEqual(api.listCalls.count, 1)
|
||||
XCTAssertEqual(api.listCalls.first?.keyword, "雪山")
|
||||
XCTAssertEqual(api.listCalls.first?.pageSize, 10)
|
||||
}
|
||||
|
||||
func testChangingMediaTypeClearsMaterialsButKeepsCover() async {
|
||||
let viewModel = UploadSampleViewModel(currentScenicIdProvider: { 3 })
|
||||
let uploader = MockSampleUploader(urls: ["https://cdn/a.jpg", "https://cdn/cover.jpg"])
|
||||
|
||||
await viewModel.addLocalMedia([makeUploadItem(fileName: "a.jpg")], uploader: uploader)
|
||||
await viewModel.setCoverImage(makeUploadItem(fileName: "cover.jpg"), uploader: uploader)
|
||||
viewModel.selectMediaType(.video)
|
||||
|
||||
XCTAssertTrue(viewModel.uploadedMediaList.isEmpty)
|
||||
XCTAssertEqual(viewModel.coverImage?.ossUrl, "https://cdn/cover.jpg")
|
||||
XCTAssertEqual(viewModel.mediaType, .video)
|
||||
}
|
||||
|
||||
func testScenicCascadeClearsSelectionsAndProjectPagination() async {
|
||||
let firstScenic = ScenicInfo(id: 3, name: "景区A", status: 1, coverImg: nil)
|
||||
let secondScenic = ScenicInfo(id: 4, name: "景区B", status: 1, coverImg: nil)
|
||||
let api = MockSampleAPI()
|
||||
api.projectResponses = [
|
||||
SampleProjectListResponse(list: (0 ..< 10).map { SampleProjectItem(id: $0, name: "项目\($0)") }, total: 11),
|
||||
SampleProjectListResponse(list: [SampleProjectItem(id: 10, name: "项目10")], total: 11),
|
||||
]
|
||||
let viewModel = UploadSampleViewModel(currentScenicIdProvider: { 3 })
|
||||
|
||||
await viewModel.selectScenic(firstScenic, api: api)
|
||||
await viewModel.loadProjects(refresh: true, api: api)
|
||||
await viewModel.loadProjects(refresh: false, api: api)
|
||||
viewModel.selectScenicSpot(ScenicSpotItem(id: 9, name: "打卡点"))
|
||||
viewModel.selectProject(SampleProjectItem(id: 10, name: "项目10"))
|
||||
|
||||
XCTAssertEqual(api.projectCalls.map(\.page), [1, 2])
|
||||
XCTAssertEqual(viewModel.projectList.count, 11)
|
||||
|
||||
await viewModel.selectScenic(secondScenic, api: api)
|
||||
|
||||
XCTAssertNil(viewModel.selectedScenicSpot)
|
||||
XCTAssertNil(viewModel.selectedProject)
|
||||
XCTAssertTrue(viewModel.projectList.isEmpty)
|
||||
XCTAssertFalse(viewModel.projectCanLoadMore)
|
||||
}
|
||||
|
||||
func testMaterialCountLimitsFollowMediaType() async {
|
||||
let imageViewModel = UploadSampleViewModel(currentScenicIdProvider: { 3 })
|
||||
await imageViewModel.addLocalMedia(
|
||||
(0 ..< 10).map { makeUploadItem(fileName: "\($0).jpg") },
|
||||
uploader: MockSampleUploader(urls: (0 ..< 9).map { "https://cdn/\($0).jpg" })
|
||||
)
|
||||
XCTAssertEqual(imageViewModel.uploadedMediaList.count, 9)
|
||||
|
||||
let videoViewModel = UploadSampleViewModel(currentScenicIdProvider: { 3 })
|
||||
videoViewModel.selectMediaType(.video)
|
||||
await videoViewModel.addLocalMedia(
|
||||
[makeUploadItem(fileName: "a.mp4"), makeUploadItem(fileName: "b.mp4")],
|
||||
uploader: MockSampleUploader(urls: ["https://cdn/a.mp4"])
|
||||
)
|
||||
XCTAssertEqual(videoViewModel.uploadedMediaList.count, 1)
|
||||
}
|
||||
|
||||
func testSequentialUploadContinuesAfterOneFailure() async {
|
||||
let viewModel = UploadSampleViewModel(currentScenicIdProvider: { 3 })
|
||||
let uploader = MockSampleUploader(results: [
|
||||
.failure(APIError.emptyData),
|
||||
.success("https://cdn/b.jpg"),
|
||||
])
|
||||
|
||||
await viewModel.addLocalMedia(
|
||||
[makeUploadItem(fileName: "a.jpg"), makeUploadItem(fileName: "b.jpg")],
|
||||
uploader: uploader
|
||||
)
|
||||
|
||||
XCTAssertEqual(viewModel.uploadedMediaList[0].ossUrl, "")
|
||||
XCTAssertEqual(viewModel.uploadedMediaList[1].ossUrl, "https://cdn/b.jpg")
|
||||
XCTAssertNil(viewModel.uploadDialogState)
|
||||
}
|
||||
|
||||
func testUploadValidationMessages() async {
|
||||
@ -81,6 +213,34 @@ final class SampleManagementViewModelTests: XCTestCase {
|
||||
XCTAssertNil(api.lastUploadRequest)
|
||||
}
|
||||
|
||||
func testUploadValidationRequiresScenicSpotThenProject() async {
|
||||
let scenic = ScenicInfo(id: 3, name: "景区", status: 1, coverImg: nil)
|
||||
let api = MockSampleAPI()
|
||||
let viewModel = UploadSampleViewModel(
|
||||
scenicListProvider: { [scenic] },
|
||||
currentScenicIdProvider: { 3 }
|
||||
)
|
||||
var messages: [String] = []
|
||||
viewModel.onShowMessage = { messages.append($0) }
|
||||
viewModel.updateAlbumName("样片")
|
||||
await viewModel.addLocalMedia(
|
||||
[makeUploadItem(fileName: "a.jpg")],
|
||||
uploader: MockSampleUploader(urls: ["https://cdn/a.jpg"])
|
||||
)
|
||||
await viewModel.setCoverImage(
|
||||
makeUploadItem(fileName: "cover.jpg"),
|
||||
uploader: MockSampleUploader(urls: ["https://cdn/cover.jpg"])
|
||||
)
|
||||
await viewModel.selectScenic(scenic, api: api)
|
||||
|
||||
await viewModel.uploadSample(api: api)
|
||||
viewModel.selectScenicSpot(ScenicSpotItem(id: 9, name: "打卡点"))
|
||||
await viewModel.uploadSample(api: api)
|
||||
|
||||
XCTAssertEqual(Array(messages.suffix(2)), ["请选择关联打卡点", "请选择关联项目"])
|
||||
XCTAssertNil(api.lastUploadRequest)
|
||||
}
|
||||
|
||||
func testUploadSuccessBuildsSampleRequest() async {
|
||||
let scenic = ScenicInfo(id: 3, name: "景区", status: 1, coverImg: nil)
|
||||
let api = MockSampleAPI()
|
||||
@ -119,10 +279,17 @@ private final class MockSampleAPI: SampleManagementServing {
|
||||
var operationCalls: [(id: Int, listingStatus: Int)] = []
|
||||
var lastUploadRequest: SampleUploadMaterialRequest?
|
||||
var spotResponse = ScenicSpotListResponse(list: [], total: 0)
|
||||
var projectResponse = SampleProjectListResponse(list: [], total: 0)
|
||||
var projectResponses: [SampleProjectListResponse] = []
|
||||
var operationError: Error?
|
||||
var listDelayNanoseconds: UInt64 = 0
|
||||
var operationDelayNanoseconds: UInt64 = 0
|
||||
var projectCalls: [(scenicId: String, page: Int, pageSize: Int)] = []
|
||||
|
||||
func list(status: Int, keyword: String?, page: Int, pageSize: Int) async throws -> SampleMaterialListResponse {
|
||||
listCalls.append((status, keyword, page, pageSize))
|
||||
if listDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: listDelayNanoseconds)
|
||||
}
|
||||
guard !listResponses.isEmpty else { return SampleMaterialListResponse() }
|
||||
return listResponses.removeFirst()
|
||||
}
|
||||
@ -133,6 +300,10 @@ private final class MockSampleAPI: SampleManagementServing {
|
||||
|
||||
func setListingStatus(id: Int, listingStatus: Int) async throws {
|
||||
operationCalls.append((id, listingStatus))
|
||||
if operationDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: operationDelayNanoseconds)
|
||||
}
|
||||
if let operationError { throw operationError }
|
||||
}
|
||||
|
||||
func upload(_ request: SampleUploadMaterialRequest) async throws {
|
||||
@ -144,16 +315,22 @@ private final class MockSampleAPI: SampleManagementServing {
|
||||
}
|
||||
|
||||
func projectList(scenicId: String, name: String?, page: Int, pageSize: Int) async throws -> SampleProjectListResponse {
|
||||
projectResponse
|
||||
projectCalls.append((scenicId, page, pageSize))
|
||||
guard !projectResponses.isEmpty else { return SampleProjectListResponse() }
|
||||
return projectResponses.removeFirst()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MockSampleUploader: SampleOSSUploading {
|
||||
private var urls: [String]
|
||||
private var results: [Result<String, Error>]
|
||||
|
||||
init(urls: [String]) {
|
||||
self.urls = urls
|
||||
results = urls.map(Result.success)
|
||||
}
|
||||
|
||||
init(results: [Result<String, Error>]) {
|
||||
self.results = results
|
||||
}
|
||||
|
||||
func uploadSampleFile(
|
||||
@ -164,7 +341,8 @@ private final class MockSampleUploader: SampleOSSUploading {
|
||||
onProgress: @escaping (Int) -> Void
|
||||
) async throws -> String {
|
||||
onProgress(100)
|
||||
return urls.isEmpty ? "https://cdn/default.jpg" : urls.removeFirst()
|
||||
guard !results.isEmpty else { return "https://cdn/default.jpg" }
|
||||
return try results.removeFirst().get()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user