同步相册云盘界面并完善相关交互
This commit is contained in:
@ -9,12 +9,47 @@ import XCTest
|
||||
@MainActor
|
||||
/// 相册云盘 ViewModel 测试。
|
||||
final class CloudDriveViewModelTests: XCTestCase {
|
||||
func testDisplayModeToggleSwitchesBetweenGridAndListAndNotifiesUI() {
|
||||
let viewModel = CloudDriveListViewModel()
|
||||
var notificationCount = 0
|
||||
viewModel.onStateChange = { notificationCount += 1 }
|
||||
|
||||
viewModel.toggleDisplayMode()
|
||||
XCTAssertEqual(viewModel.displayMode, .list)
|
||||
|
||||
viewModel.toggleDisplayMode()
|
||||
XCTAssertEqual(viewModel.displayMode, .grid)
|
||||
XCTAssertEqual(notificationCount, 2)
|
||||
}
|
||||
|
||||
func testListFileDescriptionMatchesAndroidDateSizeAndFolderCount() {
|
||||
let image = CloudFile(
|
||||
id: 1,
|
||||
name: "海边.jpg",
|
||||
createdAt: "2026-07-10 18:32:15",
|
||||
type: 2,
|
||||
fileSize: 2_048
|
||||
)
|
||||
let folder = CloudFile(
|
||||
id: 2,
|
||||
name: "旅行",
|
||||
createdAt: "2026-07-09 09:08:07",
|
||||
childNum: 3,
|
||||
type: 99
|
||||
)
|
||||
|
||||
XCTAssertEqual(CloudDriveFormatter.fileDescription(image), "2026-07-10 | 2.00 KB")
|
||||
XCTAssertEqual(CloudDriveFormatter.fileDescription(folder), "2026-07-09 | 3个项目")
|
||||
XCTAssertEqual(CloudDriveFormatter.fileSize(0), "0 B")
|
||||
}
|
||||
|
||||
func testLoadMoreSearchFilterSortAndFolderNavigation() async throws {
|
||||
let api = MockCloudDriveAPI()
|
||||
api.listResponses = [
|
||||
CloudFileListResponse(total: 3, list: [file(id: 1), folder(id: 2)]),
|
||||
CloudFileListResponse(total: 3, list: [file(id: 3)]),
|
||||
CloudFileListResponse(total: 1, list: [file(id: 4)]),
|
||||
CloudFileListResponse(total: 1, list: [file(id: 5)]),
|
||||
CloudFileListResponse(total: 0, list: []),
|
||||
]
|
||||
let viewModel = CloudDriveListViewModel()
|
||||
@ -23,13 +58,16 @@ final class CloudDriveViewModelTests: XCTestCase {
|
||||
await viewModel.loadMoreIfNeeded(lastVisibleIndex: 1, api: api)
|
||||
viewModel.updateSearchText("图")
|
||||
await viewModel.chooseFilterType(.image, api: api)
|
||||
await viewModel.chooseSortType(.createdAscending, api: api)
|
||||
_ = await viewModel.handleFileTap(folder(id: 9), api: api)
|
||||
|
||||
XCTAssertEqual(viewModel.files.map(\.id), [])
|
||||
XCTAssertEqual(viewModel.pathStack.map(\.id), [0, 9])
|
||||
XCTAssertEqual(api.listCalls.map(\.page), [1, 2, 1, 1])
|
||||
XCTAssertEqual(api.listCalls.map(\.page), [1, 2, 1, 1, 1])
|
||||
XCTAssertEqual(api.listCalls[2].name, "图")
|
||||
XCTAssertEqual(api.listCalls[2].type, 2)
|
||||
XCTAssertEqual(api.listCalls[3].orderBy, 1)
|
||||
XCTAssertEqual(api.listCalls[4].name, "")
|
||||
}
|
||||
|
||||
func testBackRestoresCachedRootAndGoHome() async {
|
||||
@ -50,6 +88,58 @@ final class CloudDriveViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(api.listCalls.count, 2)
|
||||
}
|
||||
|
||||
func testRootBackReturnsFalseWithoutRequest() async {
|
||||
let api = MockCloudDriveAPI()
|
||||
let viewModel = CloudDriveListViewModel()
|
||||
|
||||
let handled = await viewModel.navigateBack(api: api)
|
||||
|
||||
XCTAssertFalse(handled)
|
||||
XCTAssertEqual(viewModel.pathStack.map(\.id), [0])
|
||||
XCTAssertTrue(api.listCalls.isEmpty)
|
||||
}
|
||||
|
||||
func testBreadcrumbAndGoHomeRestoreFolderCaches() async {
|
||||
let api = MockCloudDriveAPI()
|
||||
api.listResponses = [
|
||||
CloudFileListResponse(total: 1, list: [folder(id: 2)]),
|
||||
CloudFileListResponse(total: 1, list: [folder(id: 3)]),
|
||||
CloudFileListResponse(total: 1, list: [file(id: 9)]),
|
||||
]
|
||||
let viewModel = CloudDriveListViewModel()
|
||||
|
||||
await viewModel.refresh(api: api)
|
||||
_ = await viewModel.handleFileTap(folder(id: 2), api: api)
|
||||
_ = await viewModel.handleFileTap(folder(id: 3), api: api)
|
||||
await viewModel.navigateToPathIndex(1, api: api)
|
||||
|
||||
XCTAssertEqual(viewModel.pathStack.map(\.id), [0, 2])
|
||||
XCTAssertEqual(viewModel.files.map(\.id), [3])
|
||||
XCTAssertEqual(api.listCalls.count, 3)
|
||||
|
||||
await viewModel.goHome(api: api)
|
||||
|
||||
XCTAssertEqual(viewModel.pathStack.map(\.id), [0])
|
||||
XCTAssertEqual(viewModel.files.map(\.id), [2])
|
||||
XCTAssertEqual(api.listCalls.count, 3)
|
||||
}
|
||||
|
||||
func testFolderDepthLimitPreventsSixthLevel() async {
|
||||
let api = MockCloudDriveAPI()
|
||||
let viewModel = CloudDriveListViewModel()
|
||||
var message: String?
|
||||
viewModel.onShowMessage = { message = $0 }
|
||||
|
||||
for id in 1 ... 4 {
|
||||
_ = await viewModel.handleFileTap(folder(id: id), api: api)
|
||||
}
|
||||
await viewModel.addFolder(name: "第六层", api: api)
|
||||
|
||||
XCTAssertEqual(viewModel.pathStack.count, 5)
|
||||
XCTAssertEqual(message, "不能创建更深的文件夹")
|
||||
XCTAssertTrue(api.createFolderCalls.isEmpty)
|
||||
}
|
||||
|
||||
func testCreateDeleteMoveAndRenameRefresh() async {
|
||||
let api = MockCloudDriveAPI()
|
||||
api.listResponses = Array(repeating: CloudFileListResponse(), count: 5)
|
||||
@ -83,6 +173,38 @@ final class CloudDriveViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(message, "空间不足")
|
||||
}
|
||||
|
||||
func testEmptyFolderAndRenameNamesAreRejected() async {
|
||||
let api = MockCloudDriveAPI()
|
||||
let viewModel = CloudDriveListViewModel()
|
||||
var messages: [String] = []
|
||||
viewModel.onShowMessage = { messages.append($0) }
|
||||
|
||||
await viewModel.addFolder(name: " ", api: api)
|
||||
viewModel.setControlFile(file(id: 7))
|
||||
await viewModel.saveCurrentFileName("\n", api: api)
|
||||
|
||||
XCTAssertEqual(messages, ["名称不能为空", "名称不能为空"])
|
||||
XCTAssertTrue(api.createFolderCalls.isEmpty)
|
||||
XCTAssertTrue(api.renameFileCalls.isEmpty)
|
||||
}
|
||||
|
||||
func testUploadCompletionRefreshesOnlyCurrentFolder() async {
|
||||
let api = MockCloudDriveAPI()
|
||||
api.listResponses = [
|
||||
CloudFileListResponse(total: 1, list: [file(id: 1)]),
|
||||
CloudFileListResponse(total: 1, list: [file(id: 2)]),
|
||||
]
|
||||
let viewModel = CloudDriveListViewModel()
|
||||
|
||||
await viewModel.refresh(api: api)
|
||||
await viewModel.handleUploadCompleted(parentFolderId: 9, api: api)
|
||||
XCTAssertEqual(api.listCalls.count, 1)
|
||||
|
||||
await viewModel.handleUploadCompleted(parentFolderId: 0, api: api)
|
||||
XCTAssertEqual(api.listCalls.count, 2)
|
||||
XCTAssertEqual(viewModel.files.map(\.id), [2])
|
||||
}
|
||||
|
||||
private func file(id: Int, type: Int = 2) -> CloudFile {
|
||||
CloudFile(id: id, fileUrl: "https://cdn/\(id).jpg", name: "文件\(id)", type: type)
|
||||
}
|
||||
|
||||
@ -77,6 +77,104 @@ final class CloudTransferManagerTests: XCTestCase {
|
||||
XCTAssertTrue(manager.downloadTasks.isEmpty)
|
||||
}
|
||||
|
||||
func testUploadPauseThenResumeCompletes() async throws {
|
||||
let uploader = PausingThenSucceedCloudUploader(url: "https://cdn/resumed.jpg")
|
||||
let api = TransferMockCloudDriveAPI()
|
||||
let manager = CloudTransferManager(
|
||||
store: MemoryTransferStore(),
|
||||
uploader: uploader,
|
||||
api: api,
|
||||
downloader: FakeDownloader(),
|
||||
photoSaver: FakePhotoSaver(),
|
||||
scenicIdProvider: { 10 }
|
||||
)
|
||||
let fileURL = try makeTempFile(name: "resume.jpg")
|
||||
|
||||
manager.addUploadTask(localFileURL: fileURL, fileName: "resume.jpg", fileType: 2, fileSize: 3, parentFolderId: 7)
|
||||
let id = try XCTUnwrap(manager.uploadTasks.first?.id)
|
||||
try await waitUntil { uploader.attempts == 1 }
|
||||
manager.pauseUpload(id: id)
|
||||
try await waitUntil { manager.uploadTasks.first?.status == .paused }
|
||||
|
||||
manager.resumeUpload(id: id)
|
||||
try await waitUntil { manager.uploadTasks.isEmpty }
|
||||
|
||||
XCTAssertEqual(uploader.attempts, 2)
|
||||
XCTAssertEqual(api.uploadCalls.first?.parentFolderId, 7)
|
||||
XCTAssertEqual(api.uploadCalls.first?.fileUrl, "https://cdn/resumed.jpg")
|
||||
}
|
||||
|
||||
func testFailedUploadCanRetry() async throws {
|
||||
let uploader = FailOnceCloudUploader(url: "https://cdn/retried.jpg")
|
||||
let api = TransferMockCloudDriveAPI()
|
||||
let manager = CloudTransferManager(
|
||||
store: MemoryTransferStore(),
|
||||
uploader: uploader,
|
||||
api: api,
|
||||
downloader: FakeDownloader(),
|
||||
photoSaver: FakePhotoSaver(),
|
||||
scenicIdProvider: { 10 }
|
||||
)
|
||||
let fileURL = try makeTempFile(name: "retry.jpg")
|
||||
|
||||
manager.addUploadTask(localFileURL: fileURL, fileName: "retry.jpg", fileType: 2, fileSize: 3, parentFolderId: 0)
|
||||
try await waitUntil { manager.uploadTasks.first?.status == .failed }
|
||||
let id = try XCTUnwrap(manager.uploadTasks.first?.id)
|
||||
|
||||
manager.resumeUpload(id: id)
|
||||
try await waitUntil { manager.uploadTasks.isEmpty }
|
||||
|
||||
XCTAssertEqual(uploader.attempts, 2)
|
||||
XCTAssertEqual(api.uploadCalls.first?.fileUrl, "https://cdn/retried.jpg")
|
||||
}
|
||||
|
||||
func testUploadCancelRemovesTaskAndStagedFile() async throws {
|
||||
let uploader = PausingThenSucceedCloudUploader(url: "unused")
|
||||
let manager = CloudTransferManager(
|
||||
store: MemoryTransferStore(),
|
||||
uploader: uploader,
|
||||
api: TransferMockCloudDriveAPI(),
|
||||
downloader: FakeDownloader(),
|
||||
photoSaver: FakePhotoSaver(),
|
||||
scenicIdProvider: { 10 }
|
||||
)
|
||||
let fileURL = try makeTempFile(name: "cancel.jpg")
|
||||
|
||||
manager.addUploadTask(localFileURL: fileURL, fileName: "cancel.jpg", fileType: 2, fileSize: 3, parentFolderId: 0)
|
||||
let id = try XCTUnwrap(manager.uploadTasks.first?.id)
|
||||
manager.cancelUpload(id: id)
|
||||
|
||||
XCTAssertTrue(manager.uploadTasks.isEmpty)
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path))
|
||||
}
|
||||
|
||||
func testDownloadPauseThenResumeCompletes() async throws {
|
||||
let downloader = PausingThenSucceedDownloader()
|
||||
let saver = FakePhotoSaver()
|
||||
let manager = CloudTransferManager(
|
||||
store: MemoryTransferStore(),
|
||||
uploader: FakeCloudUploader(url: "unused"),
|
||||
api: TransferMockCloudDriveAPI(),
|
||||
downloader: downloader,
|
||||
photoSaver: saver,
|
||||
scenicIdProvider: { 10 }
|
||||
)
|
||||
|
||||
manager.addDownloadTask(fileURL: "https://cdn/resume.jpg", fileName: "resume.jpg", fileType: 2)
|
||||
let id = try XCTUnwrap(manager.downloadTasks.first?.id)
|
||||
try await waitUntil { await downloader.attemptCount() == 1 }
|
||||
manager.pauseDownload(id: id)
|
||||
try await waitUntil { manager.downloadTasks.first?.status == .paused }
|
||||
|
||||
manager.resumeDownload(id: id)
|
||||
try await waitUntil { manager.downloadTasks.first?.status == .completed }
|
||||
|
||||
let attemptCount = await downloader.attemptCount()
|
||||
XCTAssertEqual(attemptCount, 2)
|
||||
XCTAssertEqual(saver.saved.count, 1)
|
||||
XCTAssertEqual(manager.downloadTasks.first?.progress, 100)
|
||||
}
|
||||
|
||||
private func makeTempFile(name: String) throws -> URL {
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + "_" + name)
|
||||
try Data([1, 2, 3]).write(to: url)
|
||||
@ -96,6 +194,20 @@ final class CloudTransferManagerTests: XCTestCase {
|
||||
try await Task.sleep(nanoseconds: 20_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 2,
|
||||
condition: @escaping () async -> Bool
|
||||
) async throws {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while !(await condition()) {
|
||||
if Date() > deadline {
|
||||
XCTFail("Timed out waiting for condition")
|
||||
return
|
||||
}
|
||||
try await Task.sleep(nanoseconds: 20_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class MemoryTransferStore: CloudTransferTaskStoring {
|
||||
@ -130,6 +242,44 @@ private final class FakeCloudUploader: CloudDriveUploading {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class PausingThenSucceedCloudUploader: CloudDriveUploading {
|
||||
let url: String
|
||||
private(set) var attempts = 0
|
||||
|
||||
init(url: String) {
|
||||
self.url = url
|
||||
}
|
||||
|
||||
func uploadCloudDriveFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
attempts += 1
|
||||
if attempts == 1 {
|
||||
try await Task.sleep(nanoseconds: 5_000_000_000)
|
||||
}
|
||||
onProgress(100)
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class FailOnceCloudUploader: CloudDriveUploading {
|
||||
let url: String
|
||||
private(set) var attempts = 0
|
||||
|
||||
init(url: String) {
|
||||
self.url = url
|
||||
}
|
||||
|
||||
func uploadCloudDriveFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
attempts += 1
|
||||
if attempts == 1 {
|
||||
throw TestError.failed
|
||||
}
|
||||
onProgress(100)
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
private final class FakeDownloader: CloudFileDownloading {
|
||||
var downloadCalls: [(remoteURL: String, fileName: String)] = []
|
||||
|
||||
@ -143,6 +293,38 @@ private final class FakeDownloader: CloudFileDownloading {
|
||||
}
|
||||
}
|
||||
|
||||
private final class PausingThenSucceedDownloader: CloudFileDownloading {
|
||||
private let counter = DownloadAttemptCounter()
|
||||
|
||||
func downloadFile(remoteURL: String, fileName: String, onProgress: @escaping @Sendable (Int) -> Void) async throws -> URL {
|
||||
let attempt = await counter.next()
|
||||
if attempt == 1 {
|
||||
try await Task.sleep(nanoseconds: 5_000_000_000)
|
||||
}
|
||||
onProgress(100)
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + "_" + fileName)
|
||||
try Data([1]).write(to: url)
|
||||
return url
|
||||
}
|
||||
|
||||
func attemptCount() async -> Int {
|
||||
await counter.current()
|
||||
}
|
||||
}
|
||||
|
||||
private actor DownloadAttemptCounter {
|
||||
private var value = 0
|
||||
|
||||
func next() -> Int {
|
||||
value += 1
|
||||
return value
|
||||
}
|
||||
|
||||
func current() -> Int {
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
private final class FakePhotoSaver: CloudPhotoSaving {
|
||||
var saved: [(fileURL: URL, fileType: Int)] = []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user