import Foundation import Testing @testable import LegadoCore struct RuleEngineTests { @Test func scalarResultMatchesLegadoJoinAndLeadingURLSemantics() { let result = RuleEvaluation(values: [.string(""), .string("乙"), .string("丙")]) #expect(result.joinedString == "\n乙\n丙") #expect(result.leadingString == nil) #expect(result.firstString == "乙") } private let engine = RuleEngine() @Test func javaScriptStagesContinueIntoSelectorsAndReplacementOnlyRules() async { let result = await engine.evaluate( "result.replace('旧', '新')class.body@textresult + ' 广告'## 广告", inText: "
旧正文
" ) #expect(result.strings == ["新正文"]) #expect(result.diagnostics.isEmpty) let replaced = await engine.evaluate("##广告##", inText: "正文广告") #expect(replaced.strings == ["正文"]) #expect(replaced.diagnostics.isEmpty) } @Test func listJavaScriptReceivesWholeListAndScalarJavaScriptReceivesJoinedText() async { let list = await engine.evaluate( "tag.aresult.reverse()", inText: "AB", listMode: true ) #expect(list.diagnostics.isEmpty) #expect(list.values.first?[0]?.stringValue.contains(">B<") == true) let scalar = await engine.evaluate("tag.p@text@js:result.split('\\n').reverse().join('|')", inText: "

A

B

") #expect(scalar.strings == ["B|A"]) } @Test func javaScriptCanReadOriginalContentAfterExtractionAndReturnJSON() async { let result = await engine.evaluate( "tag.h1@text@js:result + ':' + java.getString('tag.p@text')", inText: "

书名

作者

" ) #expect(result.strings == ["书名:作者"]) let json = await engine.evaluate("JSON.parse(result.slice(4))$.data.name", inText: "data{\"data\":{\"name\":\"书名\"}}") #expect(json.strings == ["书名"]) #expect(json.diagnostics.isEmpty) } @Test func standaloneDynamicRulesReturnValuesAndJavaScriptIsNotParsedAsRuleSyntax() async { let variable = await engine.evaluate("@get:{token}", inText: "

", context: .init(variables: ["token": "abc"])) #expect(variable.strings == ["abc"]) let inline = await engine.evaluate("{{'https://example.test/' + 42}}", inText: "

") #expect(inline.strings == ["https://example.test/42"]) let script = await engine.evaluate("@js:result.replace('##', '')", inText: "正文##") #expect(script.strings == ["正文"]) let nested = await engine.evaluate("{{@CSS:h1@text}}-{{//p/text()}}", inText: "

书名

作者

") #expect(nested.strings == ["书名-作者"]) let jsonString = await engine.evaluate("result{{$.name}}", inText: #"{"name":"书名"}"#) #expect(jsonString.strings == ["书名"]) let captures = await engine.evaluate("$1##(甲)##$1乙", in: .array([.string("甲"), .string("甲")])) #expect(captures.strings == ["甲乙"]) } @Test func importedQQBookURLsAndNumericCoverIDsUseStringPipelineSemantics() async throws { let input = try JSONValue.decode(data: Data(#"{"bid":1009704712}"#.utf8)) let url = await engine.evaluate( "$.bid\n1100000000+parseInt(result)\nhttps://bookshelf.html5.qq.com/qbread/api/novel/intro-info?bookid={{result}}", in: input ) #expect(url.strings == ["https://bookshelf.html5.qq.com/qbread/api/novel/intro-info?bookid=2109704712"]) #expect(url.diagnostics.isEmpty) let cover = await engine.evaluate("$.bid@js:result.slice(-3)", in: input) #expect(cover.strings == ["712"]) #expect(cover.diagnostics.isEmpty) let literal = await engine.evaluate("https://example.test/@get:{id}", in: input, context: .init(variables: ["id": "42"])) #expect(literal.strings == ["https://example.test/42"]) } @Test func combinesFallbackAndConcatenation() async throws { let input = try JSONValue.decode(data: Data(#"{"a":"A","b":"B"}"#.utf8)) let fallback = await engine.evaluate("$.missing||$.b", in: input) #expect(fallback.strings == ["B"]) #expect(fallback.diagnostics.isEmpty) let concatenated = await engine.evaluate("$.a&&$.b", in: input) #expect(concatenated.strings == ["A", "B"]) } @Test func appliesRegexReplacement() async throws { let input = try JSONValue.decode(data: Data(#"{"name":"A B"}"#.utf8)) let result = await engine.evaluate("$.name##\\s+## ", in: input) #expect(result.firstString == "A B") } @Test func regexCapturesAndRejectsHighRiskPattern() throws { let evaluator = RegexEvaluator() #expect(try evaluator.captureGroups(pattern: #"(\w+):(\d+)"#, input: "a:1 b:2") == [ ["a:1", "a", "1"], ["b:2", "b", "2"] ]) #expect(throws: RuleEngineError.self) { try evaluator.captureGroups(pattern: "(a+)+", input: String(repeating: "a", count: 100)) } } @Test func interleaveStopsAtFirstBranchLength() async throws { let input = try JSONValue.decode(data: Data(#"{"a":["A"],"b":["B1","B2"]}"#.utf8)) let result = await engine.evaluate("$.a[*]%%$.b[*]", in: input) #expect(result.strings == ["A", "B1"]) } @Test func replacementUsesFirstMatchAndInvalidRegexFallback() throws { let evaluator = RegexEvaluator() #expect(try evaluator.replacing(pattern: #"\d+"#, in: "a12b34", with: "<$0>", firstOnly: true) == "<12>") #expect(try evaluator.replacing(pattern: #"\d+"#, in: "abc", with: "x", firstOnly: true) == "") #expect(try evaluator.replacing(pattern: "[", in: "a[b[", with: "x", firstOnly: false) == "axbx") #expect(try evaluator.replacing(pattern: "[", in: "unchanged", with: "fallback", firstOnly: true) == "fallback") } @Test func supportsPutGetAndInlineJavaScriptInRules() async throws { let input = try JSONValue.decode(data: Data(#"{"selector":"field","field":"ok"}"#.utf8)) let putGet = await engine.evaluate(#"@put:{"selected":"$.selector"}$.@get:{selected}"#, in: input) let inline = await engine.evaluate(#"$.{{java.put('inlineUsed','yes'); 'field'}}"#, in: input) #expect(putGet.firstString == "ok") #expect(putGet.variables["selected"] == "field") #expect(inline.firstString == "ok") #expect(inline.variables["inlineUsed"] == "yes") } @Test func unquotedPutRulesPreserveBookIDsAndReturnTheSelectedTitle() async throws { let input = try JSONValue.decode(data: Data(#"{"id":42,"title":"书名","selector":"field","field":"正文"}"#.utf8)) let result = await engine.evaluate("title@put:{bid:id, selected:$.selector}", in: input) #expect(result.diagnostics.isEmpty) #expect(result.firstString == "书名") #expect(result.variables["bid"] == "42") #expect(result.variables["selected"] == "field") let body = await engine.evaluate("$.@get:{selected}", in: input, context: .init(variables: result.variables)) #expect(body.firstString == "正文") let html = await engine.evaluate("h1@text@put:{chapter:a@href}", inText: "

章节

目录") #expect(html.diagnostics.isEmpty) #expect(html.firstString == "章节") #expect(html.variables["chapter"] == "/1") } @Test func dynamicDoubleBracesTreatJSONPathAsNestedRuleInsteadOfJavaScript() async throws { let input = try JSONValue.decode(data: Data(#"{"book_id":"42","category":"科幻"}"#.utf8)) let value = await engine.evaluate(#"{{$.category}}-{{$.book_id}}"#, in: input) let piped = await engine.evaluate(#"{{$.category}}@js:result + '-已处理'"#, in: input) #expect(value.firstString == "科幻-42") #expect(value.diagnostics.isEmpty) #expect(piped.firstString == "科幻-已处理") #expect(piped.diagnostics.isEmpty) } @Test func putOnlyRuleKeepsCurrentResultWhileWritingVariable() async throws { let input = try JSONValue.decode(data: Data(#"{"name":"保留"}"#.utf8)) let result = await engine.evaluate(#"@put:{"saved":"$.name"}"#, in: input) #expect(result.values == [input]) #expect(result.variables["saved"] == "保留") } @Test func allInOneRegexProducesCaptureRowsAndDollarFields() async throws { let rows = await engine.evaluate( #":name=([^;]+);url=([^\n]+)"#, inText: "name=甲;url=/a\nname=乙;url=/b", listMode: true ) let first = try #require(rows.values.first) let name = await engine.evaluate("$1", in: first) let url = await engine.evaluate("$2", in: first) #expect(rows.values.count == 2) #expect(name.firstString == "甲") #expect(url.firstString == "/a") } @Test func allInOneRegexSupportsSequentialAndChain() async { let rows = await engine.evaluate( #":.*?&&name=(\w+)"#, inText: "name=firstxname=second", listMode: true ) #expect(rows.values.count == 2) #expect(rows.values[0][1]?.stringValue == "first") #expect(rows.values[1][1]?.stringValue == "second") } }