Files
mihomo-patchbay/shared/rules.test.ts
T
im0o 158dc9fca7 消化一批 P3:GEOIP 附加参数、坏 JSON 跳行、兜底丢弃;请求连接两页保活
ini 里 []GEOIP,CN,no-resolve 的附加参数原来被并进类别名,生成字段错位
的规则;现在拆出来放在目标之后。表里已有 MATCH 时配方的 []FINAL 不再
追加成死行,改为丢弃并在成功提示里注明——已有兜底是用户自己安排的
分流终点。

遥测流里偶发的坏 JSON 行改为跳过,原来一行坏数据会杀掉整个流进 5 秒
重试循环。请求页:跟随底部的阈值从 24px 降到 8px(原值比行高还大,
向上滚不足一行就被新日志拽回);silent 级别下的空状态不再说「没有流量
经过」。logs.test 的 localStorage 桩用完删掉,bun test 单进程跑,全局
残留会影响之后的测试文件。api.ts 的 format 注释照 provider.go:37 纠正。

请求页和连接页包进 KeepAlive:切走不销毁,筛选词、排序、滚动位置留住。
KeepAlive 常驻、子组件自己 v-if 进出;连接页的轮询改挂 activated——
挂载时它也触发,继续用 onMounted 会叠两个定时器,切走时停掉轮询。
2026-08-12 03:07:56 +08:00

319 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
})
})