/connections 没有流式版本,只能全量轮询;断开是运行时动作,不走 落盘那条保护链路。两个容易做反的地方都收在 shared/connections.ts: chains 数组第一个是最终出口,显示要倒过来;没有连接时 connections 字段是 null 而不是空数组。虚拟滚动用 @tanstack/vue-virtual,ADR 0006 留的两个例外之一,它只有 virtual-core 一个运行时依赖。
172 lines
5.0 KiB
TypeScript
172 lines
5.0 KiB
TypeScript
import { describe, expect, it } from 'bun:test'
|
||
import {
|
||
chainLabel,
|
||
filterConnections,
|
||
formatDuration,
|
||
groupByInbound,
|
||
hostLabel,
|
||
ruleLabel,
|
||
sortConnections,
|
||
} from './connections.ts'
|
||
import type { ConnectionItem, ConnectionMeta } from './connections.ts'
|
||
|
||
const NOW = Date.parse('2026-08-11T18:40:00+08:00')
|
||
|
||
function meta(over: Partial<ConnectionMeta> = {}): ConnectionMeta {
|
||
return {
|
||
host: 'example.com',
|
||
destinationIP: '93.184.216.34',
|
||
destinationPort: '443',
|
||
inboundName: 'lxc-web',
|
||
inboundPort: '17891',
|
||
...over,
|
||
}
|
||
}
|
||
|
||
function conn(over: Partial<ConnectionItem> = {}): ConnectionItem {
|
||
return {
|
||
id: 'a',
|
||
upload: 0,
|
||
download: 0,
|
||
start: '2026-08-11T18:39:00+08:00',
|
||
chains: ['[vless]某线路', 'AUTO', 'PROXY'],
|
||
rule: 'DomainSuffix',
|
||
rulePayload: 'example.com',
|
||
metadata: meta(),
|
||
...over,
|
||
}
|
||
}
|
||
|
||
describe('主机标签', () => {
|
||
it('有域名用域名,带端口', () => {
|
||
expect(hostLabel(meta())).toBe('example.com:443')
|
||
})
|
||
|
||
it('没域名退回 IP', () => {
|
||
expect(hostLabel(meta({ host: '' }))).toBe('93.184.216.34:443')
|
||
})
|
||
|
||
it('没端口就只给主机', () => {
|
||
expect(hostLabel(meta({ destinationPort: '' }))).toBe('example.com')
|
||
})
|
||
})
|
||
|
||
describe('出站链', () => {
|
||
it('倒过来显示:最先接触的在左,最终出口在右', () => {
|
||
expect(chainLabel(['[vless]某线路', 'AUTO', 'PROXY'])).toBe('PROXY → AUTO → [vless]某线路')
|
||
})
|
||
|
||
it('单跳就是直连', () => {
|
||
expect(chainLabel(['DIRECT'])).toBe('DIRECT')
|
||
})
|
||
})
|
||
|
||
describe('规则标签', () => {
|
||
it('类型和载荷用空格连起来', () => {
|
||
expect(ruleLabel('DomainSuffix', 'example.com')).toBe('DomainSuffix example.com')
|
||
})
|
||
|
||
it('兜底规则没有载荷', () => {
|
||
expect(ruleLabel('Match', '')).toBe('Match')
|
||
})
|
||
})
|
||
|
||
describe('持续时间', () => {
|
||
it('不满一分钟按秒', () => {
|
||
expect(formatDuration('2026-08-11T18:39:18+08:00', NOW)).toBe('42 秒')
|
||
})
|
||
|
||
it('满一分钟带分', () => {
|
||
expect(formatDuration('2026-08-11T18:36:36+08:00', NOW)).toBe('3 分 24 秒')
|
||
})
|
||
|
||
it('满一小时不再给秒', () => {
|
||
expect(formatDuration('2026-08-11T16:35:00+08:00', NOW)).toBe('2 小时 5 分')
|
||
})
|
||
|
||
it('start 带时区偏移,跨时区解析不能算错', () => {
|
||
// 同一时刻写成 UTC 和 +08:00,算出来的持续时间必须一样
|
||
const local = formatDuration('2026-08-11T18:39:00+08:00', NOW)
|
||
const utc = formatDuration('2026-08-11T10:39:00Z', NOW)
|
||
expect(local).toBe(utc)
|
||
})
|
||
|
||
it('坏数据返回空串,未来的时间按 0 算', () => {
|
||
expect(formatDuration('not-a-date', NOW)).toBe('')
|
||
expect(formatDuration('2026-08-11T19:00:00+08:00', NOW)).toBe('0 秒')
|
||
})
|
||
})
|
||
|
||
describe('排序', () => {
|
||
const list = [
|
||
conn({ id: 'small-new', download: 100, start: '2026-08-11T18:39:50+08:00' }),
|
||
conn({ id: 'big-new', download: 9000, start: '2026-08-11T18:39:40+08:00' }),
|
||
conn({ id: 'small-old', download: 50, start: '2026-08-11T18:00:00+08:00' }),
|
||
]
|
||
|
||
it('按下载量降序', () => {
|
||
expect(sortConnections(list, 'download', NOW).map((c) => c.id)).toEqual([
|
||
'big-new',
|
||
'small-new',
|
||
'small-old',
|
||
])
|
||
})
|
||
|
||
it('按持续时间降序:活得久的排前面', () => {
|
||
expect(sortConnections(list, 'duration', NOW).map((c) => c.id)).toEqual([
|
||
'small-old',
|
||
'big-new',
|
||
'small-new',
|
||
])
|
||
})
|
||
|
||
it('不动原数组', () => {
|
||
const before = list.map((c) => c.id)
|
||
sortConnections(list, 'download', NOW)
|
||
expect(list.map((c) => c.id)).toEqual(before)
|
||
})
|
||
})
|
||
|
||
describe('筛选', () => {
|
||
const list = [
|
||
conn({ id: 'a', metadata: meta({ host: 'speed.cloudflare.com' }) }),
|
||
conn({ id: 'b', metadata: meta({ host: '', destinationIP: '1.2.3.4' }) }),
|
||
]
|
||
|
||
it('匹配主机名,大小写不敏感', () => {
|
||
expect(filterConnections(list, 'CLOUDFLARE').map((c) => c.id)).toEqual(['a'])
|
||
})
|
||
|
||
it('没域名时也能按 IP 搜', () => {
|
||
expect(filterConnections(list, '1.2').map((c) => c.id)).toEqual(['b'])
|
||
})
|
||
|
||
it('空串不筛,空白也不算条件', () => {
|
||
expect(filterConnections(list, ' ')).toHaveLength(2)
|
||
})
|
||
})
|
||
|
||
describe('按入站聚合', () => {
|
||
it('同一路入站计数、流量都累加,连接多的排前面', () => {
|
||
const list = [
|
||
conn({ id: 'a', download: 10, upload: 1 }),
|
||
conn({ id: 'b', download: 20, upload: 2 }),
|
||
conn({ id: 'c', metadata: meta({ inboundName: 'lxc-db', inboundPort: '17892' }) }),
|
||
]
|
||
expect(groupByInbound(list)).toEqual([
|
||
{ label: 'lxc-web(17891)', count: 2, upload: 3, download: 30 },
|
||
{ label: 'lxc-db(17892)', count: 1, upload: 0, download: 0 },
|
||
])
|
||
})
|
||
|
||
it('默认入站用内核给的内部名,附上端口', () => {
|
||
const list = [conn({ metadata: meta({ inboundName: 'DEFAULT-MIXED', inboundPort: '17890' }) })]
|
||
expect(groupByInbound(list)[0]?.label).toBe('DEFAULT-MIXED(17890)')
|
||
})
|
||
|
||
it('入站名字缺失时用端口凑', () => {
|
||
const list = [conn({ metadata: meta({ inboundName: '' }) })]
|
||
expect(groupByInbound(list)[0]?.label).toBe('17891')
|
||
})
|
||
})
|