Files
drouter/_dev/t-update-run.py
liuzhuohua b7ca9c89e8 feat(i18n/layout): 全站中英双语 + 四轮排版修复收官 + 修 boot_id 重启检测
v1.0.10 两件大事:

1. 界面中英双语(英文界面无中文残留)
   - 新增 web/i18n.js(约 4200 条词条),零依赖零构建
   - 后端下发中文数据走 bt4() 查表翻译;独立模块(upstream/netdetail/
     realtime/update)纳入翻译并注册切语言重绘钩子
   - 后端「状态/聚合」接口只回稳定枚举+原始数值,不下发中文文案
   - 顺带修一个真实 XSS:顶层 const 不挂 window,独立脚本 esc 静默退化成零转义

2. 修掉「文字竖着排/错位」(多分辨率反复暴露)
   - 单元格 min-content 只有 1 字宽 → word-break:keep-all + 表头 nowrap
   - 依赖卡片正文被标签/按钮挤成 27px → .dep-body{flex:1 1 200px} + flex-wrap
   - 裸 flex:1(下限为0)给正文 → 一律改 flex:1 1 <px>
   - .dep-item 横向容器直接塞块级子项 → 退回块级
   - 关键陷阱:auto-fill 布局屏越宽列数越多单卡越窄,窄屏反而测不出来,
     真机审计推进到「逐视图点按钮 + 1920/2560 宽视口 + 中英双语」
   - 守卫 t-layout.py 扩到 35 条,12 处反向验证全红

3. 修真缺陷:_link_since() 重启检测被误改成 if False:(注释还写着「已修」),
   uptime 归零后相减得负数 → 恢复比对 boot_id

附带:新增 25 个检查器(t-*.py)、排版审计工具链(shot-*/ocr-*/live-*)、
版本号兜底值同步到 1.0.10、.gitignore 收纳 i18n/排版排查的一次性脚本。
2026-10-10 04:50:32 +08:00

188 lines
7.1 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""真跑验证:drouter-update 模块。
(原 t-update-run.py 的一半 —— passwall 相关用例已随功能废弃删掉。)
判据只能证明「文本里写了某句话」,证明不了「跑起来是那样」。
这里真的 import 模块、真的调函数、看真的返回值。
不碰网络的部分全部真跑。
"""
import os
import sys
import shutil
import hashlib
import tempfile
import importlib.util
HERE = os.path.dirname(os.path.abspath(__file__))
BACKEND = os.path.join(HERE, '..', 'backend')
def load(name):
"""按路径加载 —— 文件名带连字符,import 用不了。
与 drouter-web.py 的 _load_mod 是同一套做法。
"""
spec = importlib.util.spec_from_file_location(
name.replace('-', '_'), os.path.join(BACKEND, name + '.py'))
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
return m
U = load('drouter-update')
SRC = open(os.path.join(BACKEND, 'drouter-update.py'), encoding='utf-8').read()
fails = []
n = 0
def ck(desc, cond, extra=''):
global n
n += 1
if cond:
print('ok %s' % desc)
else:
print('FAIL %s %s' % (desc, extra))
fails.append(desc)
print('=== 1. 版本号解析与比较(真算) ===')
ck('parse_ver 1.0.9', U.parse_ver('v1.0.9') == (1, 0, 9))
ck('parse_ver 两段', U.parse_ver('1.1') == (1, 1, 0))
ck('parse_ver 垃圾输入返回空', U.parse_ver('abc') == ())
ck('parse_ver None 不炸', U.parse_ver(None) == ())
ck('parse_ver 1.0.10 三段', U.parse_ver('v1.0.10') == (1, 0, 10))
# 真实踩过的坑:字符串比较会把 1.0.10 判成比 1.0.9 旧
ck('1.0.10 > 1.0.9(字符串比较会错,元组才对)',
U.newer_than('v1.0.10', '1.0.9') is True)
ck('1.0.9 < 1.0.10', U.newer_than('v1.0.9', '1.0.10') is False)
ck('同版本不算有更新', U.newer_than('v1.0.9', '1.0.9') is False)
ck('1.1.0 > 1.0.99', U.newer_than('v1.1.0', '1.0.99') is True)
ck('1.10.0 > 1.9.0', U.newer_than('v1.10.0', '1.9.0') is True)
ck('解析不了就不谎报', U.newer_than('garbage', '1.0.9') is False)
ck('本地版本解析不了也不谎报', U.newer_than('v1.0.9', 'x') is False)
print()
print('=== 2. 附件名生成(真算) ===')
al = U.asset_list('v1.0.10')
ck('三个附件', len(al) == 3, str(len(al)))
ck('deb 名带版本', al[0]['name'] == 'drouter_1.0.10_all.deb', al[0]['name'])
ck('离线包名带版本',
al[1]['name'] == 'drouter-1.0.10-offline-amd64.tar.gz', al[1]['name'])
ck('镜像名带版本', al[2]['name'] == 'drouter-1.0.10-docker.tar', al[2]['name'])
ck('传入不带 v 也能用',
U.asset_list('1.0.10')[0]['name'] == 'drouter_1.0.10_all.deb')
ck('每个附件都有中文说明', all(a['desc'] for a in al))
print()
print('=== 3. 下载 URL 一定不含代理(真算) ===')
for v in ('1.0.9', '1.0.10', '2.0.0'):
for a in U.asset_list(v):
url = U.ASSET_TMPL.format(repo=U.REPO, ver=v, name=a['name'])
ck('下载 URL 走官方:%s@%s' % (a['key'], v),
url.startswith('https://github.com/')
and 'proxy' not in url and 'kkgithub' not in url, url)
print()
print('=== 4. 探测源清单(真读) ===')
ck('有 3 个探测源', len(U.PROBES) == 3, str(len(U.PROBES)))
ck('官方源排第一', 'api.github.com' in U.PROBES[0][1], U.PROBES[0][0])
ck('探测源与下载源是两个独立列表(1.0.10 改)',
len(U.PROBES[0]) == 2, 'PROBES 元素应为 (名称, URL) 两元组')
ck('下载源链存在且官方排第一',
len(U.DL_SOURCES) >= 2 and '官方' in U.DL_SOURCES[0][0],
str([d[0] for d in U.DL_SOURCES]))
ck('下载源里至少有 gh-proxy 镜像(大陆可达)',
any('gh-proxy.com' in d[1] for d in U.DL_SOURCES),
str([d[0] for d in U.DL_SOURCES]))
ck('下载源条目也是两元组', all(len(d) == 2 for d in U.DL_SOURCES))
ck('仓库名正确', U.REPO == 'liuzhuohua/drouter', U.REPO)
print()
print('=== 5. 状态文件读写(真落盘) ===')
tmpd = tempfile.mkdtemp(prefix='updtest')
# ⚠️ 三个坑叠在一起,本条判据第一版栽了两次:
# ① 路径不变式必须在**改 STATE_DIR 之前**断言 —— CACHE_FILE/STATE_FILE
# 是模块加载时算出来的,运行时改 STATE_DIR 不影响它们。
# ② 不能用 startswith(STATE_DIR) —— Windows 上 os.path.join 用反斜杠,
# '/var/lib/drouter/update/check-cache.json'.startswith(STATE_DIR) 仍成立,
# 但反过来比较字符串常量会因分隔符不同而假红。
# ③ 要比就比**规范化后**的路径。
ck('缓存文件在默认状态目录下',
os.path.normpath(U.CACHE_FILE)
== os.path.normpath('/var/lib/drouter/update/check-cache.json'),
U.CACHE_FILE)
ck('状态文件在默认状态目录下',
os.path.normpath(U.STATE_FILE)
== os.path.normpath('/var/lib/drouter/update/state.json'),
U.STATE_FILE)
ck('日志文件在默认状态目录下',
os.path.normpath(U.LOG_FILE)
== os.path.normpath('/var/lib/drouter/update/update.log'),
U.LOG_FILE)
U.STATE_DIR = tmpd
U.STATE_FILE = os.path.join(tmpd, 'state.json')
U._write_state(running=True, phase='download', pct=42)
st = U.read_state()
ck('状态可回读', st.get('pct') == 42 and st.get('phase') == 'download', str(st))
ck('状态含 running', st.get('running') is True)
U._write_state(running=False, phase='done', pct=100, msg_cn='完成')
ck('状态可覆盖', U.read_state().get('msg_cn') == '完成')
ck('没有 .part 残留', not os.path.exists(U.STATE_FILE + '.part'))
U.STATE_DIR = '/var/lib/drouter/update' # 还原,避免影响后续用例
print()
print('=== 6. 参数校验(不该启动的不启动) ===')
ck('垃圾版本号被拒', U.apply_update('abc', 'deb')['ok'] is False)
ck('空版本号被拒', U.apply_update('', 'deb')['ok'] is False)
ck('未知附件被拒', U.apply_update('1.0.9', 'evil')['ok'] is False)
ck('垃圾版本号给出中文原因',
'版本号格式不对' in U.apply_update('abc', 'deb')['msg_cn'])
ck('未知附件给出中文原因',
'未知的附件类型' in U.apply_update('1.0.9', 'evil')['msg_cn'])
ck('并发守卫存在', '已有更新任务在进行中' in SRC)
print()
print('=== 7. 中断能力(真跑) ===')
U._stop_flag.set()
ck('停止标志可设置', U._stop_flag.is_set())
U._stop_flag.clear()
ck('停止标志可清除', not U._stop_flag.is_set())
ck('无任务时取消返回失败', U.cancel()['ok'] is False)
ck('取消原因可读', '没有进行中的任务' in U.cancel()['msg_cn'])
print()
print('=== 8. sha256 真算正确性 ===')
probe = os.path.join(tmpd, 'probe.bin')
data = b'drouter-update-selftest' * 1000
with open(probe, 'wb') as f:
f.write(data)
ck('_sha256 与 hashlib 一致', U._sha256(probe) == hashlib.sha256(data).hexdigest())
# 空文件也要能算(不炸)
empty = os.path.join(tmpd, 'empty.bin')
open(empty, 'wb').close()
ck('_sha256 空文件 = 已知值',
U._sha256(empty) == hashlib.sha256(b'').hexdigest())
os.remove(probe)
os.remove(empty)
print()
print('=== 9. 清理 ===')
try:
shutil.rmtree(tmpd)
ok = True
except Exception:
ok = False
ck('临时目录可清理', ok)
print()
print('=' * 56)
print('真跑验证 %d 条,失败 %d 条' % (n, len(fails)))
for f in fails:
print(' FAIL: %s' % f)
sys.exit(1 if fails else 0)