mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-22 11:51:23 +08:00
- 修复书源文件选择、访问权限与导入预览流程,支持查看分组内的书源 - 修复背景图片、字体选择与主题切换,限制背景图片解码尺寸 - 根据已保存书签恢复当前位置状态,避免重复添加 - 补充模型、阅读器和文件选择回归测试及测试样本 - 更新应用版本为 0.1.10(build 16) Fixes #1
292 lines
16 KiB
Swift
292 lines
16 KiB
Swift
import ImageIO
|
||
import SwiftUI
|
||
import UIKit
|
||
import XCTest
|
||
@testable import Legado
|
||
|
||
private final class BackgroundPhotoProvider: NSItemProvider, @unchecked Sendable {
|
||
let photoData: Data?
|
||
let photoError: (any Error)?
|
||
|
||
init(data: Data?, error: (any Error)? = nil) {
|
||
photoData = data
|
||
photoError = error
|
||
super.init()
|
||
}
|
||
|
||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||
|
||
override var registeredTypeIdentifiers: [String] { ["public.png"] }
|
||
|
||
override func loadDataRepresentation(
|
||
forTypeIdentifier typeIdentifier: String,
|
||
completionHandler: @escaping @Sendable (Data?, (any Error)?) -> Void
|
||
) -> Progress {
|
||
DispatchQueue.global(qos: .userInitiated).async { [photoData, photoError] in
|
||
dispatchPrecondition(condition: .notOnQueue(.main))
|
||
completionHandler(photoData, photoError)
|
||
}
|
||
return Progress(totalUnitCount: 1)
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
final class ReadingPhotoPickerTests: XCTestCase {
|
||
func testPhotoDataReturnedOnBackgroundQueueReachesMainActor() async throws {
|
||
let expected = Data("photo-provider-data".utf8)
|
||
let provider = BackgroundPhotoProvider(data: expected)
|
||
let received = expectation(description: "Photo data delivered on main actor")
|
||
let coordinator = ReadingPhotoPickerController.Coordinator { result in
|
||
XCTAssertTrue(Thread.isMainThread)
|
||
XCTAssertEqual(try? result?.get(), expected)
|
||
received.fulfill()
|
||
}
|
||
|
||
coordinator.loadImage(from: provider)
|
||
await fulfillment(of: [received], timeout: 5)
|
||
withExtendedLifetime(coordinator) {}
|
||
}
|
||
|
||
func testPhotoProviderFailureOnBackgroundQueueReachesMainActor() async throws {
|
||
let errors: [(any Error)?] = [CocoaError(.fileReadNoPermission), nil]
|
||
for error in errors {
|
||
let provider = BackgroundPhotoProvider(data: nil, error: error)
|
||
let received = expectation(description: "Photo error delivered on main actor")
|
||
let coordinator = ReadingPhotoPickerController.Coordinator { result in
|
||
XCTAssertTrue(Thread.isMainThread)
|
||
guard case .failure(let failure)? = result else {
|
||
XCTFail("Expected an import error")
|
||
received.fulfill()
|
||
return
|
||
}
|
||
XCTAssertEqual((failure as NSError).domain, NSCocoaErrorDomain)
|
||
XCTAssertEqual((failure as NSError).code, (error as NSError?)?.code ?? NSFileReadCorruptFileError)
|
||
received.fulfill()
|
||
}
|
||
|
||
coordinator.loadImage(from: provider)
|
||
await fulfillment(of: [received], timeout: 5)
|
||
withExtendedLifetime(coordinator) {}
|
||
}
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
final class ReaderBackgroundStoreTests: XCTestCase {
|
||
func testLargeBackgroundIsDownsampledOnImportAndLegacyReload() throws {
|
||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||
defer { try? FileManager.default.removeItem(at: directory) }
|
||
let target = directory.appendingPathComponent("background.jpg")
|
||
let format = UIGraphicsImageRendererFormat()
|
||
format.scale = 1
|
||
let data = UIGraphicsImageRenderer(size: CGSize(width: 4096, height: 3072), format: format).jpegData(withCompressionQuality: 0.9) { context in
|
||
UIColor.red.setFill()
|
||
context.fill(CGRect(x: 0, y: 0, width: 4096, height: 3072))
|
||
}
|
||
|
||
for _ in 0..<3 {
|
||
let imported = try ReaderBackgroundStore.save(data, to: target)
|
||
XCTAssertEqual(imported.size.width, CGFloat(ReaderBackgroundStore.maximumPixelSize))
|
||
XCTAssertEqual(imported.size.height / imported.size.width, 0.75, accuracy: 0.001)
|
||
let restored = try XCTUnwrap(ReaderBackgroundStore.load(from: target))
|
||
XCTAssertEqual(restored.size, imported.size)
|
||
}
|
||
try data.write(to: target)
|
||
let legacy = try XCTUnwrap(ReaderBackgroundStore.load(from: target))
|
||
XCTAssertEqual(legacy.size.width, CGFloat(ReaderBackgroundStore.maximumPixelSize))
|
||
}
|
||
|
||
func testCorruptReplacementPreservesExistingBackground() throws {
|
||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||
defer { try? FileManager.default.removeItem(at: directory) }
|
||
let target = directory.appendingPathComponent("background.jpg")
|
||
let image = UIGraphicsImageRenderer(size: CGSize(width: 40, height: 60)).image { context in
|
||
UIColor.blue.setFill()
|
||
context.fill(CGRect(x: 0, y: 0, width: 40, height: 60))
|
||
}
|
||
_ = try ReaderBackgroundStore.save(try XCTUnwrap(image.pngData()), to: target)
|
||
let saved = try Data(contentsOf: target)
|
||
|
||
XCTAssertThrowsError(try ReaderBackgroundStore.save(Data("not an image".utf8), to: target))
|
||
XCTAssertEqual(try Data(contentsOf: target), saved)
|
||
XCTAssertNotNil(ReaderBackgroundStore.load(from: target))
|
||
}
|
||
|
||
func testBackgroundPreservesPhotoOrientation() throws {
|
||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||
defer { try? FileManager.default.removeItem(at: directory) }
|
||
let image = UIGraphicsImageRenderer(size: CGSize(width: 120, height: 60)).image { context in
|
||
UIColor.green.setFill()
|
||
context.fill(CGRect(x: 0, y: 0, width: 120, height: 60))
|
||
}
|
||
let data = NSMutableData()
|
||
let destination = try XCTUnwrap(CGImageDestinationCreateWithData(data, "public.jpeg" as CFString, 1, nil))
|
||
CGImageDestinationAddImage(destination, try XCTUnwrap(image.cgImage), [kCGImagePropertyOrientation: 6] as CFDictionary)
|
||
XCTAssertTrue(CGImageDestinationFinalize(destination))
|
||
|
||
let saved = try ReaderBackgroundStore.save(data as Data, to: directory.appendingPathComponent("background.jpg"))
|
||
XCTAssertEqual(saved.imageOrientation, .up)
|
||
XCTAssertEqual(saved.size.height / saved.size.width, 2, accuracy: 0.001)
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
final class ReaderBookmarkPersistenceTests: XCTestCase {
|
||
func testBookmarkSurvivesNewActivityAndReaderInstances() async throws {
|
||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||
defer { try? FileManager.default.removeItem(at: directory) }
|
||
let repository = FileReadingActivityRepository(
|
||
bookmarksURL: directory.appendingPathComponent("bookmarks.json"),
|
||
recordsURL: directory.appendingPathComponent("records.json")
|
||
)
|
||
let book = Book(bookUrl: "demo://saved-bookmark", name: "书", author: "作者")
|
||
let chapters = [BookChapter(url: "demo://chapter", title: "第一章", bookUrl: book.bookUrl, index: 0)]
|
||
let original = ReadingActivityModel(repository: repository, deviceID: "test")
|
||
await original.load()
|
||
await original.addBookmark(book: book, chapter: chapters[0], position: 40, content: String(repeating: "正文", count: 100))
|
||
XCTAssertNil(original.errorMessage)
|
||
|
||
let restored = ReadingActivityModel(repository: repository, deviceID: "test")
|
||
await restored.load()
|
||
let reader = ReaderViewModel(demoBook: book, chapters: chapters, initialIndex: 0, content: "正文")
|
||
XCTAssertEqual(reader.bookmarks(at: 40, in: restored.bookmarks).count, 1)
|
||
XCTAssertTrue(reader.bookmarks(at: 80, in: restored.bookmarks).isEmpty)
|
||
await restored.removeBookmark(try XCTUnwrap(restored.bookmarks.first))
|
||
XCTAssertTrue(reader.bookmarks(at: 40, in: restored.bookmarks).isEmpty)
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
final class ReaderScrollGeometryTests: XCTestCase {
|
||
func testPrependingAndRecyclingStayWithinOnePhysicalPixel() async throws {
|
||
let chapters = (4...6).map { chapter($0) }
|
||
let fixture = host(chapters: chapters, active: 5)
|
||
defer { fixture.coordinator.displayLink?.invalidate(); fixture.window.isHidden = true }
|
||
let coordinator = fixture.coordinator, view = fixture.view
|
||
let layout = view.collectionViewLayout as! ReaderContinuousLayout
|
||
let row = try XCTUnwrap(coordinator.blocks.firstIndex { $0.chapter.chapter.id == 5 })
|
||
let id = coordinator.blocks[row].id
|
||
view.setContentOffset(CGPoint(x: 0, y: layout.frames[row].minY + 71.25), animated: false)
|
||
let screenY = layout.frames[row].minY - view.contentOffset.y
|
||
let chapterLayout = coordinator.blocks[row].chapter
|
||
|
||
coordinator.parent = surface(chapters: [chapter(2), chapter(3)] + chapters, active: 5)
|
||
coordinator.update(view)
|
||
let insertedRow = try XCTUnwrap(coordinator.blocks.firstIndex { $0.id == id })
|
||
XCTAssertEqual(layout.frames[insertedRow].minY - view.contentOffset.y, screenY, accuracy: 1 / fixture.window.screen.scale)
|
||
XCTAssertTrue(coordinator.blocks[insertedRow].chapter === chapterLayout)
|
||
|
||
coordinator.parent = surface(chapters: [chapter(5), chapter(6), chapter(7)], active: 5)
|
||
coordinator.update(view)
|
||
let recycledRow = try XCTUnwrap(coordinator.blocks.firstIndex { $0.id == id })
|
||
XCTAssertEqual(layout.frames[recycledRow].minY - view.contentOffset.y, screenY, accuracy: 1 / fixture.window.screen.scale)
|
||
XCTAssertTrue(coordinator.blocks[recycledRow].chapter === chapterLayout)
|
||
}
|
||
|
||
func testAppendingAndNaturalChapterChangesDoNotRebuildVisibleText() async throws {
|
||
let chapters = (0...2).map { chapter($0) }
|
||
let fixture = host(chapters: chapters, active: 0)
|
||
defer { fixture.coordinator.displayLink?.invalidate(); fixture.window.isHidden = true }
|
||
let coordinator = fixture.coordinator, view = fixture.view
|
||
let original = try XCTUnwrap(coordinator.blocks.first?.chapter)
|
||
view.setContentOffset(CGPoint(x: 0, y: 123.75), animated: false)
|
||
let offset = view.contentOffset
|
||
coordinator.parent = surface(chapters: chapters + [chapter(3), chapter(4)], active: 1)
|
||
coordinator.update(view)
|
||
XCTAssertEqual(view.contentOffset.y, offset.y, accuracy: 0.01)
|
||
XCTAssertTrue(coordinator.blocks[0].chapter === original)
|
||
XCTAssertEqual(Set(coordinator.blocks.map { $0.chapter.chapter.id }), Set(0...4))
|
||
}
|
||
|
||
func testReflowKeepsTheSameTextLineAtTheSameScreenPosition() async throws {
|
||
let fixture = host(chapters: [chapter(0), chapter(1)], active: 0)
|
||
defer { fixture.coordinator.displayLink?.invalidate(); fixture.window.isHidden = true }
|
||
let coordinator = fixture.coordinator, view = fixture.view
|
||
let old = try XCTUnwrap(coordinator.blocks.first?.chapter)
|
||
let glyph = old.manager.glyphIndexForCharacter(at: old.bodyStart + 200)
|
||
let lineY = old.manager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil).minY
|
||
let startOfLine = old.manager.characterIndexForGlyph(at: old.manager.glyphIndex(
|
||
for: CGPoint(x: 0, y: lineY + 0.5), in: old.container))
|
||
let layout = view.collectionViewLayout as! ReaderContinuousLayout
|
||
let chapterY = layout.frames[0].minY
|
||
view.setContentOffset(CGPoint(x: 0, y: chapterY + lineY + 3.25), animated: false)
|
||
let previousScreenY = chapterY + lineY - view.contentOffset.y
|
||
coordinator.parent = surface(chapters: [chapter(0), chapter(1)], active: 0, fontSize: 25)
|
||
coordinator.update(view)
|
||
let new = try XCTUnwrap(coordinator.blocks.first?.chapter)
|
||
let newGlyph = new.manager.glyphIndexForCharacter(at: startOfLine)
|
||
let newLineY = new.manager.lineFragmentRect(forGlyphAt: newGlyph, effectiveRange: nil).minY
|
||
XCTAssertEqual(layout.frames[0].minY + newLineY - view.contentOffset.y, previousScreenY, accuracy: 1 / fixture.window.screen.scale)
|
||
}
|
||
|
||
func testChunksCoverAllGlyphsAndDoNotRetainTheirChapter() async {
|
||
weak var released: ReaderScrollChapterLayout?
|
||
autoreleasepool {
|
||
let layout = ReaderScrollChapterLayout(chapter: chapter(0), configuration: configuration(), width: 390)
|
||
released = layout
|
||
XCTAssertGreaterThan(layout.blocks.count, 2)
|
||
var nextGlyph = 0
|
||
for block in layout.blocks {
|
||
XCTAssertEqual(block.glyphRange.location, nextGlyph)
|
||
XCTAssertGreaterThan(block.height, 0)
|
||
nextGlyph = NSMaxRange(block.glyphRange)
|
||
}
|
||
XCTAssertEqual(nextGlyph, layout.manager.numberOfGlyphs)
|
||
XCTAssertTrue(layout.storage.string.contains("👨👩👧👦"))
|
||
}
|
||
XCTAssertNil(released, "Recycled TextKit chapter layouts must be released")
|
||
}
|
||
|
||
func testShortLastChapterCanReachTheTopWithoutChangingTheRequestedChapter() async throws {
|
||
let short = ReaderScrollChapter(id: 8, title: "短章", content: "仅一行正文")
|
||
let fixture = host(chapters: [chapter(7), short], active: 8)
|
||
defer { fixture.coordinator.displayLink?.invalidate(); fixture.window.isHidden = true }
|
||
let layout = fixture.view.collectionViewLayout as! ReaderContinuousLayout
|
||
let row = try XCTUnwrap(fixture.coordinator.blocks.firstIndex { $0.chapter.chapter.id == 8 })
|
||
XCTAssertEqual(fixture.view.contentOffset.y, layout.frames[row].minY, accuracy: 1 / fixture.window.screen.scale)
|
||
}
|
||
|
||
private func chapter(_ index: Int) -> ReaderScrollChapter {
|
||
.init(id: index, title: "第\(index)章", content: String(repeating: "正文包含中文、英文 words 与家庭表情👨👩👧👦,用于验证连续阅读的排版和位置。\n", count: 100))
|
||
}
|
||
|
||
private func host(chapters: [ReaderScrollChapter], active: Int) -> (window: UIWindow, view: ReaderScrollCollectionView, coordinator: ReaderScrollView.Coordinator) {
|
||
let parent = surface(chapters: chapters, active: active)
|
||
let coordinator = parent.makeCoordinator()
|
||
let view = coordinator.makeView()
|
||
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
|
||
let controller = UIViewController()
|
||
controller.view.addSubview(view)
|
||
view.frame = window.bounds
|
||
window.rootViewController = controller
|
||
window.makeKeyAndVisible()
|
||
coordinator.update(view)
|
||
view.layoutIfNeeded()
|
||
return (window, view, coordinator)
|
||
}
|
||
|
||
private func surface(chapters: [ReaderScrollChapter], active: Int, fontSize: Double = 20) -> ReaderScrollView {
|
||
.init(configuration: configuration(fontSize: fontSize), chapters: chapters,
|
||
resetID: 0, activeIndex: active, loadingAhead: false, loadingBehind: false,
|
||
onNeedChapters: { _ in }, onPositionChanged: { _, _ in },
|
||
onRetainChapters: { _ in })
|
||
}
|
||
|
||
private func configuration(fontSize: Double = 20) -> ReaderTextView {
|
||
.init(text: "", title: "", previousTitle: nil, previousText: "", isPreviousReady: false,
|
||
continuationTitle: nil, continuationText: "", isContinuationReady: false, isVolume: false,
|
||
fontSize: fontSize, fontPath: "", letterSpacing: 0, lineSpacing: 8, paragraphSpacing: 12,
|
||
paragraphIndent: " ", paddingLeft: 20, paddingRight: 20, paddingTop: 16, paddingBottom: 16,
|
||
headerInset: 0, footerInset: 0, fontWeight: 0, titleMode: 0, titleSize: 4,
|
||
titleTopSpacing: 16, titleBottomSpacing: 24, underlineMode: 0, isSelectable: false,
|
||
fullJustify: true, pageAnimation: 3, isEInkMode: false, clickActions: Array(repeating: 0, count: 9),
|
||
backgroundColor: .white, foregroundColor: .black, requestedPosition: 0, jumpPosition: nil,
|
||
renderRevision: 0, readerPosition: .constant(0), pageRanges: .constant([]),
|
||
currentPage: .constant(1), totalPages: .constant(1), isAutoReading: false,
|
||
isAutoReadPaused: false, isLoadingNextChapter: false, secondsPerScreen: 10,
|
||
onReachedEnd: { _ in }, onReachedStart: { _ in }, onEnteredPrevious: { _ in },
|
||
onEnteredContinuation: { _ in }, onDeferredRender: {}, onCancelNextLoading: {},
|
||
onPageChanged: {}, onTapAction: { _ in })
|
||
}
|
||
}
|