规则页:全局分流规则的编辑与规则组管理
新增 RulesPage.vue 和规则、规则组、ini 导入三个对话框,纯逻辑放在 shared/rules.ts。拆分规则字符串复用 vendor 的 splitRuleFields——引号和 嵌套括号里的逗号不会被当成字段分隔;目标之后的选项段原样拼回,改一条 规则不会丢掉它的 no-resolve 尾巴。规则组的缓存路径显式生成:不写 path 时内核按 url 哈希自己算一个,那个路径这里算不出来。
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
buildRule,
|
||||
findRuleIssues,
|
||||
firstMatchIndex,
|
||||
inferRuleFormat,
|
||||
insertBeforeMatch,
|
||||
isStructuredEditable,
|
||||
missingLanRules,
|
||||
moveRule,
|
||||
parseIniRulesets,
|
||||
parseRule,
|
||||
planIniImport,
|
||||
ruleProviderPath,
|
||||
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('结构化编辑只给常见形状,逻辑规则和子规则按原文编辑', () => {
|
||||
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('孤零零一段')).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([])
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user