feat: 支持 EPUB 阅读并改善规则兼容与阅读稳定性

支持 EPUB 2/3 导入、目录、封面和章节插图,保留本地书管理与阅读进度。
修复 JSON 模板、动态替换与请求参数兼容,以及 RSS 翻页和连续滚动定位。
更新编辑器工具栏和回归测试,发布版本为 0.1.12(build 18)。
This commit is contained in:
Aminark
2026-10-24 00:58:11 +08:00
parent 3322fd71db
commit 67bc212ffd
58 changed files with 2331 additions and 244 deletions
@@ -246,6 +246,54 @@ final class ReaderScrollGeometryTests: XCTestCase {
XCTAssertEqual(fixture.view.contentOffset.y, layout.frames[row].minY, accuracy: 1 / fixture.window.screen.scale)
}
func testRepeatedShortChapterPrependingDoesNotAccumulateOffsetDrift() async throws {
func short(_ index: Int) -> ReaderScrollChapter {
.init(id: index, title: "第\(index + 1)章 · 连续阅读", content: "短章正文,继续向下可以自然读到下一章。")
}
let fixture = host(chapters: (20...22).map(short), active: 20)
defer { fixture.coordinator.displayLink?.invalidate(); fixture.window.isHidden = true }
for first in stride(from: 19, through: 0, by: -1) {
fixture.coordinator.parent = surface(chapters: (first...22).map(short), active: 20)
fixture.coordinator.update(fixture.view)
try await Task.sleep(nanoseconds: 10_000_000)
}
let layout = fixture.view.collectionViewLayout as! ReaderContinuousLayout
let row = try XCTUnwrap(fixture.coordinator.blocks.firstIndex { $0.chapter.chapter.id == 20 })
XCTAssertEqual(fixture.view.contentOffset.y, layout.frames[row].minY,
accuracy: 1 / fixture.window.screen.scale)
XCTAssertTrue(fixture.view.accessibilityValue?.contains("chapter=20 ") == true,
fixture.view.accessibilityValue ?? "Missing viewport report")
}
func testFractionalPixelBoundariesBelongToTheFollowingRow() {
let layout = ReaderContinuousLayout()
let frames = (0..<100).map { CGRect(x: 0, y: CGFloat($0 * 5) / 3, width: 390, height: 5.0 / 3) }
layout.install(frames: frames, size: CGSize(width: 390, height: 500.0 / 3), correction: 0)
for index in frames.indices {
XCTAssertEqual(layout.row(at: frames[index].minY), index)
}
}
func testPrependingPreservesTheChapterBelowTopPadding() async throws {
func short(_ index: Int) -> ReaderScrollChapter {
.init(id: index, title: "短章\(index)", content: "短章正文")
}
let fixture = host(chapters: [short(20)], active: 20)
defer { fixture.coordinator.displayLink?.invalidate(); fixture.window.isHidden = true }
// A short initial chapter can settle at offset zero with its heading
// still below the reading area's top padding.
fixture.view.setContentOffset(.zero, animated: false)
fixture.coordinator.parent = surface(chapters: (19...21).map(short), active: 20)
fixture.coordinator.update(fixture.view)
try await Task.sleep(nanoseconds: 10_000_000)
let layout = fixture.view.collectionViewLayout as! ReaderContinuousLayout
let row = try XCTUnwrap(fixture.coordinator.blocks.firstIndex { $0.chapter.chapter.id == 20 })
XCTAssertEqual(layout.frames[row].minY - fixture.view.contentOffset.y, 16,
accuracy: 1 / fixture.window.screen.scale)
XCTAssertTrue(fixture.view.accessibilityValue?.contains("chapter=20 ") == true,
fixture.view.accessibilityValue ?? "Missing viewport report")
}
private func chapter(_ index: Int) -> ReaderScrollChapter {
.init(id: index, title: "第\(index)章", content: String(repeating: "正文包含中文、英文 words 与家庭表情👨‍👩‍👧‍👦,用于验证连续阅读的排版和位置。\n", count: 100))
}
@@ -88,3 +88,159 @@ final class WebReaderBridgeTests: XCTestCase {
XCTAssertEqual(result as? String, "已保存|规则正文")
}
}
private actor RSSPaginationTransport: HTTPTransport {
private var requests: [URL] = []
private var shouldFailSecondPage = true
func data(for request: URLRequest) async throws -> HTTPResponse {
let url = try XCTUnwrap(request.url)
requests.append(url)
if url.host == "unavailable.test" { throw URLError(.cannotConnectToHost) }
let page = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?
.first { $0.name == "page" }?.value ?? "1"
if page == "2", shouldFailSecondPage {
shouldFailSecondPage = false
throw URLError(.networkConnectionLost)
}
let body = "{\"items\":[{\"title\":\"Page \(page)\",\"link\":\"https://feed.test/article/\(page)\"}]}"
return .init(data: Data(body.utf8), statusCode: 200, headers: [:], finalURL: url)
}
func requestedPages() -> [String] {
requests.compactMap { URLComponents(url: $0, resolvingAgainstBaseURL: false)?.queryItems?.first { $0.name == "page" }?.value }
}
func requestCount() -> Int { requests.count }
}
private actor RSSDelayedTransport: HTTPTransport {
let pausedHost: String
let pausedPage: String
private var requestTotal = 0
private var pending: CheckedContinuation<Void, Never>?
init(host: String, page: String) { pausedHost = host; pausedPage = page }
func data(for request: URLRequest) async throws -> HTTPResponse {
let url = try XCTUnwrap(request.url)
let page = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?
.first { $0.name == "page" }?.value ?? "1"
requestTotal += 1
if url.host == pausedHost, page == pausedPage {
await withCheckedContinuation { continuation in
pending = continuation
}
}
let body = "{\"items\":[{\"title\":\"Page \(page)\",\"link\":\"https://feed.test/article/\(page)\"}]}"
return .init(data: Data(body.utf8), statusCode: 200, headers: [:], finalURL: url)
}
func waitUntilPaused() async throws {
for _ in 0..<500 {
if pending != nil { return }
try await Task.sleep(nanoseconds: 10_000_000)
}
throw URLError(.timedOut)
}
func resume() { pending?.resume(); pending = nil }
func requestCount() -> Int { requestTotal }
}
@MainActor
final class RSSLibraryRegressionTests: XCTestCase {
func testUnavailableDatabaseDoesNotCrashOrPublishUnsavedImports() async throws {
let blocked = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try Data("existing file".utf8).write(to: blocked)
defer { try? FileManager.default.removeItem(at: blocked) }
let model = RSSLibraryModel.live(databaseURL: blocked.appendingPathComponent("rss.sqlite3"))
XCTAssertNotNil(model.errorMessage)
await model.load()
XCTAssertNotNil(model.errorMessage)
let saved = await model.save(source(), replacing: nil)
XCTAssertFalse(saved)
await model.importText(#"{"sourceUrl":"https://feed.test","sourceName":"未保存的订阅"}"#)
XCTAssertTrue(model.sources.isEmpty)
XCTAssertFalse(model.isWorking)
XCTAssertEqual(try Data(contentsOf: blocked), Data("existing file".utf8))
}
private func source(host: String = "feed.test") -> RssSource {
var source = RssSource(sourceUrl: "https://\(host)", sourceName: host)
source.sortUrl = "https://\(host)/list?page={{page}}"
source.ruleArticles = "$.items"
source.ruleTitle = "$.title"
source.ruleLink = "$.link"
source.ruleNextPage = "PAGE"
return source
}
func testFailedNextPageRetriesTheSamePageWithoutSkippingArticles() async throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let database = try LegadoDatabase(url: directory.appendingPathComponent("rss.sqlite3"))
let transport = RSSPaginationTransport()
let model = RSSLibraryModel(database: database, service: RSSService(transport: transport))
let feed = source()
await model.refresh(feed)
XCTAssertEqual(model.articles.map(\.title), ["Page 1"])
await model.loadMore(feed)
XCTAssertNotNil(model.errorMessage)
XCTAssertEqual(model.articles.map(\.title), ["Page 1"])
await model.loadMore(feed)
let pages = await transport.requestedPages()
XCTAssertEqual(pages, ["1", "2", "2"])
XCTAssertEqual(model.articles.map(\.title), ["Page 1", "Page 2"])
}
func testFailedSourceChangeDoesNotReusePreviousSourcesArticlesOrNextPage() async throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let database = try LegadoDatabase(url: directory.appendingPathComponent("rss.sqlite3"))
let transport = RSSPaginationTransport()
let model = RSSLibraryModel(database: database, service: RSSService(transport: transport))
await model.refresh(source())
let unavailable = source(host: "unavailable.test")
await model.refresh(unavailable)
XCTAssertTrue(model.articles.isEmpty)
let count = await transport.requestCount()
await model.loadMore(unavailable)
let afterLoadMore = await transport.requestCount()
XCTAssertEqual(afterLoadMore, count)
}
func testSlowPreviousSourceCannotReplaceTheCurrentFeed() async throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let database = try LegadoDatabase(url: directory.appendingPathComponent("rss.sqlite3"))
let transport = RSSDelayedTransport(host: "slow.test", page: "1")
let model = RSSLibraryModel(database: database, service: RSSService(transport: transport))
let slow = Task { await model.refresh(source(host: "slow.test")) }
try await transport.waitUntilPaused()
await model.refresh(source())
XCTAssertTrue(model.isWorking)
await transport.resume()
await slow.value
XCTAssertEqual(model.articles.map(\.origin), ["https://feed.test"])
XCTAssertFalse(model.isWorking)
}
func testRepeatedLoadMoreWhileWaitingRequestsOnlyOnePage() async throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let database = try LegadoDatabase(url: directory.appendingPathComponent("rss.sqlite3"))
let transport = RSSDelayedTransport(host: "feed.test", page: "2")
let model = RSSLibraryModel(database: database, service: RSSService(transport: transport))
let feed = source()
await model.refresh(feed)
let pending = Task { await model.loadMore(feed) }
try await transport.waitUntilPaused()
await model.loadMore(feed)
let count = await transport.requestCount()
XCTAssertEqual(count, 2)
await transport.resume()
await pending.value
XCTAssertEqual(model.articles.map(\.title), ["Page 1", "Page 2"])
}
}