Files
legado-ios/Tests/LegadoReaderTests/ReaderScrollGeometryTests.swift
T
Aminark 0915a144a7 fix: 修复书源导入、阅读外观和书签状态
- 修复书源文件选择、访问权限与导入预览流程,支持查看分组内的书源
- 修复背景图片、字体选择与主题切换,限制背景图片解码尺寸
- 根据已保存书签恢复当前位置状态,避免重复添加
- 补充模型、阅读器和文件选择回归测试及测试样本
- 更新应用版本为 0.1.10(build 16)

Fixes #1
2026-10-20 23:30:42 +08:00

292 lines
16 KiB
Swift
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 })
}
}