import { describe, expect, it } from 'bun:test' import { buildRule, findRuleIssues, firstMatchIndex, inferRuleFormat, insertBeforeMatch, isStructuredEditable, missingLanRules, moveRule, parseIniRulesets, parseRule, planIniImport, ruleProviderPath, ruleProviderPathConflict, ruleSetReferences, setRuleProviders, setRules, } from './rules.ts' import type { ConfigObject } from '../vendor/config-editor/index.ts' describe('规则字符串的拆分与拼装', () => { it('常见的三段规则', () => { expect(parseRule('DOMAIN-SUFFIX,youtube.com,PROXY')).toEqual({ type: 'DOMAIN-SUFFIX', payload: 'youtube.com', target: 'PROXY', extras: [], }) }) it('MATCH 没有载荷', () => { expect(parseRule('MATCH,PROXY')).toEqual({ type: 'MATCH', payload: null, target: 'PROXY', extras: [], }) }) it('选项尾部原样保留', () => { const parsed = parseRule('IP-CIDR,127.0.0.0/8,DIRECT,no-resolve') expect(parsed.extras).toEqual(['no-resolve']) // 改目标不能丢掉选项 expect(buildRule({ ...parsed, target: 'REJECT' })).toBe('IP-CIDR,127.0.0.0/8,REJECT,no-resolve') }) it('拆开再拼回原样', () => { for (const rule of [ 'DOMAIN-SUFFIX,youtube.com,PROXY', 'MATCH,PROXY', 'IP-CIDR,127.0.0.0/8,DIRECT,no-resolve', 'RULE-SET,ads,REJECT', 'AND,((DOMAIN-SUFFIX,a.com),(DST-PORT,443)),PROXY', ]) { expect(buildRule(parseRule(rule))).toBe(rule) } }) it('逻辑规则的载荷里有逗号也不被拆开', () => { const parsed = parseRule('AND,((DOMAIN-SUFFIX,a.com),(DST-PORT,443)),PROXY') expect(parsed.payload).toBe('((DOMAIN-SUFFIX,a.com),(DST-PORT,443))') expect(parsed.target).toBe('PROXY') }) it('正则类规则的顶层逗号不拆坏:最后一段是目标,其余全归载荷', () => { // 内核对 DOMAIN-REGEX 这几类按「载荷可含逗号、不许带选项」读(rules/common/base.go:47-79) expect(parseRule('DOMAIN-REGEX,^(a|b),c$,PROXY')).toEqual({ type: 'DOMAIN-REGEX', payload: '^(a|b),c$', target: 'PROXY', extras: [], }) }) it('结构化编辑只给常见形状,逻辑规则、子规则和正则类按原文编辑', () => { expect(isStructuredEditable('DOMAIN-SUFFIX,youtube.com,PROXY')).toBe(true) expect(isStructuredEditable('MATCH,PROXY')).toBe(true) expect(isStructuredEditable('AND,((DOMAIN-SUFFIX,a.com),(DST-PORT,443)),PROXY')).toBe(false) expect(isStructuredEditable('SUB-RULE,(DOMAIN-SUFFIX,a.com),sub1')).toBe(false) expect(isStructuredEditable('DOMAIN-REGEX,^ads?,b$,REJECT')).toBe(false) expect(isStructuredEditable('孤零零一段')).toBe(false) }) }) describe('规则表的顺序', () => { const rules = ['DOMAIN-SUFFIX,a.com,DIRECT', 'MATCH,PROXY', 'DOMAIN-SUFFIX,b.com,REJECT'] it('第一条 MATCH 之后的规则不可达', () => { expect(firstMatchIndex(rules)).toBe(1) expect(firstMatchIndex(['DOMAIN-SUFFIX,a.com,DIRECT'])).toBe(-1) }) it('新规则插在第一条 MATCH 之前', () => { expect(insertBeforeMatch(rules, ['GEOIP,CN,DIRECT'])).toEqual([ 'DOMAIN-SUFFIX,a.com,DIRECT', 'GEOIP,CN,DIRECT', 'MATCH,PROXY', 'DOMAIN-SUFFIX,b.com,REJECT', ]) }) it('没有 MATCH 时加在末尾', () => { expect(insertBeforeMatch(['GEOIP,CN,DIRECT'], ['MATCH,PROXY'])).toEqual([ 'GEOIP,CN,DIRECT', 'MATCH,PROXY', ]) }) it('上移下移只交换相邻两条,其余不动', () => { expect(moveRule(rules, 0, 1)).toEqual([ 'MATCH,PROXY', 'DOMAIN-SUFFIX,a.com,DIRECT', 'DOMAIN-SUFFIX,b.com,REJECT', ]) expect(moveRule(rules, 0, -1)).toBe(rules) expect(moveRule(rules, 2, 1)).toBe(rules) }) }) describe('断引用', () => { it('目标出站不存在和规则组不存在都标出来', () => { const rules = [ 'DOMAIN-SUFFIX,a.com,NOPE', 'RULE-SET,ads,REJECT', 'RULE-SET,gone,DIRECT', 'SUB-RULE,(DOMAIN-SUFFIX,a.com),sub1', 'MATCH,PROXY', ] const issues = findRuleIssues(rules, new Set(['PROXY', 'REJECT', 'DIRECT']), new Set(['ads'])) expect(issues).toEqual([ { index: 0, kind: 'target', name: 'NOPE' }, { index: 2, kind: 'rule-set', name: 'gone' }, ]) }) }) describe('内网直连预设', () => { it('空表全缺', () => { expect(missingLanRules([])).toHaveLength(8) }) it('已管住的网段不重复加,哪怕写法不完全一样', () => { const existing = ['IP-CIDR,192.168.0.0/16,DIRECT', 'MATCH,PROXY'] const missing = missingLanRules(existing) expect(missing).toHaveLength(7) expect(missing.some((r) => r.includes('192.168.0.0/16'))).toBe(false) }) }) describe('删除规则组前找引用', () => { it('列出 rules 里引用它的规则下标', () => { const rules = ['RULE-SET,ads,REJECT', 'DOMAIN-SUFFIX,a.com,DIRECT', 'RULE-SET,ads,PROXY'] expect(ruleSetReferences(rules, 'ads')).toEqual([0, 2]) expect(ruleSetReferences(rules, 'other')).toEqual([]) }) }) describe('配置形状', () => { it('rules 空了删掉整个键', () => { const data: ConfigObject = { rules: ['MATCH,PROXY'] } setRules(data, ['A,B,C']) expect(data.rules).toEqual(['A,B,C']) setRules(data, []) expect('rules' in data).toBe(false) }) it('rule-providers 空了删掉整个键', () => { const data: ConfigObject = {} setRuleProviders(data, { ads: { type: 'http' } }) expect(Object.keys(data['rule-providers'] as ConfigObject)).toEqual(['ads']) setRuleProviders(data, {}) expect('rule-providers' in data).toBe(false) }) }) describe('ini 的 ruleset= 行解析', () => { const ini = ` [custom] ; 注释行不算 ;ruleset=🎯 全球直连,[]GEOIP,LAN ruleset=🎯 全球直连,https://example.com/LocalAreaNetwork.list ruleset=🛑 全球拦截,https://example.com/BanAD.list?token=1 ruleset=🎯 全球直连,https://example.com/GoogleCN.list ruleset=🍎 苹果服务,Apple.list ruleset=🎯 全球直连,[]GEOIP,CN ruleset=🐟 漏网之鱼,[]FINAL custom_proxy_group=🚀 节点选择\`select\`[]AUTO ` it('URL、本地路径、内联 GEOIP 和 FINAL 都认出来', () => { expect(parseIniRulesets(ini)).toEqual([ { target: '🎯 全球直连', kind: 'url', source: 'https://example.com/LocalAreaNetwork.list' }, { target: '🛑 全球拦截', kind: 'url', source: 'https://example.com/BanAD.list?token=1' }, { target: '🎯 全球直连', kind: 'url', source: 'https://example.com/GoogleCN.list' }, { target: '🍎 苹果服务', kind: 'file', source: 'Apple.list' }, { target: '🎯 全球直连', kind: 'geoip', source: 'CN' }, { target: '🐟 漏网之鱼', kind: 'final', source: '' }, ]) }) it('custom_proxy_group 和残缺行不产生条目', () => { expect(parseIniRulesets('ruleset=\nruleset=只有目标,\ncustom_proxy_group=x`select`y')).toEqual([]) }) it('GEOIP 类别后的附加参数不并进类别名,生成规则时放在目标之后', () => { const entries = parseIniRulesets('ruleset=🎯 全球直连,[]GEOIP,CN,no-resolve') expect(entries).toEqual([ { target: '🎯 全球直连', kind: 'geoip', source: 'CN', flags: ['no-resolve'] }, ]) const plan = planIniImport(entries, { '🎯 全球直连': 'DIRECT' }, new Set()) expect(plan.rules).toEqual(['GEOIP,CN,DIRECT,no-resolve']) }) }) describe('ini 导入计划', () => { const entries = parseIniRulesets(` ruleset=🎯 全球直连,https://example.com/LocalAreaNetwork.list ruleset=🎯 全球直连,https://example.com/GoogleCN.list ruleset=🎯 全球直连,[]GEOIP,CN ruleset=🐟 漏网之鱼,[]FINAL `) it('每行一个规则组加一条规则骨架,FINAL 落在表末尾', () => { const plan = planIniImport( entries, { '🎯 全球直连': 'DIRECT', '🐟 漏网之鱼': 'PROXY' }, new Set(), ) expect(Object.keys(plan.providers)).toEqual(['LocalAreaNetwork', 'GoogleCN']) expect(plan.providers.LocalAreaNetwork).toEqual({ type: 'http', behavior: 'classical', url: 'https://example.com/LocalAreaNetwork.list', path: './providers/rules/LocalAreaNetwork.txt', format: 'text', interval: 86400, }) expect(plan.rules).toEqual([ 'RULE-SET,LocalAreaNetwork,DIRECT', 'RULE-SET,GoogleCN,DIRECT', 'GEOIP,CN,DIRECT', ]) expect(plan.finals).toEqual(['MATCH,PROXY']) }) it('同一个来源出现多次只声明一个规则组', () => { const plan = planIniImport( [ { target: '甲', kind: 'url', source: 'https://example.com/a.list' }, { target: '乙', kind: 'url', source: 'https://example.com/a.list' }, ], { 甲: 'DIRECT', 乙: 'REJECT' }, new Set(), ) expect(Object.keys(plan.providers)).toEqual(['a']) expect(plan.rules).toEqual(['RULE-SET,a,DIRECT', 'RULE-SET,a,REJECT']) }) it('和已有规则组撞名时加序号', () => { const plan = planIniImport( [{ target: '甲', kind: 'url', source: 'https://example.com/ads.list' }], { 甲: 'REJECT' }, new Set(['ads']), ) expect(Object.keys(plan.providers)).toEqual(['ads-2']) expect(plan.rules).toEqual(['RULE-SET,ads-2,REJECT']) }) it('本地路径生成 file 类型,path 就是来源本身', () => { const plan = planIniImport( [{ target: '甲', kind: 'file', source: 'D:\\lists\\ads.list' }], { 甲: 'REJECT' }, new Set(), ) expect(plan.providers['ads']).toEqual({ type: 'file', behavior: 'classical', path: 'D:\\lists\\ads.list', format: 'text', }) }) it('没映射目标组的行跳过', () => { const plan = planIniImport(entries, { '🎯 全球直连': 'DIRECT' }, new Set()) expect(plan.finals).toEqual([]) expect(plan.rules).toHaveLength(3) }) }) describe('清单格式与缓存路径', () => { it('格式按扩展名推断,拿不准回 yaml', () => { expect(inferRuleFormat('https://example.com/a.list?token=1')).toBe('text') expect(inferRuleFormat('https://example.com/a.txt')).toBe('text') expect(inferRuleFormat('https://example.com/a.mrs')).toBe('mrs') expect(inferRuleFormat('https://example.com/a.yaml')).toBe('yaml') expect(inferRuleFormat('https://example.com/no-extension')).toBe('yaml') }) it('缓存路径的扩展名跟着格式走', () => { expect(ruleProviderPath('ads', 'text')).toBe('./providers/rules/ads.txt') expect(ruleProviderPath('ads', 'mrs')).toBe('./providers/rules/ads.mrs') expect(ruleProviderPath('ads', 'yaml')).toBe('./providers/rules/ads.yaml') expect(ruleProviderPath('🛑 广告', 'yaml')).toBe('./providers/rules/广告.yaml') }) it('名字只差符号时清单路径撞车,能认出来;自己不算', () => { const data = { 'rule-providers': { '🛑 广告': { behavior: 'domain', path: './providers/rules/广告.yaml' }, }, } const path = ruleProviderPath('广告', 'yaml') expect(ruleProviderPathConflict(data, '广告', path)).toBe('🛑 广告') expect(ruleProviderPathConflict(data, '🛑 广告', path)).toBeNull() expect(ruleProviderPathConflict(data, 'x', ruleProviderPath('x', 'yaml'))).toBeNull() }) })