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
@@ -0,0 +1,141 @@
import Foundation
import Testing
@testable import LegadoCore
struct ReplaceRuleExecutionTests {
private func process(_ content: String, rules: [ReplaceRule], title: String = "第十二章") -> ContentProcessingReport {
let book = Book(bookUrl: "book", name: "测试书", origin: "https://source.test")
let chapter = BookChapter(url: "chapter", title: title, bookUrl: book.bookUrl, index: 11)
return ContentSanitizer().process(book: book, chapter: chapter, content: content, rules: rules,
removeDuplicateTitle: false, resegment: false)
}
@Test func evaluatesJavaScriptForEveryMatchIncludingZeroWidthMatches() {
let width = ReplaceRule(id: 1, name: "全角", pattern: "[A-Z0-9]",
replacement: "@js:String.fromCharCode(result.charCodeAt(0) - 65248)")
var punctuation = ReplaceRule(id: 2, name: "标点", pattern: #"(?<=说道)(?=“)|\.{3,}"#,
replacement: #"@js:if (result === '') { ':' } else { '…' }"#)
punctuation.sortOrder = 1
let result = process("ABC123\n他说道“等待...”", rules: [punctuation, width])
#expect(result.content == "ABC123\n他说道:“等待…”")
#expect(result.effectiveRuleIDs == [1, 2])
#expect(result.diagnostics.isEmpty)
}
@Test func bindsBookAndChapterAndSharesOnlyJavaVariablesBetweenMatches() {
let rule = ReplaceRule(id: 1, name: "上下文", pattern: "[ab]", replacement: """
@js:n = typeof n === 'undefined' ? 1 : n + 1;
java.put('count', String(Number(java.get('count') || 0) + 1));
[book.name, book.getName(), chapter.title, chapter.getIndex(), result, n, java.get('count')].join(':')
""")
let report = process("a b", rules: [rule])
#expect(report.content == "测试书:测试书:第十二章:11:a:1:1 测试书:测试书:第十二章:11:b:1:2")
#expect(report.diagnostics.isEmpty)
// A new application of the same rule must not inherit another chapter's variables.
#expect(process("a", rules: [rule]).content == "测试书:测试书:第十二章:11:a:1:1")
}
@Test(arguments: [
(#"'$1'"#, "x"),
(#"String.fromCharCode(92) + 'n'"#, #"\n"#),
("null", "null"),
("undefined", "null"),
("['a', 'b']", "a,b"),
("123", "123.0"),
("false", "false")
])
func convertsScriptCompletionAndPreservesReplacementEscaping(_ sample: (String, String)) {
let rule = ReplaceRule(id: 1, name: "返回值", pattern: "(x)", replacement: "@js:" + sample.0)
let report = process("x", rules: [rule])
#expect(report.content == sample.1)
#expect(report.diagnostics.isEmpty)
}
@Test func failedScriptDiscardsAllPartialReplacementsAndLaterRulesStillRun() {
let failing = ReplaceRule(id: 1, name: "失败", pattern: "[ab]",
replacement: "@js:if (result === 'b') { throw new Error('bad match') }; 'changed'")
var following = ReplaceRule(id: 2, name: "后续", pattern: "tail", replacement: "后续成功")
following.sortOrder = 1
let report = process("ab tail", rules: [failing, following])
#expect(report.content == "ab 后续成功")
#expect(report.effectiveRuleIDs == [2])
#expect(report.diagnostics.count == 1)
#expect(report.diagnostics.first?.code == "rule.malformed")
}
@Test func skipsDisabledExcludedAndUnmatchedScriptsAndKeepsLiteralReplacementsLiteral() {
var excluded = ReplaceRule(id: 1, name: "排除", pattern: "x", replacement: "@js:throw new Error('excluded')")
excluded.excludeScope = "测试书"
var disabled = excluded
disabled.id = 2
disabled.excludeScope = nil
disabled.isEnabled = false
let unmatched = ReplaceRule(id: 3, name: "未匹配", pattern: "z", replacement: "@js:throw new Error('unmatched')")
var literal = ReplaceRule(id: 4, name: "普通文本", pattern: "x", replacement: "@js:'literal'")
literal.isRegex = false
let report = process("x", rules: [excluded, disabled, unmatched, literal])
#expect(report.content == "@js:'literal'")
#expect(report.diagnostics.isEmpty)
}
@Test func preservesLastNonblankTitleButAllowsDeletingBody() {
var rename = ReplaceRule(id: 1, name: "标题", pattern: "十二", replacement: "12")
rename.scopeTitle = true
rename.scopeContent = false
var erase = ReplaceRule(id: 2, name: "空白", pattern: "^.+$", replacement: "@js:'  \\n'")
erase.scopeTitle = true
erase.sortOrder = 1
let report = process("正文", rules: [erase, rename])
#expect(report.title == "第12章")
#expect(report.content == "  \n")
#expect(report.effectiveRuleIDs == [1, 2])
erase.replacement = ""
erase.scopeContent = false
let titleOnly = process("正文", rules: [erase])
#expect(titleOnly.title == "第十二章")
#expect(titleOnly.effectiveRuleIDs.isEmpty)
}
@Test(arguments: [
(#"(?:\n[^\n]+){0,15}$"#, "正文\nPS:说明\n感谢支持", "正文"),
(#"^(?:[^\n\w]\w+){0,2}"#, ",第一章,开始\n正文", "\n正文"),
(#""((?:[\w,。]|‘\w+’|\b[%\+\-])+)["]"#, ""你好,世界。"", "“你好,世界。”"),
(#"(?:[+*]|\\)+"#, #"+*\"#, "")
])
func acceptsBoundedDelimitedAndEscapedQuantifiers(_ sample: (String, String, String)) throws {
let replacement = sample.0.hasPrefix(""") ? "“$1”" : ""
#expect(try RegexEvaluator().replacingStrict(pattern: sample.0, in: sample.1, with: replacement) == sample.2)
}
@Test(arguments: ["(a+)+$", #"(\w*)*"#, "(?:[ab]+){2,}"])
func retainsChecksForClearlyNestedUnboundedRepetition(_ pattern: String) {
#expect(throws: RuleEngineError.self) {
try RegexEvaluator().replacingStrict(pattern: pattern, in: "aaa!", with: "changed")
}
}
@Test func dynamicReplacementRespectsMatchOutputAndWholeOperationTimeLimits() throws {
let limited = RegexEvaluator(limits: .init(maximumInputLength: 4, maximumMatches: 2))
#expect(throws: RuleEngineError.self) {
try limited.replacingStrict(pattern: "a", in: "aaa", replacement: { _ in "b" })
}
#expect(try limited.replacingStrict(pattern: "a", in: "aa", replacement: { _ in "b" }) == "bb")
// Include untouched tail in the output limit, not only replaced spans.
#expect(throws: RuleEngineError.self) {
try limited.replacingStrict(pattern: "a", in: "abcd", replacement: { _ in "1234567" })
}
#expect(throws: RuleEngineError.self) {
try limited.replacingStrict(pattern: "a", in: "a", timeoutMilliseconds: 1, replacement: { _ in
Thread.sleep(forTimeInterval: 0.02)
return "b"
})
}
}
@Test func rejectsKnownUnboundedJavaScriptAndReportsErrorWithoutChangingInput() {
let rule = ReplaceRule(id: 1, name: "循环", pattern: "x", replacement: "@js:while(true){}")
let report = process("x", rules: [rule])
#expect(report.content == "x")
#expect(report.diagnostics.first?.code == "rule.resource_limit")
}
}