设置:显式主题补 color-scheme,滚动条和 select 弹层这些 UA 部件不看 变量。连接:断开失败的提示与轮询取数分成两条通道,不再被下一秒的轮询 成功抹掉;排序加 id 决胜,内核快照的遍历序无序,空闲连接下载量普遍 相同,没有决胜键行会在断开按钮下面跳。仪表盘与请求:一条流断了连带 收掉同组连接;断流不等于内核没了(配置重载掐断流而 online 从未翻转), 5 秒自动重连。规则:正则类规则(DOMAIN-REGEX 等三种)按内核的 payloadCanContainComma 语义拆分与禁入结构化编辑,顶层逗号不再把正则 截一半当目标;findRuleIssues 注释里对内核校验的错误断言改正。
297 lines
10 KiB
TypeScript
297 lines
10 KiB
TypeScript
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('正则类规则的顶层逗号不拆坏:最后一段是目标,其余全归载荷', () => {
|
||
// 内核对 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([])
|
||
})
|
||
})
|
||
|
||
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')
|
||
})
|
||
})
|