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
@@ -80,11 +80,14 @@ final class DarkModeUITests: XCTestCase {
let keyboard = try luminances(app.keyboards.firstMatch, in: app)
XCTAssertLessThan(keyboard[keyboard.count / 2], 0.15)
}
let accessory = app.buttons["完成"]
let accessoryFrame = accessory.frame.intersection(app.frame)
if accessory.isHittable && !accessoryFrame.isNull && accessoryFrame.width > 20 && accessoryFrame.height > 20 {
try assertReadableDarkFill(accessory, in: app)
}
let accessory = app.buttons["syntaxCodeEditor.done"]
XCTAssertTrue(accessory.exists && accessory.isHittable)
try assertReadableDarkFill(accessory, in: app)
app.buttons["@"].tap()
XCTAssertTrue((app.textViews["syntaxCodeEditor"].value as? String)?.contains("@") == true)
accessory.tap()
let dismissed = expectation(for: NSPredicate { _, _ in !accessory.exists || !accessory.isHittable }, evaluatedWith: nil)
wait(for: [dismissed], timeout: 5)
}
func testDarkSettingsRulesAndManagementPages() throws {
@@ -146,8 +149,8 @@ final class DarkModeUITests: XCTestCase {
let app = launch(["-uiDemoTab", "search", "-themeMode", "2", "-uiAutomationSearch", "深色外观检查"])
let cancelSearch = app.buttons.matching(NSPredicate(format: "label IN %@", ["取消", "Cancel"])).firstMatch
if cancelSearch.waitForExistence(timeout: 3) { cancelSearch.tap() }
XCTAssertTrue(app.buttons["搜索选项"].waitForExistence(timeout: 10))
app.buttons["搜索选项"].tap()
XCTAssertTrue(app.buttons["search.options"].waitForExistence(timeout: 10))
app.buttons["search.options"].tap()
app.buttons["分组或书源"].tap()
XCTAssertTrue(app.navigationBars["分组或书源"].waitForExistence(timeout: 5))
try captureDarkPage(app, name: "Search group filter")
@@ -108,7 +108,10 @@ final class LiveReadingFlowUITests: XCTestCase {
showReaderMenu()
app.buttons["换源"].tap()
XCTAssertTrue(app.navigationBars["为《极道天魔》换源"].waitForExistence(timeout: 10))
let replacement = app.buttons["sourceSwitch.candidate." + replacementID]
let replacement = app.buttons.matching(NSPredicate(
format: "identifier BEGINSWITH %@ AND label CONTAINS %@",
"sourceSwitch.candidate." + replacementID + "|", "滚开"
)).firstMatch
XCTAssertTrue(replacement.waitForExistence(timeout: 240))
attach("07-source-candidates")
replacement.tap()
@@ -354,8 +354,13 @@ final class ReaderScrollGestureTests: XCTestCase {
app.launch()
let reader = app.collectionViews["reader.continuous"]
XCTAssertTrue(reader.waitForExistence(timeout: 20))
var lastInitialState = "<not reported>"
let ready = expectation(for: NSPredicate { _, _ in
let text = reader.value as? String ?? ""
let text = reader.value as? String ?? "<missing>"
if text != lastInitialState {
print("[ContinuousReaderInitial] \(text)")
lastInitialState = text
}
return text.contains("chapter=20 ") && !text.contains("chapters=1 ")
}, evaluatedWith: nil)
wait(for: [ready], timeout: 20)
@@ -2,34 +2,89 @@ import XCTest
@MainActor
final class RuleEditorUITests: XCTestCase {
func testBookshelfTXTFilePickerImportsTXTAndDisablesJSON() throws {
try exerciseLocalTXTImport(fromFileManagement: false)
}
func testFileManagementTXTFilePickerImportsTXTAndDisablesJSON() throws {
try exerciseLocalTXTImport(fromFileManagement: true)
}
private func exerciseLocalTXTImport(fromFileManagement: Bool) throws {
func testRSSSearchSubmissionPaginationAndFavorites() throws {
guard let fixture = ProcessInfo.processInfo.environment["LEGADO_RSS_UI_FIXTURE_URL"] else {
throw XCTSkip("Set LEGADO_RSS_UI_FIXTURE_URL to the local RSS fixture source URL")
}
continueAfterFailure = false
let app = XCUIApplication()
app.launchArguments = ["-uiDemo", "-themeMode", "1", "-bookshelfLayout", "grid3"]
app.launchArguments = ["-uiDemo", "-uiDemoTab", "rss", "-themeMode", "1"]
app.launch()
XCTAssertTrue(app.navigationBars["RSS"].waitForExistence(timeout: 20))
app.buttons["RSS操作"].tap()
app.buttons["URL或粘贴JSON"].tap()
let editor = app.textViews.firstMatch
XCTAssertTrue(editor.waitForExistence(timeout: 5))
editor.tap()
editor.typeText(fixture)
app.navigationBars["导入RSS源"].buttons["导入"].tap()
let source = app.staticTexts["RSS 全流程测试"]
XCTAssertTrue(source.waitForExistence(timeout: 10))
source.tap()
XCTAssertTrue(app.staticTexts["首页文章"].waitForExistence(timeout: 10))
let search = app.searchFields["源内搜索"]
if !search.exists { app.swipeDown() }
XCTAssertTrue(search.waitForExistence(timeout: 5))
search.tap()
search.typeText("needle\n")
let result = app.staticTexts["搜索结果 needle 第1页"]
XCTAssertTrue(result.waitForExistence(timeout: 10))
let nextPage = app.buttons["加载下一页"]
let ready = expectation(for: NSPredicate { _, _ in nextPage.exists && nextPage.isEnabled }, evaluatedWith: nil)
wait(for: [ready], timeout: 10)
nextPage.tap()
XCTAssertTrue(app.staticTexts["搜索结果 needle 第2页"].waitForExistence(timeout: 10))
result.tap()
XCTAssertTrue(app.navigationBars["文章"].waitForExistence(timeout: 5))
if app.buttons["取消收藏"].exists { app.buttons["取消收藏"].tap() }
XCTAssertTrue(app.buttons["收藏"].waitForExistence(timeout: 5))
app.buttons["收藏"].tap()
XCTAssertTrue(app.buttons["取消收藏"].waitForExistence(timeout: 5))
app.navigationBars["文章"].buttons.element(boundBy: 0).tap()
let cancelSearch = app.navigationBars["RSS 全流程测试"].buttons.matching(NSPredicate(
format: "label IN %@", ["Cancel", "取消", "Close", "关闭"]
)).firstMatch
if cancelSearch.exists { cancelSearch.tap() }
app.navigationBars["RSS 全流程测试"].buttons["RSS"].tap()
XCTAssertTrue(app.navigationBars["RSS"].waitForExistence(timeout: 5))
app.segmentedControls.buttons["收藏"].tap()
XCTAssertTrue(result.waitForExistence(timeout: 5))
attach(app, name: "RSS search article saved to favorites")
app.terminate()
app.launch()
XCTAssertTrue(app.staticTexts["RSS 全流程测试"].waitForExistence(timeout: 10))
app.segmentedControls.buttons["收藏"].tap()
XCTAssertTrue(result.waitForExistence(timeout: 5))
}
func testBookshelfFilePickerImportsTXTAndEPUBAndDisablesJSON() throws {
try exerciseLocalBookImport(fromFileManagement: false)
}
func testFileManagementFilePickerImportsTXTAndEPUBAndDisablesJSON() throws {
try exerciseLocalBookImport(fromFileManagement: true)
}
private func exerciseLocalBookImport(fromFileManagement: Bool) throws {
continueAfterFailure = false
let app = XCUIApplication()
app.launchArguments = ["-uiDemo", "-themeMode", "1", "-bookshelfLayout", "grid3", "-pageAnim", "0"]
if fromFileManagement { app.launchArguments += ["-uiDemoTab", "settings", "-uiDemoFiles"] }
app.launch()
let importTitle = fromFileManagement ? "导入 TXT 文件" : "本地书"
let importTitle = fromFileManagement ? "导入 TXT / EPUB" : "本地书"
let more = fromFileManagement
? app.navigationBars["文件管理"].buttons["更多"].firstMatch
: app.buttons["更多"].firstMatch
XCTAssertTrue(more.waitForExistence(timeout: 20))
more.tap()
app.buttons[importTitle].tap()
app.buttons[importTitle].firstMatch.tap()
let cancel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Cancel", "取消"])).firstMatch
XCTAssertTrue(cancel.waitForExistence(timeout: 10))
cancel.tap()
let closed = expectation(for: NSPredicate { _, _ in !cancel.exists }, evaluatedWith: nil)
wait(for: [closed], timeout: 10)
more.tap()
app.buttons[importTitle].tap()
app.buttons[importTitle].firstMatch.tap()
XCTAssertTrue(cancel.waitForExistence(timeout: 10))
let pickerFiles = app.collectionViews["File View"].cells
@@ -66,6 +121,10 @@ final class RuleEditorUITests: XCTestCase {
let uppercase = pickerFiles.matching(NSPredicate(format: "label BEGINSWITH %@", "TXT-Uppercase")).firstMatch
XCTAssertTrue(uppercase.waitForExistence(timeout: 5))
uppercase.tap()
let epub = pickerFiles.matching(NSPredicate(format: "label BEGINSWITH %@", "EPUB-Book")).firstMatch
XCTAssertTrue(epub.waitForExistence(timeout: 5))
XCTAssertTrue(epub.isEnabled)
epub.tap()
XCTAssertTrue(open.waitForExistence(timeout: 5))
XCTAssertTrue(open.isEnabled)
attach(app, name: "TXT selectable and JSON excluded")
@@ -78,14 +137,34 @@ final class RuleEditorUITests: XCTestCase {
XCTAssertTrue(app.navigationBars["导入本地书"].waitForExistence(timeout: 15))
XCTAssertFalse(app.staticTexts["JSON-Source.json"].exists)
XCTAssertFalse(app.staticTexts["JSON-Uppercase.JSON"].exists)
XCTAssertTrue(app.staticTexts["TXT-Book.txt"].exists)
XCTAssertTrue(app.staticTexts["TXT-Uppercase.TXT"].exists)
XCTAssertTrue(app.staticTexts["TXT-Book.txt"].waitForExistence(timeout: 10), app.debugDescription)
XCTAssertTrue(app.staticTexts["TXT-Uppercase.TXT"].waitForExistence(timeout: 10), app.debugDescription)
app.buttons["导入"].tap()
}
// Repeated runs preserve managed files and correctly add a numeric suffix.
XCTAssertTrue(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "TXT-Book")).firstMatch.waitForExistence(timeout: 15))
XCTAssertTrue(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "TXT-Uppercase")).firstMatch.waitForExistence(timeout: 15))
attach(app, name: "Local TXT books imported onto shelf")
let epubBook = app.staticTexts["EPUB 导入测试"].firstMatch
XCTAssertTrue(epubBook.waitForExistence(timeout: 15))
attach(app, name: "Local TXT and EPUB books imported onto shelf")
epubBook.tap()
let reader = app.textViews["reader.text"]
XCTAssertTrue(reader.waitForExistence(timeout: 15))
XCTAssertTrue((reader.value as? String)?.contains("这是 EPUB 导入后的第一章正文") == true)
reader.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
app.buttons["更多"].firstMatch.tap()
app.buttons["本章插图"].tap()
XCTAssertTrue(app.navigationBars["本章插图"].waitForExistence(timeout: 5))
XCTAssertTrue(app.images["书籍插图"].waitForExistence(timeout: 10))
app.buttons["完成"].tap()
reader.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
app.buttons["目录"].tap()
app.staticTexts["第二章 EPUB"].firstMatch.tap()
let loaded = expectation(for: NSPredicate { _, _ in
(reader.value as? String)?.contains("目录锚点跳转成功") == true
}, evaluatedWith: nil)
wait(for: [loaded], timeout: 10)
attach(app, name: "EPUB second chapter after TOC navigation")
}
func testJSONFilePickerOffersOpenAndImportsSelectedFiles() throws {
@@ -146,7 +225,7 @@ final class RuleEditorUITests: XCTestCase {
attach(app, name: "JSON import review after file picker dismissal")
app.buttons["导入"].tap()
if app.alerts.firstMatch.waitForExistence(timeout: 5) { app.alerts.buttons["好"].tap() }
XCTAssertTrue(app.staticTexts["文件导入 JSON 测试"].waitForExistence(timeout: 10))
XCTAssertTrue(app.staticTexts["文件导入 JSON 测试"].waitForExistence(timeout: 10), app.debugDescription)
XCTAssertTrue(app.staticTexts["文件导入大写扩展名测试"].exists)
}