fix(admin): 局域网大屏卡片按「地址类别」判定 + 三层兜底 + 手填入口(dev_0.5.0)

## 判断失误的修正(主人现场实测出来的)
主人现场是 **169.254 自动配置网段**(没有 DHCP,设备靠链路本地地址互访),
平板实测能访问 `http://169.254.79.190:19540/display`;而卡片一开始把链路本地地址
**排除**了,于是显示「未检测到局域网地址」。
根因:`usableLanIPv4` 排除了 `IsLinkLocalUnicast()`,理由写的是「169.254 说明没拿到 IP」
——那是**对网络健不健康的猜测**,不是**地址能不能用的事实**。

现改为按**地址类别**判定(`ipv4Scope`,全部用标准库谓词,**代码里没有任何网段字面量**):
只排除「作为对端访问目标没有意义」的四类(回环 / 未指定 / 组播 / 广播),
链路本地与私网、全局一样进候选,只**排在最后**并带说明
「自动配置地址:本网段没有 DHCP,同网段设备可以互相访问」。
新增**反硬编码护栏用例**:函数体内不得出现 `x.x.x.x` 字面量,且必须用满 6 个谓词。
顺带修掉新用例抓出的真 bug:`ipv4Scope(nil)` 曾落进 default 被当成「可用的 global」,
而 IPv6 的 `To4()` 正是 nil → 「只有 IPv6 的网卡」会被误判成有可用地址。

## 三层兜底(离线现场必须拿得到地址)
1. **严格轮**:接口 up 且非回环;
2. **宽松轮**:严格轮为空时不再要求 up 位(某些驱动/虚拟网卡读不到),命中标 `confidence: low`;
3. **默认路由探测**:两轮皆空时用 `net.Dial("udp4", …)` 问出本机源地址
   (UDP connect 只查路由表、不握手不发包,离线安全;TCP 会等 SYN 超时,不能用),标 `confidence: route`。
接口同时回 `scope`(private/global/link-local)与 `skipped`(被跳过的网卡 + 原因,
code 收敛为 no-address / only-ipv6 / not-up / loopback——link-local 不再是「被跳过」的理由)。

## 手填入口(最后一道保险)
自动检测不到、或不是现场要用的网段时,操作员把 `ipconfig` 里的地址填进卡片,
立刻生成链接 + 二维码;排在第一条、带「手动」徽标、可删除、localStorage 持久化、
四条就地校验文案(空/含空格/不像 IP/重复),IPv6 也支持。

## 自检工具
`debug/诊断/局域网地址自检.exe` + 说明:只读、不写文件、不需管理员,
打印系统给 Go 看的网卡列表(含类别)、默认路由探测结果与可用大屏地址。
(正是它把现场定性成 169.254 网段的。)

## 真浏览器验证(四场景,非源码断言)
链接本地地址出链接+二维码+说明句 / 空态列出被跳过网卡并折叠(5 条 + 等共 N 块)/
low 与 route 两种来源提示各占一行互不顶掉 / 手填地址排第一并刷新不丢 —— 全部通过,0 页面错误。

## 文档
使用手册 §9、部署指南 7.5/7.9、上线检查清单(**防火墙放行入站 19540** 与
「另一台设备能打开局域网大屏」两条新增)、常见问题 Q101、升级说明 dev_0.5.0;
devdocs 05/07/13 同步(含这次误判的来龙去脉与「Get-NetAdapter 显示 Up ≠ 有 IP」)。
**没有数据迁移**,旧赛事包照常导入。

自检:服务端 13 包 ×2;前端 vue-tsc + 680 例 ×2 + build;Console 20 文件 + 68 例。
This commit is contained in:
AskaEth committed 2026-09-12 18:30:46 +08:00
1 parent 9ef68c2589
commit c97d3150c9
18 files changed
+2425 -79

No files matched your search

+79 -4
View File
@@ -30,12 +30,87 @@ const { ApiError, adminApi } = await import('../api/client')
const PAGE = readFileSync(new URL('../pages/DisplayPage.vue', import.meta.url), 'utf8')
test('卡片文案齐全:标题、一句话说明、复制反馈、扫码说明、空态', () => {
test('卡片文案齐全:标题、一句话说明、复制反馈、扫码说明、来源提示', () => {
assert.match(PAGE, /局域网大屏/)
assert.match(PAGE, /在别的设备浏览器打开下面的地址,就是一块大屏/)
assert.match(PAGE, /手机扫码直接打开/)
assert.match(PAGE, /已复制/)
assert.match(PAGE, /未检测到局域网地址(可能是没插网线 \/ 只有回环)/)
// 提示原文钉在 lanDisplay.ts(只有一处),页面只负责渲染 lanDisplayLinks 给的那句话:
// 页面自己判断 confidence 就会漏掉第三种来源(route)。
assert.match(PAGE, /\{\{ item\.hint \}\}/)
assert.doesNotMatch(PAGE, /LAN_LOW_CONFIDENCE_HINT|LAN_ROUTE_CONFIDENCE_HINT/)
})
test('空态文案由 lanEmptyNote 生成(逐条列原因),不写死在页面里', () => {
assert.match(PAGE, /from '\.\/lanDisplay'/)
assert.match(PAGE, /lanEmptyNote,/)
assert.match(PAGE, /<p v-else class="display-note">\{\{ lanEmptyText \}\}<\/p>/)
assert.match(PAGE, /const lanEmptyText = computed\(/)
// 老文案属于 lanDisplay.ts(老服务端分支),页面里不该再抄一份。
assert.doesNotMatch(PAGE, /class="display-note">未检测到局域网地址/)
})
test('来源提示只挂在有提示的那条地址旁(high 一句都不说)', () => {
// 提示挂在 item.hint 上,而 hint 由 lanDisplay 按来源给:high → null,low / route → 各自的句子。
assert.match(PAGE, /<p v-if="item\.hint !== null" class="display-note lan-uncertain">/)
// 链接与二维码不受来源影响:up 位读不到 / 路由推断出来的地址照常渲染。
assert.match(PAGE, /<a class="lan-link" :href="item\.url" target="_blank" rel="noopener">/)
assert.match(PAGE, /v-if="index === 0 && lanQrSvg !== null"/)
})
test('类别说明:只有 scopeHint 不为空的那条地址才出现(私网 / 全局没有)', () => {
assert.match(
PAGE,
/<p v-if="item\.scopeHint !== null" class="display-note lan-uncertain">\{\{ item\.scopeHint \}\}<\/p>/,
)
// 页面不自己判类别、更不按前缀猜:说明句由 lanDisplay 按服务端给的 scope 算好。
assert.doesNotMatch(PAGE, /link-local|169\.254/)
})
test('来源提示与类别说明是两条独立的行,不能互相顶掉', () => {
// 两条都必须是 v-if:写成 v-if + v-else 的话,一条地址明明两个提示都该给,却只会显示一个。
const hintIndex = PAGE.indexOf('{{ item.hint }}')
const scopeIndex = PAGE.indexOf('{{ item.scopeHint }}')
assert.ok(hintIndex > 0 && scopeIndex > hintIndex, '两条提示都要渲染,且各占一行')
assert.doesNotMatch(PAGE, /v-else[\s\S]{0,200}?\{\{ item\.scopeHint \}\}/)
})
test('手填入口:标题、副说明、输入框、添加按钮、就地红字', () => {
assert.match(PAGE, /手动添加地址/)
assert.match(PAGE, /自动检测不到时,把 ipconfig 里的 IPv4 地址填进来/)
assert.match(PAGE, /v-model="manualInput"/)
assert.match(PAGE, /@keyup\.enter="addManualAddress"/)
assert.match(PAGE, />添加<\/button>/)
// 非法输入必须当场红字说明,而不是点了没反应。
assert.match(PAGE, /v-if="manualError !== null" class="mx-helper mx-helper-error"/)
// 输入框本身也要标红:现场一眼看出是这一格填错了。
assert.match(PAGE, /:class="\{ 'mx-input-error': manualError !== null \}"/)
// 只在拼得出链接时给入口(端口没下发时填了也白填)。
assert.match(PAGE, /v-if="lanManualEnabled" class="lan-manual"/)
})
test('手填的地址带「手动」徽标、可删除,并写进 localStorage', () => {
assert.match(PAGE, /v-if="item\.manual" class="mx-badge mx-badge-neutral">手动</)
assert.match(PAGE, /@click="removeManualAddress\(item\.ip\)"/)
assert.match(PAGE, /const manualIps = ref<string\[\]>\(readManualAddresses\(manualAddressStorage\(\)\)\)/)
assert.match(PAGE, /writeManualAddresses\(manualAddressStorage\(\), manualIps\.value\)/)
// 删除只动手填列表;自动检测出来的地址不写回存储、也不受影响。
assert.match(PAGE, /manualIps\.value\.filter\(\(item\) => item !== ip\)/)
})
test('添加这条路真的会校验并持久化(只看「有 writeManualAddresses」会被别处的调用蒙混过去)', () => {
const addFn = /function addManualAddress\(\): void \{[\s\S]*?\n\}/.exec(PAGE)?.[0] ?? ''
assert.notEqual(addFn, '', '找不到 addManualAddress')
assert.match(addFn, /parseManualAddress\(manualInput\.value, manualIps\.value\)/)
assert.match(addFn, /writeManualAddresses\(manualAddressStorage\(\), manualIps\.value\)/)
// 校验不过必须当场返回,不能把非法值塞进列表。
assert.match(addFn, /if \(!result\.ok\) \{[\s\S]*?return/)
})
test('手填入口不依赖权限与示例数据:没有 gate / isMock 判断', () => {
const manualBlock = /<div v-if="lanManualEnabled" class="lan-manual">[\s\S]*?<\/div>\s*<\/div>/.exec(PAGE)?.[0] ?? ''
assert.notEqual(manualBlock, '')
assert.doesNotMatch(manualBlock, /lanGateReason|isMock|readOnly/)
})
test('链接本身就是可点的新窗口链接', () => {
@@ -43,8 +118,8 @@ test('链接本身就是可点的新窗口链接', () => {
})
test('页面的地址只来自 lanDisplayLinks(),不写死端口或路径', () => {
assert.match(PAGE, /import \{ copyText, lanDisplayLinks \} from '\.\/lanDisplay'/)
assert.match(PAGE, /lanDisplayLinks\(lanInfo\.value\)/)
assert.match(PAGE, /from '\.\/lanDisplay'/)
assert.match(PAGE, /lanDisplayLinks\(lanInfo\.value, manualIps\.value\)/)
// 19540 一旦出现在页面里,就说明有人在拼地址时替服务端做了决定。
assert.doesNotMatch(PAGE, /19540/)
})
+389 -2
View File
@@ -1,8 +1,28 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import type { NetworkInfo } from '@/shared'
import { copyText, displayUrl, lanDisplayLinks } from '../pages/lanDisplay'
import type { NetworkConfidence, NetworkInfo, NetworkSkipCode } from '@/shared'
import {
confidenceHint,
copyText,
displayUrl,
LAN_EMPTY_FALLBACK,
LAN_LINK_LOCAL_SCOPE_HINT,
LAN_LOW_CONFIDENCE_HINT,
LAN_ROUTE_CONFIDENCE_HINT,
LAN_SKIP_LIST_LIMIT,
lanDisplayLinks,
lanEmptyNote,
MANUAL_LAN_STORAGE_KEY,
MAX_MANUAL_ADDRESSES,
NETWORK_SKIP_REASON_LABELS,
parseManualAddress,
readManualAddresses,
scopeHint,
skipReasonLabel,
writeManualAddresses,
type ManualAddressStorage,
} from '../pages/lanDisplay'
/**
* 「局域网大屏」卡片的两条硬要求:
@@ -55,6 +75,373 @@ test('端口为 0 时给空数组,不拼 http://ip:0/display 这种点不开
assert.deepEqual(lanDisplayLinks({ ...NETWORK, port: 0 }), [])
})
test('低置信地址照常生成链接(藏起来等于把现场唯一能用的那条也拿走),并带上 confidence', () => {
const info: NetworkInfo = {
...NETWORK,
addresses: [
{ ip: '192.168.1.23', interface: '以太网', family: 'ipv4', confidence: 'low' },
{ ip: '10.0.8.7', interface: 'wlan0', family: 'ipv4' },
],
}
assert.deepEqual(lanDisplayLinks(info), [
{
ip: '192.168.1.23',
url: 'http://192.168.1.23:19540/display',
confidence: 'low',
manual: false,
hint: LAN_LOW_CONFIDENCE_HINT,
scope: null,
scopeHint: null,
},
// 老服务端没有 confidence / scope 字段:按 high,且不加任何提示。
{
ip: '10.0.8.7',
url: 'http://10.0.8.7:19540/display',
confidence: 'high',
manual: false,
hint: null,
scope: null,
scopeHint: null,
},
])
})
test('路由推断出来的地址标 route,并给一句自己的提示', () => {
const info: NetworkInfo = {
...NETWORK,
addresses: [{ ip: '192.168.5.9', interface: '默认路由', family: 'ipv4', confidence: 'route', scope: 'private' }],
}
assert.deepEqual(lanDisplayLinks(info), [
{
ip: '192.168.5.9',
url: 'http://192.168.5.9:19540/display',
confidence: 'route',
manual: false,
hint: LAN_ROUTE_CONFIDENCE_HINT,
scope: 'private',
scopeHint: null,
},
])
})
test('来源提示:只有不常规的来源才说话,high 一句都不多说', () => {
assert.equal(confidenceHint('high'), null)
assert.equal(confidenceHint('low'), LAN_LOW_CONFIDENCE_HINT)
assert.equal(confidenceHint('route'), LAN_ROUTE_CONFIDENCE_HINT)
// 三条文案必须互不相同:route 的地址被说成「网卡状态读不到」会把人引到完全错的方向。
assert.notEqual(LAN_ROUTE_CONFIDENCE_HINT, LAN_LOW_CONFIDENCE_HINT)
assert.equal(LAN_ROUTE_CONFIDENCE_HINT, '这条地址是按系统默认路由推断出来的(网卡枚举里没有它),地址本身可用,打不开就换下面一条')
})
// ── 地址类别(scope)────────────────────────────────────────────────────
test('自动配置地址给一句说明,私网 / 全局 / 未知都不给', () => {
assert.equal(LAN_LINK_LOCAL_SCOPE_HINT, '自动配置地址:本网段没有 DHCP,同网段设备可以互相访问')
assert.equal(scopeHint('link-local'), LAN_LINK_LOCAL_SCOPE_HINT)
assert.equal(scopeHint('private'), null)
assert.equal(scopeHint('global'), null)
assert.equal(scopeHint(null), null)
})
test('主人现场那条地址:自动配置地址照常给链接、二维码位置在第一条,并带上说明', () => {
const info: NetworkInfo = {
...NETWORK,
addresses: [
{ ip: '192.168.1.23', interface: 'WLAN', family: 'ipv4', confidence: 'high', scope: 'private' },
{ ip: '169.254.79.190', interface: '以太网', family: 'ipv4', confidence: 'high', scope: 'link-local' },
],
}
const links = lanDisplayLinks(info)
assert.deepEqual(
links.map((link) => [link.ip, link.scope, link.scopeHint]),
[
['192.168.1.23', 'private', null],
// 私网在前、自动配置在后;后者照常是可点的链接,只是多一句说明。
['169.254.79.190', 'link-local', LAN_LINK_LOCAL_SCOPE_HINT],
],
)
assert.equal(links[1]!.url, 'http://169.254.79.190:19540/display')
})
test('来源提示与类别提示互不顶掉:同一条地址两条都给', () => {
const info: NetworkInfo = {
...NETWORK,
addresses: [{ ip: '169.254.79.190', interface: '以太网', family: 'ipv4', confidence: 'low', scope: 'link-local' }],
}
const link = lanDisplayLinks(info)[0]!
assert.equal(link.hint, LAN_LOW_CONFIDENCE_HINT)
assert.equal(link.scopeHint, LAN_LINK_LOCAL_SCOPE_HINT)
})
test('scope 认不出(老服务端)时不给类别说明,也不按前缀自己猜', () => {
const info: NetworkInfo = {
...NETWORK,
// 老服务端不返回 scope:即使地址恰好长得像自动配置地址,也不加说明(分类是服务端的知识)。
addresses: [{ ip: '169.254.79.190', interface: '以太网', family: 'ipv4', confidence: 'high' }],
}
const link = lanDisplayLinks(info)[0]!
assert.equal(link.scope, null)
assert.equal(link.scopeHint, null)
})
test('低置信提示的文案只有一处', () => {
assert.equal(LAN_LOW_CONFIDENCE_HINT, '网卡状态读不到(可能是驱动或虚拟网卡),地址本身可用,打不开就换下面一条')
})
test('空态逐条列出被跳过的网卡与原因', () => {
const info: NetworkInfo = {
...NETWORK,
addresses: [],
skipped: [
{ interface: '以太网', code: 'not-up', addresses: ['10.0.0.10/24'] },
{ interface: 'WLAN', code: 'only-ipv6', addresses: ['fe80::1/64'] },
],
}
assert.equal(
lanEmptyNote(info),
'没找到可用的局域网地址。已检查:以太网(接口未连接)、WLAN(只有 IPv6 地址)。',
)
})
test('空态:服务端没给 skipped(老版本)时退回原来那句话', () => {
assert.equal(lanEmptyNote({ ...NETWORK, addresses: [] }), LAN_EMPTY_FALLBACK)
assert.equal(lanEmptyNote({ ...NETWORK, addresses: [], skipped: [] }), LAN_EMPTY_FALLBACK)
})
test('空态:网卡名缺失时也不能拼出「()」', () => {
const info: NetworkInfo = {
...NETWORK,
addresses: [],
skipped: [{ interface: '', code: 'no-address', addresses: [] }],
}
assert.equal(lanEmptyNote(info), '没找到可用的局域网地址。已检查:未知网卡(接口没有地址)。')
})
test('原因映射覆盖全部 code(一个都不能漏)与未知 code 的兜底', () => {
// 集合里没有 link-local:自动配置地址现在是候选地址,不再是「被跳过」的理由。
const codes: NetworkSkipCode[] = ['no-address', 'only-ipv6', 'not-up', 'loopback']
// 表本身必须与 code 集合完全对应:多一个少一个都在这里红灯。
assert.deepEqual(Object.keys(NETWORK_SKIP_REASON_LABELS).sort(), [...codes].sort())
for (const code of codes) {
const label = skipReasonLabel(code)
assert.equal(label, NETWORK_SKIP_REASON_LABELS[code])
assert.notEqual(label.trim(), '')
// 未知 code 之外不能落到兜底话术上,否则「漏了 code」会被兜底悄悄盖住。
assert.doesNotMatch(label, /未知原因/)
}
// 服务端比前端新:认不出的 code 也要说人话,不能留空白。
assert.equal(skipReasonLabel('brand-new-code'), '未知原因(brand-new-code)')
// 链路本地不再是跳过原因:旧服务端万一还发这个 code,也不能显示成「地址不能用」那一类说法。
assert.notEqual(skipReasonLabel('link-local'), '只有自动配置地址(169.254.x)')
})
test('每条地址的置信度只可能是 high / low / route', () => {
const info: NetworkInfo = {
...NETWORK,
addresses: [{ ip: '10.0.0.4', interface: 'ZeroTier', family: 'ipv4' }],
}
const confidence: NetworkConfidence = lanDisplayLinks(info)[0]!.confidence
assert.equal(confidence, 'high')
})
// ── 空态的折叠 ────────────────────────────────────────────────────────────
test('空态最多列 5 张网卡,其余的折叠成「等共 N 块网卡」', () => {
const many: NetworkInfo = {
...NETWORK,
addresses: [],
skipped: Array.from({ length: 30 }, (_, index) => ({
interface: `vEthernet ${index + 1}`,
code: 'no-address' as const,
addresses: [],
})),
}
const note = lanEmptyNote(many)
// 只有前 5 张带原因,第 6 张的接口名不能出现(否则等于没折叠)。
for (let index = 1; index <= LAN_SKIP_LIST_LIMIT; index += 1) {
assert.match(note, new RegExp(`vEthernet ${index}(接口没有地址)`))
}
assert.doesNotMatch(note, /vEthernet 6(/)
assert.match(note, /等共 30 块网卡。$/)
assert.equal(
note,
'没找到可用的局域网地址。已检查:vEthernet 1(接口没有地址)、vEthernet 2(接口没有地址)、' +
'vEthernet 3(接口没有地址)、vEthernet 4(接口没有地址)、vEthernet 5(接口没有地址)等共 30 块网卡。',
)
})
test('空态:不超过 5 张时不折叠(不出现「等共」)', () => {
const info: NetworkInfo = {
...NETWORK,
addresses: [],
skipped: Array.from({ length: LAN_SKIP_LIST_LIMIT }, (_, index) => ({
interface: `网卡${index + 1}`,
code: 'loopback' as const,
addresses: [],
})),
}
assert.doesNotMatch(lanEmptyNote(info), /等共/)
// 恰好 5 张也要一张不漏地列出来。
assert.match(lanEmptyNote(info), /网卡5(回环接口)。$/)
})
// ── 手填地址 ──────────────────────────────────────────────────────────────
test('手填地址:合法 IP 排在自动检测结果前面,带「手动」标记与链接', () => {
const links = lanDisplayLinks(NETWORK, ['192.168.9.9'])
assert.deepEqual(links, [
{
ip: '192.168.9.9',
url: 'http://192.168.9.9:19540/display',
confidence: 'high',
manual: true,
// 手填的是操作员自己的判断,不需要额外解释来源;类别也不猜(scope 为 null)。
hint: null,
scope: null,
scopeHint: null,
},
{
ip: '192.168.1.23',
url: 'http://192.168.1.23:19540/display',
confidence: 'high',
manual: false,
hint: null,
scope: null,
scopeHint: null,
},
{
ip: '10.0.8.7',
url: 'http://10.0.8.7:19540/display',
confidence: 'high',
manual: false,
hint: null,
scope: null,
scopeHint: null,
},
])
})
test('手填地址:多个按添加顺序排在前面,IPv6 拼 URL 带方括号', () => {
const links = lanDisplayLinks(NETWORK, ['10.1.1.1', 'fe80::1'])
assert.deepEqual(
links.map((link) => link.ip),
['10.1.1.1', 'fe80::1', '192.168.1.23', '10.0.8.7'],
)
assert.equal(links[1]!.url, 'http://[fe80::1]:19540/display')
assert.equal(links[1]!.manual, true)
})
test('手填地址:与自动检测重复时只留手填的那条(同一个链接不出现两次)', () => {
const links = lanDisplayLinks(NETWORK, ['192.168.1.23'])
assert.deepEqual(
links.map((link) => [link.ip, link.manual]),
[
['192.168.1.23', true],
['10.0.8.7', false],
],
)
})
test('手填地址:端口没下发时连手填的也拼不出链接(不留半条能点的地址)', () => {
assert.deepEqual(lanDisplayLinks({ ...NETWORK, port: 0 }, ['192.168.9.9']), [])
})
test('手填校验:合法 IPv4 / IPv6 收下,非法输入给红字且不添加', () => {
assert.deepEqual(parseManualAddress('192.168.1.23'), { ok: true, ip: '192.168.1.23' })
// 从 ipconfig 复制粘贴常带首尾空格:去掉后就合法。
assert.deepEqual(parseManualAddress(' 192.168.1.23 '), { ok: true, ip: '192.168.1.23' })
// IPv6 允许,并统一成小写压缩写法(去重才靠得住)。
assert.deepEqual(parseManualAddress('FE80::0001'), { ok: true, ip: 'fe80::1' })
assert.deepEqual(parseManualAddress('::1'), { ok: true, ip: '::1' })
const bad: Array<[string, RegExp]> = [
['', /请先填一个 IP 地址/],
[' ', /请先填一个 IP 地址/],
['192.168.1.23 10.0.0.1', /不能有空格/],
['192.168.1.2 3', /不能有空格/],
['192.168.1', /不像一个 IP 地址/],
['999.168.1.1', /不像一个 IP 地址/],
['192.168.1.256', /不像一个 IP 地址/],
['abc', /不像一个 IP 地址/],
['not:an:ip', /不像一个 IP 地址/],
['fe80::1%eth0', /不像一个 IP 地址/],
]
for (const [raw, message] of bad) {
const result = parseManualAddress(raw)
assert.equal(result.ok, false, `${raw} 不该通过校验`)
if (!result.ok) assert.match(result.error, message)
}
})
test('手填校验:重复的地址不添加,并说清原因', () => {
const result = parseManualAddress('10.0.0.4', ['192.168.1.23', '10.0.0.4'])
assert.equal(result.ok, false)
if (!result.ok) assert.match(result.error, /已经在列表里/)
})
test('手填持久化:localStorage 往返,坏数据一律不认', () => {
const store = fakeStorage()
assert.deepEqual(readManualAddresses(store), [])
writeManualAddresses(store, ['192.168.9.9', '10.1.1.1'])
assert.equal(store.value(MANUAL_LAN_STORAGE_KEY), '["192.168.9.9","10.1.1.1"]')
assert.deepEqual(readManualAddresses(store), ['192.168.9.9', '10.1.1.1'])
// 脏数据:不是数组 / 元素不是字符串 / 不是 IP / 重复 —— 全部丢掉,不能让页面崩。
store.setValue(MANUAL_LAN_STORAGE_KEY, '{"not":"an array"}')
assert.deepEqual(readManualAddresses(store), [])
store.setValue(MANUAL_LAN_STORAGE_KEY, '{ 这不是 json')
assert.deepEqual(readManualAddresses(store), [])
store.setValue(MANUAL_LAN_STORAGE_KEY, JSON.stringify(['192.168.1.23', 42, 'abc', ' ', '192.168.1.23']))
assert.deepEqual(readManualAddresses(store), ['192.168.1.23'])
// 超量的旧数据要在读的时候就被截断,不然键里会一直堆着几十条。
store.setValue(
MANUAL_LAN_STORAGE_KEY,
JSON.stringify(Array.from({ length: MAX_MANUAL_ADDRESSES + 5 }, (_, index) => `10.0.0.${index + 1}`)),
)
assert.equal(readManualAddresses(store).length, MAX_MANUAL_ADDRESSES)
})
test('手填持久化:存储不可用(隐私模式 / 配额满)时静默降级,不抛异常', () => {
assert.deepEqual(readManualAddresses(null), [])
writeManualAddresses(null, ['192.168.1.23'])
const throwing: ManualAddressStorage = {
getItem: () => {
throw new Error('SecurityError')
},
setItem: () => {
throw new Error('QuotaExceededError')
},
}
assert.deepEqual(readManualAddresses(throwing), [])
writeManualAddresses(throwing, ['192.168.1.23'])
})
/** 内存版 localStorage:node 里没有浏览器存储,存取两条路都要能测。 */
function fakeStorage(): ManualAddressStorage & { value(key: string): string | null; setValue(key: string, raw: string): void } {
const data = new Map<string, string>()
return {
getItem: (key) => data.get(key) ?? null,
setItem: (key, value) => {
data.set(key, value)
},
value: (key) => data.get(key) ?? null,
setValue: (key, raw) => {
data.set(key, raw)
},
}
}
test('复制:navigator.clipboard 不存在时走到 execCommand 回退', async () => {
const seen: string[] = []
const ok = await copyText('http://192.168.1.23:19540/display', {