Files
legado-ios/Tests/LegadoAppModelsTests/LocalFileManagementTests.swift
T
Aminark bf72ebc894 fix(import): 修复本地 TXT 文件选择与导入
- 书架和文件管理统一使用 TXT 文件选择器,支持大小写扩展名及多选,排除 JSON 书源
- 保留所选文件访问权限至异步导入完成,等待选择器关闭后展示导入确认
- 补充双入口文件选择回归、导入校验和 TXT 测试样本
2026-10-22 10:51:09 +08:00

143 lines
7.5 KiB
Swift

import Foundation
import Testing
import LegadoCore
@testable import LegadoAppModels
@MainActor
struct LocalFileManagementTests {
@Test func pickedTXTImportsButJSONFilesCannotBecomeLocalBooks() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let inputs = [
("小说.TXT", "第一章 开始\n本地小说的正文"),
("书源.json", #"{"bookSourceName":"书源"}"#),
("大写书源.JSON", #"{"bookSourceName":"书源"}"#)
]
let files = try inputs.map { name, text in
let url = root.appendingPathComponent(name)
try Data(text.utf8).write(to: url)
return LocalBookImportFile(url: url)
}
let model = BookLibraryModel(repository: InMemoryBookRepository(),
localDirectory: root.appendingPathComponent("managed"),
chapterRepository: InMemoryBookChapterRepository())
await model.load()
await model.importPickedLocalFiles(files)
let book = try #require(model.books.first)
#expect(model.books.count == 1)
#expect(book.origin == "local")
#expect(book.originName == "小说.TXT")
#expect(model.errorMessage?.contains("书源.json") == true)
#expect(model.errorMessage?.contains("大写书源.JSON") == true)
#expect(try await model.localFiles.list().count == 1)
let imported = try #require(URL(string: book.bookUrl))
#expect(try Data(contentsOf: imported) == Data(inputs[0].1.utf8))
}
private func fixture(fails: Bool = false) async throws -> (URL, Book, BookLibraryModel) {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
let file = root.appendingPathComponent("原文件.txt")
try "第一章 开始\n正文".write(to: file, atomically: true, encoding: .utf8)
var book = Book(bookUrl: file.absoluteString, name: "原书名", origin: "local")
book.originName = file.lastPathComponent
book.durChapterIndex = 2
book.durChapterTitle = "第三章"
book.durChapterPos = 42
book.group = 8
let repository: any BookRepository = fails ? FileOperationFailingRepository(books: [book]) : InMemoryBookRepository(books: [book])
let model = BookLibraryModel(repository: repository, localDirectory: root, chapterRepository: InMemoryBookChapterRepository())
await model.load()
return (root, book, model)
}
@Test func renamingFileRetargetsShelfAndPreservesTitleAndReadingState() async throws {
let (root, original, model) = try await fixture()
defer { try? FileManager.default.removeItem(at: root) }
try await model.renameLocalFile(at: URL(string: original.bookUrl)!, to: "新文件")
let book = try #require(model.books.first)
#expect(book.bookUrl == root.appendingPathComponent("新文件.txt").absoluteString)
#expect(book.originName == "新文件.txt")
#expect(book.name == original.name)
#expect(book.durChapterIndex == 2 && book.durChapterPos == 42 && book.group == 8)
#expect(FileManager.default.fileExists(atPath: URL(string: book.bookUrl)!.path))
#expect(!FileManager.default.fileExists(atPath: URL(string: original.bookUrl)!.path))
}
@Test func failedShelfSaveRestoresRenamedFileAndShelf() async throws {
let (root, book, model) = try await fixture(fails: true)
defer { try? FileManager.default.removeItem(at: root) }
await #expect(throws: (any Error).self) { try await model.renameLocalFile(at: URL(string: book.bookUrl)!, to: "新文件") }
#expect(model.books == [book])
#expect(FileManager.default.fileExists(atPath: URL(string: book.bookUrl)!.path))
#expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent("新文件.txt").path))
}
@Test func failedShelfSaveRestoresDeletedFileBytes() async throws {
let (root, book, model) = try await fixture(fails: true)
defer { try? FileManager.default.removeItem(at: root) }
let file = URL(string: book.bookUrl)!
let data = try Data(contentsOf: file)
await #expect(throws: (any Error).self) { try await model.deleteLocalFile(at: file) }
#expect(model.books == [book])
#expect(try Data(contentsOf: file) == data)
}
@Test func deletingFileAlsoRemovesShelfReference() async throws {
let (root, book, model) = try await fixture()
defer { try? FileManager.default.removeItem(at: root) }
let file = URL(string: book.bookUrl)!
try await model.deleteLocalFile(at: file)
#expect(model.books.isEmpty)
#expect(!FileManager.default.fileExists(atPath: file.path))
#expect(try await model.localFiles.list().isEmpty)
}
@Test func mixedImportKeepsSuccessfulFilesAndReportsUnsupportedOnes() async throws {
let (root, original, model) = try await fixture()
defer { try? FileManager.default.removeItem(at: root) }
let source = root.appendingPathComponent("原文件.txt")
let unsupported = root.appendingPathComponent("unsupported.pdf")
try Data("pdf".utf8).write(to: unsupported)
await model.importLocalFiles([source, unsupported, source])
#expect(model.books.count == 3)
#expect(model.books.first == original)
#expect(model.errorMessage?.contains("unsupported.pdf") == true)
#expect(try await model.localFiles.list().count == 3)
}
@Test func missingFileFromOldContainerCanBeRemovedFromShelf() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
let missing = root.appendingPathComponent("old-container/missing.txt")
let book = Book(bookUrl: missing.absoluteString, name: "丢失的书", origin: "local")
let model = BookLibraryModel(repository: InMemoryBookRepository(books: [book]), localDirectory: root.appendingPathComponent("current-container"))
await model.load()
try await model.deleteLocalFile(at: missing)
#expect(model.books.isEmpty)
#expect(!FileManager.default.fileExists(atPath: root.path))
}
@Test func existingFileOutsideLibraryCannotBeDeletedThroughShelf() async throws {
let (root, _, _) = try await fixture()
defer { try? FileManager.default.removeItem(at: root) }
let external = root.appendingPathComponent("原文件.txt")
let bytes = try Data(contentsOf: external)
let book = Book(bookUrl: external.absoluteString, name: "外部文件", origin: "local")
let model = BookLibraryModel(repository: InMemoryBookRepository(books: [book]), localDirectory: root.appendingPathComponent("managed"))
await model.load()
await #expect(throws: LocalFileError.outsideLibrary) { try await model.deleteLocalFile(at: external) }
#expect(model.books == [book])
#expect(try Data(contentsOf: external) == bytes)
}
}
private actor FileOperationFailingRepository: BookRepository {
let books: [Book]
init(books: [Book]) { self.books = books }
func load() -> [Book] { books }
func save(_ books: [Book]) throws { throw CocoaError(.fileWriteOutOfSpace) }
}