fix(权限): 409 的第二种含义是「本档不该问」——四桥都补上;状态写入点不再兜默认档
线上事故(jianf 经 pi 转达):补投路径漏传 permission_mode,插件拿 undefined 兜了 workspace 档,把 full 档会话写成 workspace-write + ask —— 不是"拦一次",是一整轮 工具能力降级,且状态留在会话里;随后该会话每次受守卫调用都撞 409。 四件事: 1. **状态写入点不接受默认值**(新增共享 `modeForStateWrite`):缺字段/脏值 → `null` = 不写状态。"默认值可以出现在**决策**里,不可以出现在**状态写入**里。" 同时保留共享契约的 fail-closed:真读到 workspace 才写 workspace。 2. **409 的两种含义分开处理**。`allowed-once` 只绕过**审批**,改不了**沙箱** —— 所以 dsh 桥在放行前先把服务端给的权威档位**写回会话**(这也就成了自愈路径: 已经降级的会话,下一次带档位的 409 会把它修回来);只认服务端明说的 full, plan 与"链上没有人类"照旧 fail closed。 3. **同一处缺陷在 zcode / opencode 也在**(`hooks/permission.mjs` 与 `index.js` 都把 409 当永久失败拒绝)。我先前在回信里写过"这两个桥不转发权限询问,不需要改" —— 那句话是错的,我当时的搜索面只有 `<plugin>/src/*.mjs`。按 pi 的要求把这条 **否定性事实变成常驻判据**后,它第一次运行就红给我看。四桥现在都有 「409 + full → 放行」,且**排在永久失败分支之前**(含顺序变异自检)。 4. **共用测试重新同源**:`test/catchup.test.mjs` 从 `153985e` 起就是分叉的 (我那版把平台专属路径写进了共用文件),而 `deploy/install.sh` 第 24 行会跑 `check-shared-libs.sh` —— 也就是说**部署一直是红的**,我没跑过那个脚本。 共用文件只放契约(值/行为),跨平台配对judge 移到平台专属文件,四份逐字节相同。 另外把"判代码 vs 判理由"从记忆变成代码:`test/lib/read.mjs` 提供 `code()/prose()/bytes()`, 判据目录里不得再裸用 `readFileSync`(新判据 `criteria-hygiene` 管,含读取器自检)。 判据证据(每条都做过"能不能红"的变异): - 写回去掉 → 红;纠正块挪到普通 409 之后 → 红;状态写入点退回兜默认 → 红; - zcode/opencode 的放行分支拿掉 → 各自红;共用测试分叉 → check-shared-libs 红。 各套件:dsh 388、pi 443、zcode 387、opencode 333(均经 npm test,含 tsc); electron `npm test` 15/15 判据绿 + vitest 266 + typecheck;`check-shared-libs.sh` 退出 0; Go `go test ./...` 全 ok。
This commit is contained in:
@ -12,7 +12,7 @@
|
||||
// 于是**谁先同步谁决定**。24/8 与 12/4 的差别不是审美,是同一个账号在不同客户端
|
||||
// 登录会得到不同的压暗强度。
|
||||
// 3. 缓存键是"换账号串味"的成因(全局键 → saved=false 时把上一个账号的外观推上去)。
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { code, prose } from './lib/read.mjs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { test } from 'node:test';
|
||||
@ -26,7 +26,7 @@ const ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
|
||||
|
||||
/** 从 Go 源码里读服务端契约(不复制一份数字到判据里 —— 那又会变成"两处各写一套") */
|
||||
function serverDefaults() {
|
||||
const src = readFileSync(SERVER_MODELS, 'utf8');
|
||||
const src = prose(SERVER_MODELS);
|
||||
// 只取 DefaultAppearance 函数体,避免匹配到别的结构体字面量
|
||||
const at = src.indexOf('func DefaultAppearance()');
|
||||
assert.ok(at > 0, '服务端要有 DefaultAppearance()');
|
||||
@ -97,18 +97,18 @@ test('★ 默认外观 = 服务端契约(去 Go 源码里读,不在判据里
|
||||
assert.deepEqual([s.dim, s.blur], [12, 4], '服务端 DefaultAppearance 是 12/4(权威值)');
|
||||
|
||||
// WebUI:默认值必须来自共享常量,且等于服务端
|
||||
const defaults = readFileSync(join(ROOT, 'client/electron/src/lib/appearanceDefaults.ts'), 'utf8');
|
||||
const defaults = code(join(ROOT, 'client/electron/src/lib/appearanceDefaults.ts'));
|
||||
assert.match(defaults, new RegExp(`DEFAULT_DIM = ${s.dim}\\b`), `DEFAULT_DIM 要等于服务端的 BgDim=${s.dim}`);
|
||||
assert.match(defaults, new RegExp(`DEFAULT_BLUR = ${s.blur}\\b`), `DEFAULT_BLUR 要等于服务端的 BgBlur=${s.blur}`);
|
||||
// 严格:store 里不许再自写一套数字(原来这里写的是 24/8,与服务端不一致)
|
||||
const store = readFileSync(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'), 'utf8');
|
||||
const store = code(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'));
|
||||
const defBlock = /export const DEFAULT_BACKGROUND: BackgroundState = \{[\s\S]*?\};/.exec(store);
|
||||
assert.ok(defBlock, 'DEFAULT_BACKGROUND 要能取到');
|
||||
assert.match(defBlock[0], /dim:\s*DEFAULT_DIM/, 'dim 要引用共享常量,不许自写字面量');
|
||||
assert.match(defBlock[0], /blur:\s*DEFAULT_BLUR/, 'blur 要引用共享常量');
|
||||
assert.ok(!/dim:\s*\d/.test(defBlock[0]), `DEFAULT_BACKGROUND 里不许再出现数字字面量(现在:${defBlock[0].replace(/\s+/g, ' ')})`);
|
||||
// 同步层的兜底值也要引用同一常量(原来 clamp(..., 12, 4) 是另写的一份)
|
||||
const lib = readFileSync(join(ROOT, 'client/electron/src/lib/appearance.ts'), 'utf8');
|
||||
const lib = code(join(ROOT, 'client/electron/src/lib/appearance.ts'));
|
||||
const clamps = [...lib.matchAll(/clamp\([^)]*\)/g)].map(m => m[0]);
|
||||
assert.ok(clamps.length >= 4, `clamp 调用要能取到(实际 ${clamps.length} 处)`);
|
||||
for (const c of clamps) {
|
||||
@ -116,23 +116,23 @@ test('★ 默认外观 = 服务端契约(去 Go 源码里读,不在判据里
|
||||
}
|
||||
|
||||
// 鸿蒙:默认值(在 AppearanceResponse 的字段默认值里)与预设也要与服务端对得上
|
||||
const ap = readFileSync(join(ETS, 'model/Appearance.ts'), 'utf8');
|
||||
const ap = code(join(ETS, 'model/Appearance.ts'));
|
||||
assert.match(ap, new RegExp(`bg_dim: number = ${s.dim}\\b`), `鸿蒙默认压暗要等于服务端 BgDim=${s.dim}`);
|
||||
assert.match(ap, new RegExp(`bg_blur: number = ${s.blur}\\b`), `鸿蒙默认模糊要等于服务端 BgBlur=${s.blur}`);
|
||||
assert.match(ap, new RegExp(`theme: string = '${s.theme}'`), `鸿蒙默认主题要等于服务端 Theme=${s.theme}`);
|
||||
const wp = readFileSync(join(ETS, 'model/Wallpaper.ts'), 'utf8');
|
||||
const wp = code(join(ETS, 'model/Wallpaper.ts'));
|
||||
assert.match(wp, new RegExp(`'${s.preset}'`), `服务端默认预设 ${s.preset} 要在鸿蒙的预设清单里`);
|
||||
|
||||
// 这句注释在**这份源码**里现在是事实(它宣称"与客户端默认值一致")—— 这条就是它的核对器。
|
||||
// (运行时是否一致不由此判据保证:见文件头第 1 条的措辞说明。)
|
||||
const models = readFileSync(SERVER_MODELS, 'utf8');
|
||||
const models = prose(SERVER_MODELS);
|
||||
const comment = /\/\/ DefaultAppearance[\s\S]{0,200}?func DefaultAppearance/.exec(models);
|
||||
assert.ok(comment, 'DefaultAppearance 上面要有说明注释');
|
||||
assert.match(comment[0], /一致/, '注释仍在宣称"与客户端默认值一致"(本判据负责让它为真)');
|
||||
});
|
||||
|
||||
test('★ 缓存键按账号分:两端的键都带账号,且都不许退回全局键', () => {
|
||||
const store = readFileSync(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'), 'utf8');
|
||||
const store = code(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'));
|
||||
// 键函数:必须把 accountId 拼进去(按块取函数体,不看调用点 —— "配对/解析"那条)
|
||||
const at = store.indexOf('export function storageKey(');
|
||||
assert.ok(at > 0, '要有 storageKey 函数');
|
||||
@ -201,7 +201,7 @@ test('★ 缓存键按账号分:两端的键都带账号,且都不许退回
|
||||
assert.match(store, /localStorage\.setItem\(storageKey\(\), JSON\.stringify\(state\)\)/, '写缓存要按账号的键');
|
||||
|
||||
// 鸿蒙侧:键同样带账号(两边形状一致,这条差异已经消除)
|
||||
const ets = readFileSync(join(ETS, 'common/AppearanceStore.ets'), 'utf8');
|
||||
const ets = code(join(ETS, 'common/AppearanceStore.ets'));
|
||||
const eAt = ets.indexOf('prefKey(accountId: string)');
|
||||
assert.ok(eAt > 0, '鸿蒙要有按账号取键的函数');
|
||||
let d2 = 0;
|
||||
@ -224,7 +224,7 @@ test('★ 切账号的顺序:**先按新账号重读本地**,再拉服务端
|
||||
* 服务端"没有记录"时 `pull()` 会"以本地为准推上去"——所以重读必须在前,
|
||||
* 否则推上去的是上一个账号的外观(全局键时代就是这个现象,而且写进了服务端)。
|
||||
*/
|
||||
const sync = readFileSync(join(ROOT, 'client/electron/src/stores/appearanceSync.ts'), 'utf8');
|
||||
const sync = code(join(ROOT, 'client/electron/src/stores/appearanceSync.ts'));
|
||||
const sub = /activeId !== prev\.activeId\)[\s\S]{0,400}?\}\);/.exec(sync);
|
||||
assert.ok(sub, '要能取到账号切换的处理块');
|
||||
const block = sub[0];
|
||||
@ -234,7 +234,7 @@ test('★ 切账号的顺序:**先按新账号重读本地**,再拉服务端
|
||||
assert.ok(pullAt > 0, '切换账号仍然要拉服务端');
|
||||
assert.ok(reloadAt < pullAt, 'reloadForAccount() 必须在 pull() 之前(顺序就是这条判据的全部意义)');
|
||||
// 重读本身不许落盘(它只是把本账号已有的值读回来,不是用户的改动)
|
||||
const store = readFileSync(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'), 'utf8');
|
||||
const store = code(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'));
|
||||
const rAt = store.indexOf('reloadForAccount: () => {');
|
||||
assert.ok(rAt > 0, 'store 要提供 reloadForAccount');
|
||||
const rBlock = store.slice(rAt, store.indexOf('}', store.indexOf('set(next)', rAt)));
|
||||
|
||||
@ -7,13 +7,14 @@
|
||||
* 这些检查存在的理由:背景是**装饰层叠加在内容之下**,它的 bug 形态是
|
||||
* 「正文读不动」和「背景根本没出现」,两者都不报错、不影响构建。
|
||||
*/
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { prose } from './lib/read.mjs';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import postcss from 'postcss';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const read = p => readFileSync(join(here, p), 'utf8');
|
||||
const read = p => prose(join(here, p));
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
@ -149,7 +150,7 @@ check(
|
||||
const compDir = join(here, '../src/components');
|
||||
const unmapped = [];
|
||||
for (const f of readdirSync(compDir).filter(x => x.endsWith('.tsx'))) {
|
||||
const src = readFileSync(join(compDir, f), 'utf8');
|
||||
const src = prose(join(compDir, f));
|
||||
if (/(?:bg|text|border)-(?:emerald|purple)-\d+/.test(src)) unmapped.push(f);
|
||||
}
|
||||
check('新组件未使用未映射色族', unmapped.length === 0, unmapped.join(' '));
|
||||
@ -205,7 +206,7 @@ check('新组件未使用未映射色族', unmapped.length === 0, unmapped.join(
|
||||
const full = join(dir, e.name);
|
||||
if (e.isDirectory()) walk(full);
|
||||
else if (/\.(tsx?|jsx?)$/.test(e.name)) {
|
||||
const src = readFileSync(full, 'utf8');
|
||||
const src = prose(full);
|
||||
for (const mm of src.matchAll(/bg-[a-z]+-\d{2,3}/g)) {
|
||||
if (/^bg-[a-z]+-(50|100|200)$/.test(mm[0])) used.add(mm[0]);
|
||||
}
|
||||
|
||||
@ -10,10 +10,11 @@
|
||||
*
|
||||
* 判据落在三处接线:vite 注入 → 组件渲染 → 产物里真的有。
|
||||
*/
|
||||
import { prose } from './lib/read.mjs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { existsSync, readdirSync } from 'node:fs';
|
||||
|
||||
import { srcState } from '../scripts/build-info.mjs';
|
||||
import { dirname, join } from 'node:path';
|
||||
@ -21,7 +22,7 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..', '..');
|
||||
const read = p => readFileSync(join(HERE, p), 'utf8');
|
||||
const read = p => prose(join(HERE, p));
|
||||
const STAMP = /[0-9a-f]{7,}·\d{4}-\d{4}/;
|
||||
|
||||
test('vite 注入 __BUILD_STAMP__', () => {
|
||||
@ -40,7 +41,7 @@ test('★ 构建产物里真的带着戳', () => {
|
||||
const dir = join(HERE, '../dist/assets');
|
||||
const js = readdirSync(dir).filter(f => f.startsWith('index-') && f.endsWith('.js'));
|
||||
assert.ok(js.length > 0, '先跑 npm run build');
|
||||
const found = js.some(f => STAMP.test(readFileSync(join(dir, f), 'utf8')));
|
||||
const found = js.some(f => STAMP.test(prose(join(dir, f))));
|
||||
assert.ok(found, '产物里没有构建戳 —— 界面上就永远看不出自己跑的是哪一份');
|
||||
});
|
||||
|
||||
@ -117,7 +118,7 @@ test('★ 产物必须自报来源:BUILD_INFO 精确比对(不是比时间
|
||||
assert.ok(existsSync(infoPath),
|
||||
'dist 里没有 BUILD_INFO.json —— 构建没走 `npm run build`(它最后一步会写这份自证)。\n' +
|
||||
' 重构建:cd client/electron && npm run build');
|
||||
const info = JSON.parse(readFileSync(infoPath, 'utf8'));
|
||||
const info = JSON.parse(prose(infoPath));
|
||||
const now = srcState();
|
||||
// 精确比对:这一条比"谁更新"强的地方在于它**能读出**差在哪
|
||||
assert.equal(info.srcHash, now.srcHash,
|
||||
|
||||
91
client/electron/test/criteria-hygiene.test.mjs
Normal file
91
client/electron/test/criteria-hygiene.test.mjs
Normal file
@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 判据目录自身的卫生:**读文本必须走 `test/lib/read.mjs` 的两个具名入口**。
|
||||
*
|
||||
* # 为什么这条判据存在(pi 2026-09-14 §4)
|
||||
*
|
||||
* 规范里写着"判代码读剥离版(`code`)、判理由/文档读原文(`prose`)",
|
||||
* 我 P5 写过一次、当天又踩了一次:那条断言读的是**原文**,而它要找的标识符
|
||||
* 恰好出现在一段解释性注释里 → 误报。**第二次犯规说明问题不在记性,在形态**:
|
||||
* 靠人记得执行的规范一定会有下一次。
|
||||
*
|
||||
* 所以把"用哪个读取器"从**记忆**变成**代码里的一个词**,并且可被检查:
|
||||
* - `code(path)` —— 剥掉注释;判"代码里有没有这个调用/这个值";
|
||||
* - `prose(path)` —— 原文;判"注释/文档里写了什么";
|
||||
* - `bytes(path)` —— 二进制(安装包等)。
|
||||
*
|
||||
* # 判据
|
||||
*
|
||||
* 判据目录(`test/**` 里跑的判据 + `run-all.mjs`)中**不得出现裸 `readFileSync`**,
|
||||
* 唯一例外是 `test/lib/read.mjs` 自己。`test/manual/**` 是人工脚本、不是判据,不在范围内。
|
||||
*
|
||||
* 附两条自检:读取器本身要真的剥注释(否则 `code` 退化成 `prose` 这条判据就废了)、
|
||||
* 以及探测器要能认出裸调用(否则"都没有"与"探测器坏了"结果一样)。
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { readdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { code, prose } from './lib/read.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const RELECTRON = join(HERE, '..'); // test/ 的上一级就是 client/electron
|
||||
const SELF = join(HERE, 'lib', 'read.mjs');
|
||||
|
||||
/** 判据文件清单:`test/**` 下会跑的判据 + 编排器;不含 lib/ 与 manual/ */
|
||||
function criteriaFiles(dir = HERE, out = []) {
|
||||
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
if (e.name === 'lib' || e.name === 'manual' || e.name === 'node_modules') continue;
|
||||
criteriaFiles(p, out);
|
||||
} else if (/\.(test\.mjs|test\.ts|test\.tsx|mjs)$/.test(e.name) && !e.name.endsWith('.d.ts')) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 探测器:一段源码里有没有裸 readFileSync */
|
||||
const BARE = /\breadFileSync\s*\(/;
|
||||
|
||||
test('探测器自检 + 读取器自检', () => {
|
||||
// ① 探测器能认出裸调用(否则"都没有"与"探测器坏了"分不开)
|
||||
assert.equal(BARE.test("const s = " + "readFile" + "Sync(p, 'utf8');"), true);
|
||||
assert.equal(BARE.test('const s = prose(p);'), false);
|
||||
|
||||
// ② code 真的剥注释、prose 不剥 —— 这条是整套用法的地基:
|
||||
// 若 code 退化成 prose,那么"读剥离版"的规范就变成一句空话,而且没人会发现。
|
||||
const probe = join(RELECTRON, 'test', '_reader_probe.tmp.ts');
|
||||
// 注:这个探针文本**故意拼接**而不是写字面量 —— 否则本判据自己会被自己判红
|
||||
// (它扫的就是"文本里有没有这个写法",判据文件也在扫描范围内)。
|
||||
writeFileSync(probe, "const REAL = 1; // " + "readFile" + "Sync( 注释里的假调用\n/* allowed-once */\n");
|
||||
try {
|
||||
assert.ok(!code(probe).includes('allowed-once'), 'code() 必须剥掉块注释');
|
||||
assert.ok(!code(probe).includes('假调用'), 'code() 必须剥掉行注释');
|
||||
assert.ok(code(probe).includes('REAL'), 'code() 要保留真代码');
|
||||
assert.ok(prose(probe).includes('allowed-once') && prose(probe).includes('假调用'), 'prose() 必须保留注释');
|
||||
} finally {
|
||||
unlinkSync(probe);
|
||||
}
|
||||
});
|
||||
|
||||
test('★ 判据目录里不得出现裸 readFileSync(必须走 code/prose/bytes)', () => {
|
||||
const offenders = [];
|
||||
for (const f of criteriaFiles()) {
|
||||
if (f === SELF) continue; // 读取器的实现自己当然要用它
|
||||
const src = prose(f); // 扫的是"文本里有没有这个写法",所以读原文
|
||||
if (BARE.test(src)) {
|
||||
const line = src.split('\n').findIndex(l => BARE.test(l)) + 1;
|
||||
offenders.push(`${relative(RELECTRON, f)}:${line}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(offenders, [],
|
||||
`这些判据文件里还在裸用 readFileSync:\n ${offenders.join('\n ')}\n` +
|
||||
" 改用 test/lib/read.mjs 的具名入口:\n" +
|
||||
" · code(path) —— 剥掉注释。判「代码里有没有这个调用/这个值」时用它(默认选它);\n" +
|
||||
" · prose(path) —— 原文。判「注释/文档里写了什么」时用它;\n" +
|
||||
" · bytes(path) —— 二进制(安装包等)。\n" +
|
||||
" 为什么不能裸用:读原文去判代码,会被解释性注释骗(同一个坑已经踩过两次)。");
|
||||
});
|
||||
@ -8,17 +8,22 @@
|
||||
* 这里只断言"两边对同一件事的取值一致",不断言实现方式(WebUI 用 CSS 变量、
|
||||
* 鸿蒙用 ArkTS 常量,本来就该不同)。
|
||||
*/
|
||||
import { code, prose, stripComments } from './lib/read.mjs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..', '..');
|
||||
const HARMONY_ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
|
||||
const web = readFileSync(join(ROOT, 'client/electron/src/index.css'), 'utf8');
|
||||
const harmony = readFileSync(join(HARMONY_ETS, 'common/Theme.ets'), 'utf8');
|
||||
const web = code(join(ROOT, 'client/electron/src/index.css'));
|
||||
const harmonyPath = join(HARMONY_ETS, 'common/Theme.ets');
|
||||
/** 判「代码里有什么」用这个 */
|
||||
const harmony = code(harmonyPath);
|
||||
/** 判「注释/文档里写了什么」用这个 —— 两条判据各取所需,别混用 */
|
||||
const harmonyDoc = prose(harmonyPath);
|
||||
|
||||
/**
|
||||
* 裸色值的**类**(不只 `#RRGGBB`):四种写法一起扫 ——
|
||||
@ -26,8 +31,7 @@ const harmony = readFileSync(join(HARMONY_ETS, 'common/Theme.ets'), 'utf8');
|
||||
* 注释先剥掉:注释里引用旧写法是常有的事,而"诚实的注释"不该把判据判红。
|
||||
*/
|
||||
const RAW_COLOR = /#[0-9A-Fa-f]{6,8}\b|\brgba?\s*\(|\b0x[0-9A-Fa-f]{6,8}\b/g;
|
||||
const code = src => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
const rawColors = src => code(src).match(RAW_COLOR) || [];
|
||||
const rawColors = src => stripComments(src).match(RAW_COLOR) || [];
|
||||
|
||||
/**
|
||||
* 文档里的「有意差异」表(§7.12)。
|
||||
@ -36,7 +40,7 @@ const rawColors = src => code(src).match(RAW_COLOR) || [];
|
||||
* 不是"证明做法对"。所以跨端那几条判据的主体仍落在代码上
|
||||
* (来源必须是系统资源 / 品牌色必须是那个值),文档只做第三只手。
|
||||
*/
|
||||
const plan = readFileSync(join(ROOT, 'docs/HARMONY-ALIGN-PLAN.md'), 'utf8');
|
||||
const plan = prose(join(ROOT, 'docs/HARMONY-ALIGN-PLAN.md'));
|
||||
/**
|
||||
* 取「有意差异」那一小节。
|
||||
*
|
||||
@ -107,7 +111,7 @@ test('圆角:WebUI 有它自己的令牌,鸿蒙跟随系统 —— 差异被
|
||||
* 注意 ③ 是**弱判据**(防遗忘),不是"证明做法对"。主体是 ①②,它们落在代码上。
|
||||
*/
|
||||
assert.match(web, /--radius-card:\s*0\.875rem/, 'WebUI 侧要保留自己的圆角令牌');
|
||||
assert.match(code(harmony), /radiusCard: Resource = \$r\('sys\.float\./, '鸿蒙卡片圆角要来自系统');
|
||||
assert.match(stripComments(harmony), /radiusCard: Resource = \$r\('sys\.float\./, '鸿蒙卡片圆角要来自系统');
|
||||
assert.match(diffSection(), /圆角/, '圆角是"有意差异",要写进文档的差异表(否则会被当成漏改)');
|
||||
});
|
||||
|
||||
@ -115,7 +119,7 @@ test('材质:WebUI 声明透明度令牌,鸿蒙用系统材质 —— 差异
|
||||
// 同上:旧口径钉"两边都 0.72"。鸿蒙改用 `BlurStyle` 后,"0.72 的白色"这件事
|
||||
// 由系统按主题决定(深浅各一套),所以取值**允许**不同,只要求"材质来自系统"且差异被记录。
|
||||
assert.match(web, /--nav-bg:\s*255 255 255 \/ 0\.72/, 'WebUI 侧要保留自己的导航底令牌');
|
||||
assert.match(code(harmony), /navMaterial: BlurStyle = BlurStyle\./, '鸿蒙导航材质要来自系统');
|
||||
assert.match(stripComments(harmony), /navMaterial: BlurStyle = BlurStyle\./, '鸿蒙导航材质要来自系统');
|
||||
assert.match(diffSection(), /材质/, '材质是"有意差异",要写进文档的差异表');
|
||||
});
|
||||
|
||||
@ -126,7 +130,7 @@ test('★ 判据自检:把鸿蒙的品牌蓝改成别的必须判红', () => {
|
||||
});
|
||||
|
||||
test('鸿蒙的令牌文件说明了与 WebUI 的对应关系(不是凭空一套)', () => {
|
||||
assert.match(harmony, /与 WebUI 的令牌\*\*一一对应|对应 WebUI/);
|
||||
assert.match(harmonyDoc, /与 WebUI 的令牌\*\*一一对应|对应 WebUI/, '这条判的是**注释里写的对应关系**,所以要读原文(prose),不是剥离版');
|
||||
});
|
||||
|
||||
test('★ 鸿蒙页面里不得出现任何裸色值(枚举挡不住漂移,"类"才能挡)', () => {
|
||||
@ -148,7 +152,7 @@ test('★ 鸿蒙页面里不得出现任何裸色值(枚举挡不住漂移,"
|
||||
const files = readdirSync(dir).filter(f => f.endsWith('.ets')).sort();
|
||||
assert.ok(files.length >= 8, `pages/ 下只扫到 ${files.length} 个 .ets,判据大概扫错了目录`);
|
||||
for (const f of files) {
|
||||
const hits = rawColors(readFileSync(join(dir, f), 'utf8'));
|
||||
const hits = rawColors(prose(join(dir, f)));
|
||||
assert.equal(hits.length, 0, `${f} 里有裸色值 ${hits.join('、')}(应改用 Theme 令牌或系统资源)`);
|
||||
}
|
||||
// 反向对照:四种形态都要抓得到(否则写错了正则也是一片绿)
|
||||
@ -192,7 +196,7 @@ test('A|系统拥有的维度,鸿蒙侧的唯一来源是系统资源(不
|
||||
// 反向断言:这些格子不得退回 string/number 类型(退回 = 又开始自己定值了)
|
||||
for (const name of Object.keys(sysOwned)) {
|
||||
assert.ok(
|
||||
!new RegExp(`${name}: (string|number) =`).test(code(harmony)),
|
||||
!new RegExp(`${name}: (string|number) =`).test(stripComments(harmony)),
|
||||
`${name} 不再是系统资源了 —— 这是"用系统方案"被回退的信号`
|
||||
);
|
||||
}
|
||||
@ -220,7 +224,7 @@ const SELF_OWNED_COLORS = [
|
||||
];
|
||||
|
||||
test('A2|Theme.ets 里"自己写的色"必须**登记过**:新写死一个色不该默默通过', () => {
|
||||
const themeSrc = code(harmony);
|
||||
const themeSrc = harmony; // 声明在代码里:读剥离版就够了
|
||||
const declared = [...themeSrc.matchAll(/static readonly (\w+): string = '(#[0-9A-Fa-f]{6,8})'/g)].map(m => m[1]);
|
||||
assert.ok(declared.length >= 10, `要从 Theme.ets 里读到那些手写色,实际读到 ${declared.length} 个`);
|
||||
|
||||
@ -240,11 +244,11 @@ test('A2|Theme.ets 里"自己写的色"必须**登记过**:新写死一个
|
||||
* 不是剥过注释的源码(剥注释会把登记表本身剥掉,第一版就踩了这个:
|
||||
* `indexOf` 返回 -1,切片拿到一段不相干的东西)。
|
||||
*/
|
||||
const regStart = harmony.indexOf('手写色**登记表**');
|
||||
const regStart = harmonyDoc.indexOf('手写色**登记表**');
|
||||
assert.ok(regStart > 0, 'Theme.ets 里要有「手写色登记表」那一段');
|
||||
const regEnd = harmony.indexOf('品牌色:跨客户端身份', regStart);
|
||||
const regEnd = harmonyDoc.indexOf('品牌色:跨客户端身份', regStart);
|
||||
assert.ok(regEnd > regStart, '登记表那一段要有明确的结束边界(下一个分节标题)');
|
||||
const registry = harmony.slice(regStart, regEnd);
|
||||
const registry = harmonyDoc.slice(regStart, regEnd);
|
||||
assert.ok(registry.length > 200, `登记表太短,可能切错了段落(${registry.length} 字符)`);
|
||||
for (const name of SELF_OWNED_COLORS) {
|
||||
assert.ok(registry.includes(name), `登记表里要提到 ${name}(否则理由只存在于写它那个人的记忆里)`);
|
||||
@ -265,7 +269,7 @@ test('A2|Theme.ets 里"自己写的色"必须**登记过**:新写死一个
|
||||
});
|
||||
|
||||
test('B|旧机制不得回来:手写玻璃 alpha、替系统猜深色、与 WebUI 绑死的圆角数字', () => {
|
||||
const codeOnly = code(harmony);
|
||||
const codeOnly = stripComments(harmony);
|
||||
/*
|
||||
* `navBgLight` / `navBgDark` 这两个名字本身就是罪证:浅色一个、深色一个手写玻璃,
|
||||
* 等于"我们替系统猜了深色该怎么做"。pi 在 WebUI 侧撤掉 `.dark` 导航令牌、
|
||||
@ -353,7 +357,7 @@ test('C|玻璃:位置用系统材质、不许叠、每一处都要登记(
|
||||
* (并列本身没问题),而真正的嵌套它没在判。P5 正是"悬浮玻璃导航",很可能撞上第二处;
|
||||
* 到那时若把 1 改成 2,这条就退化成"最多两处"(等于不判)。所以现在就把形状改对。
|
||||
*/
|
||||
const codeOnly = code(harmony);
|
||||
const codeOnly = stripComments(harmony);
|
||||
assert.match(codeOnly, /static readonly navMaterial: BlurStyle = BlurStyle\.[A-Z_]+/, '导航材质要声明成系统材质档次');
|
||||
const navMaterial = codeOnly.match(/navMaterial: BlurStyle = BlurStyle\.([A-Z_]+)/)[1];
|
||||
assert.notEqual(navMaterial, 'NONE', 'NONE 等于没有材质,"玻璃"就名存实亡');
|
||||
@ -362,7 +366,7 @@ test('C|玻璃:位置用系统材质、不许叠、每一处都要登记(
|
||||
/** 每个调用点:哪一处(文件#组件)、调用下标、它作用的**组件块**(配对出来的) */
|
||||
const found = [];
|
||||
for (const f of collectEts(etsRoot)) {
|
||||
const src = code(readFileSync(f, 'utf8'));
|
||||
const src = code(f);
|
||||
const spans = braceSpans(src);
|
||||
for (const m of src.matchAll(/backgroundBlurStyle\(/g)) {
|
||||
const at = m.index;
|
||||
@ -414,7 +418,7 @@ test('C|玻璃:位置用系统材质、不许叠、每一处都要登记(
|
||||
}
|
||||
|
||||
// 导航条那一处必须真的还在(形状判定之外,位置本身也要在)
|
||||
const main = code(readFileSync(join(ROOT, 'client/harmony/entry/src/main/ets/pages/MainPage.ets'), 'utf8'));
|
||||
const main = code(join(ROOT, 'client/harmony/entry/src/main/ets/pages/MainPage.ets'));
|
||||
assert.match(main, /\.backgroundBlurStyle\(Theme\.navMaterial\)/, '导航条要用系统材质(不是手写 alpha)');
|
||||
|
||||
/*
|
||||
@ -439,22 +443,19 @@ test('★ 品牌色防线:主操作色不得退化成系统强调色', () => {
|
||||
*/
|
||||
assert.match(harmony, /accent: string = '#2563EB'/, '品牌蓝必须仍是 WebUI 的那个值');
|
||||
assert.ok(
|
||||
!/accent: Resource/.test(code(harmony)),
|
||||
!/accent: Resource/.test(stripComments(harmony)),
|
||||
'accent 变成系统资源了 —— 品牌色跟着系统变就不再是同一个产品的标识'
|
||||
);
|
||||
// 主操作/选中态仍引用它(否则"没退化"只是因为它没被用 —— 值留着也没意义)
|
||||
const login = code(readFileSync(join(ROOT, 'client/harmony/entry/src/main/ets/pages/LoginPage.ets'), 'utf8'));
|
||||
const login = code(join(ROOT, 'client/harmony/entry/src/main/ets/pages/LoginPage.ets'));
|
||||
assert.match(login, /backgroundColor\(Theme\.accent\)/, '登录按钮仍是品牌色');
|
||||
const main = code(readFileSync(join(ROOT, 'client/harmony/entry/src/main/ets/pages/MainPage.ets'), 'utf8'));
|
||||
const main = code(join(ROOT, 'client/harmony/entry/src/main/ets/pages/MainPage.ets'));
|
||||
assert.match(main, /backgroundColor\(Theme\.accent\)/, '主操作按钮/选中态仍是品牌色');
|
||||
});
|
||||
|
||||
test('权限档位徽标两边同一套色(plan=蓝 / workspace=绿 / full=琥珀)', () => {
|
||||
// WebUI 的映射写在 PermissionChip.tsx 的类名里;鸿蒙的映射是 Theme.permBg/permFg。
|
||||
const chip = readFileSync(
|
||||
join(ROOT, 'client/electron/src/components/PermissionChip.tsx'),
|
||||
'utf8'
|
||||
);
|
||||
const chip = code(join(ROOT, 'client/electron/src/components/PermissionChip.tsx'));
|
||||
assert.match(chip, /plan'[\s\S]{0,80}bg-blue-50 text-blue-700/, 'WebUI plan 档应是蓝');
|
||||
assert.match(chip, /full'[\s\S]{0,80}bg-amber-50 text-amber-700/, 'WebUI full 档应是琥珀');
|
||||
assert.match(chip, /bg-green-50 text-green-700/, 'WebUI workspace 档应是绿');
|
||||
@ -513,10 +514,10 @@ test('遮罩:交给系统的遮罩语义色("随主题换向"这件事现在
|
||||
* 深浅两套值由系统给,而且不会再漏配一边 —— 比我们自己维护两个常量更不容易错。
|
||||
* 所以口径改成"遮罩来自系统"(这条是硬的),WebUI 侧仍然两段式(它没有系统可跟随)。
|
||||
*/
|
||||
assert.match(code(harmony), /overlay: Resource = \$r\('sys\.color\.ohos_id_color_mask_regular'\)/, '鸿蒙遮罩要用系统遮罩色');
|
||||
assert.match(stripComments(harmony), /overlay: Resource = \$r\('sys\.color\.ohos_id_color_mask_regular'\)/, '鸿蒙遮罩要用系统遮罩色');
|
||||
// 不许再自己维护"色 + 透明度"两个常量(那正是系统已经替我们做掉的事)
|
||||
assert.ok(!/overlayColor: string/.test(code(harmony)), '遮罩色不该再由我们自己定');
|
||||
assert.ok(!/overlayAlpha: number/.test(code(harmony)), '遮罩透明度不该再由我们自己定');
|
||||
assert.ok(!/overlayColor: string/.test(stripComments(harmony)), '遮罩色不该再由我们自己定');
|
||||
assert.ok(!/overlayAlpha: number/.test(stripComments(harmony)), '遮罩透明度不该再由我们自己定');
|
||||
|
||||
/*
|
||||
* pi 读出来的第四条:判据只断言 `overlay` **存在**,没断言它**被用** ——
|
||||
@ -528,13 +529,13 @@ test('遮罩:交给系统的遮罩语义色("随主题换向"这件事现在
|
||||
const themePath = join(HARMONY_ETS, 'common/Theme.ets');
|
||||
const overlayUsers = collectEts(etsRoot)
|
||||
.filter(f => f !== themePath)
|
||||
.filter(f => /Theme\.overlay\b/.test(code(readFileSync(f, 'utf8'))))
|
||||
.filter(f => /Theme\.overlay\b/.test(code(f)))
|
||||
.map(f => f.slice(etsRoot.length + 1));
|
||||
assert.ok(overlayUsers.length > 0,
|
||||
'Theme.overlay 声明了却没有任何使用点 —— 那就是个死令牌(要么删掉,要么写出它的使用处)');
|
||||
// 用它的必须是**自绘遮罩**的地方(系统自带遮罩的弹窗不需要它)
|
||||
assert.ok(overlayUsers.every(f => f.endsWith('.ets')), `遮罩使用点应该是页面:${overlayUsers.join('、')}`);
|
||||
assert.ok(!/#[0-9A-Fa-f]{8}/.test(code(harmony)), '遮罩不该再写成色与透明度焊死的 #AARRGGBB 单值');
|
||||
assert.ok(!/#[0-9A-Fa-f]{8}/.test(stripComments(harmony)), '遮罩不该再写成色与透明度焊死的 #AARRGGBB 单值');
|
||||
// WebUI 侧同构:颜色两套(浅/深,同一个变量名换向)+ 透明度独立
|
||||
assert.match(web, /--bg-scrim: 255 255 255/, 'WebUI 浅色遮罩色');
|
||||
assert.match(web, /--bg-scrim: 0 0 0/, 'WebUI 深色遮罩色');
|
||||
@ -624,7 +625,7 @@ test('★ 手写色清册**跨文件**:全 ets 树里每个 `X: string = \'#RR
|
||||
const declared = [];
|
||||
for (const f of tree) {
|
||||
const rel = f.slice(HARMONY_ETS.length + 1);
|
||||
const src = readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
const src = prose(f).replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
for (const m of src.matchAll(/(\w+)\s*:\s*string\s*=\s*'(#[0-9A-Fa-f]{6})'/g)) {
|
||||
declared.push({ file: rel, name: m[1], value: m[2] });
|
||||
}
|
||||
|
||||
@ -9,9 +9,9 @@
|
||||
* 而单测全绿 —— 因为测试只断言了方法与报文,没断言 URL(路径多写了一层 `/api/v1`)。
|
||||
* 所以这里有一条判据专门钉路径,而且钉的是**相对基地址**的形状。
|
||||
*/
|
||||
import { code, prose } from './lib/read.mjs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
@ -26,8 +26,7 @@ const MODULE_TS = join(HARMONY_ETS, 'model/Appearance.ts');
|
||||
const A = await import(pathToFileURL(MODULE_TS).href);
|
||||
|
||||
/** 剥注释读源码:注释里出现某个调用恰恰说明不了那个调用存在 */
|
||||
const code = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
const read = (rel) => code(readFileSync(join(HARMONY_ETS, rel), 'utf8'));
|
||||
const read = (rel) => code(join(HARMONY_ETS, rel));
|
||||
|
||||
const snap = (over = {}) => Object.assign(new A.AppearanceSnapshot(), over);
|
||||
const resp = (over = {}) => Object.assign(new A.AppearanceResponse(), over);
|
||||
@ -147,8 +146,10 @@ test('★ 模糊值映射到**系统材质档次**(不是把 40 当半径塞
|
||||
assert.equal(A.blurStyleFor(-3), 'NONE');
|
||||
// 档次必须来自系统枚举(写成自造名字会编译不过/不生效)
|
||||
const sdk = A.blurStyleFor(12);
|
||||
const commonDts = readFileSync(process.env.HARMONY_COMMON_DTS
|
||||
|| '/opt/huawei/command-line-tools/sdk/default/openharmony/ets/component/common.d.ts', 'utf8');
|
||||
// SDK 的 .d.ts 是**源码**(判它的枚举成员),所以走 code():剥掉注释,
|
||||
// 免得注释里举例的枚举名被当成员读进来
|
||||
const commonDts = code(process.env.HARMONY_COMMON_DTS
|
||||
|| '/opt/huawei/command-line-tools/sdk/default/openharmony/ets/component/common.d.ts');
|
||||
const enumBlock = commonDts.slice(commonDts.indexOf('declare enum BlurStyle'));
|
||||
const members = [...enumBlock.slice(0, enumBlock.indexOf('}')).matchAll(/^\s{2,}([A-Za-z][A-Za-z_0-9]*)\s*[,=]/gm)].map(m => m[1]);
|
||||
assert.ok(members.length > 3, '要从 SDK 里读到 BlurStyle 成员');
|
||||
@ -168,7 +169,7 @@ test('主题 → **系统色彩模式**(深浅两套颜色由系统给,不
|
||||
* `COLOR_MODE_DARK = 0`、`COLOR_MODE_LIGHT = 1`。判据直接读 SDK 的枚举文件比对,
|
||||
* 不凭印象(我第一版就是按 0=浅色 写的,选深色会切成浅色)。
|
||||
*/
|
||||
const constDts = readFileSync(process.env.HARMONY_CONFIG_CONSTANT_DTS
|
||||
const constDts = code(process.env.HARMONY_CONFIG_CONSTANT_DTS
|
||||
|| '/opt/huawei/command-line-tools/sdk/default/openharmony/ets/api/@ohos.app.ability.ConfigurationConstant.d.ts', 'utf8');
|
||||
const valueOf = (name) => {
|
||||
const m = new RegExp(`${name}\\s*=\\s*(-?\\d+)`).exec(constDts);
|
||||
@ -253,8 +254,8 @@ export const __coverage = ['snapshotFromResponse', 'payloadFromLocal', 'mergeApp
|
||||
|
||||
const WALL_TS = join(HARMONY_ETS, 'model/Wallpaper.ts');
|
||||
const W = await import(pathToFileURL(WALL_TS).href);
|
||||
const webCss = readFileSync(join(ROOT, 'client/electron/src/index.css'), 'utf8');
|
||||
const webBgStore = readFileSync(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'), 'utf8');
|
||||
const webCss = code(join(ROOT, 'client/electron/src/index.css'));
|
||||
const webBgStore = code(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'));
|
||||
|
||||
test('★ 预设清单与 WebUI 一致(id + 顺序 + 中文标签)—— 少一个档就是"用户设了在鸿蒙看不到"', () => {
|
||||
/*
|
||||
@ -434,10 +435,10 @@ test('★ 模糊归属:壁纸层**不许**再模糊,导航条必须有系统
|
||||
const store = read('common/AppearanceStore.ets');
|
||||
assert.match(store, /colorModeValue\(theme\)/, '主题走系统色彩模式');
|
||||
/*
|
||||
* "理由写清了没"要读**原文**(`read()` 会剥注释 —— 而理由就在注释里)。
|
||||
* "理由写清了没"要读**原文**(`prose()`;`code()`/`read()` 会剥注释 —— 而理由就在注释里)。
|
||||
* 剥注释读源码是为了防"注释里的调用被当成真调用",但断言"注释里写了理由"时正好相反。
|
||||
*/
|
||||
const wallRaw = readFileSync(join(HARMONY_ETS, 'model/Wallpaper.ts'), 'utf8');
|
||||
const wallRaw = prose(join(HARMONY_ETS, 'model/Wallpaper.ts')); // 理由在注释里 → prose(见上)
|
||||
assert.match(wallRaw, /系统没有对应的渐变原语/, '网格档为什么用 Canvas 要写清(否则以后会被当成绕开系统方案)');
|
||||
assert.match(wallRaw, /repeating-linear-gradient/, '要指名道姓写出 CSS 用的是哪个原语(后人查得到)');
|
||||
});
|
||||
@ -559,7 +560,7 @@ test('★ isDarkMode:system 要看系统当时的深浅,读不到时按浅
|
||||
assert.equal(A.isDarkMode('system', 1), false, 'system + 系统浅色 → 浅色色板');
|
||||
assert.equal(A.isDarkMode('system', -1), false, '系统还没定(NOT_SET)→ 按浅色,与 WebUI 的 :root 默认一致');
|
||||
// 数值锚到 SDK:0=深、1=浅
|
||||
const sdkConst = readFileSync(join(CLT, 'sdk/default/openharmony/ets/api/@ohos.app.ability.ConfigurationConstant.d.ts'), 'utf8');
|
||||
const sdkConst = code(join(CLT, 'sdk/default/openharmony/ets/api/@ohos.app.ability.ConfigurationConstant.d.ts'));
|
||||
assert.match(sdkConst, /COLOR_MODE_DARK = 0/, 'SDK 里深色是 0(别记反)');
|
||||
assert.match(sdkConst, /COLOR_MODE_LIGHT = 1/, 'SDK 里浅色是 1');
|
||||
// 页面真的按主题选色板(不是写死 false)
|
||||
@ -596,7 +597,7 @@ test('★ 预设档的遮盖:两档同一个浓度(WebUI 的 --bg-dim 不区
|
||||
}
|
||||
}
|
||||
// WebUI 侧的前提:遮罩真的不区分档位(否则"对齐"就没有依据)
|
||||
const bgStore = readFileSync(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'), 'utf8');
|
||||
const bgStore = code(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'));
|
||||
assert.match(bgStore, /--bg-dim|setProperty\('--bg-dim'/, 'WebUI 要无条件写 --bg-dim(这是"两档都压"的依据)');
|
||||
const main = read('pages/MainPage.ets');
|
||||
// 两档各有一处遮盖层(都用系统遮罩色 + 算出来的浓度)
|
||||
@ -636,7 +637,7 @@ test('★ 多账号缓存键:**按账号**分(鸿蒙是对的,不许为"
|
||||
* 2026-09-14 WebUI 侧也按账号分键了(dsh 接手 pi 的两个开项),**差异已消除** ——
|
||||
* 所以现在断言的是"两端都是按账号的键",而且不允许任何一端退回全局键。
|
||||
*/
|
||||
const webStore = readFileSync(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'), 'utf8');
|
||||
const webStore = code(join(ROOT, 'client/electron/src/stores/backgroundStore.ts'));
|
||||
assert.match(webStore, /export function storageKey\(/, 'WebUI 也要有按账号取键的函数');
|
||||
assert.match(webStore, /LEGACY_STORAGE_KEY/, '旧全局键只作为迁移源存在');
|
||||
assert.ok(!/setItem\('agentmail\.background'/.test(webStore), 'WebUI 也不许再往全局键写');
|
||||
@ -665,7 +666,7 @@ test('★ 主题变化时**我们自己算的值**要跟着重算(pi 的规则
|
||||
}
|
||||
assert.ok(/applyAppearance\(\)/.test(main.slice(watchAt, end + 400)), '回调里要重算外观(色板属于"我们算的")');
|
||||
// SDK 锚点:这个 API 与回调形状不能凭记忆写
|
||||
const sdk = readFileSync(join(CLT, 'sdk/default/openharmony/ets/api/@ohos.app.ability.EnvironmentCallback.d.ts'), 'utf8');
|
||||
const sdk = code(join(CLT, 'sdk/default/openharmony/ets/api/@ohos.app.ability.EnvironmentCallback.d.ts'));
|
||||
assert.match(sdk, /onConfigurationUpdated\(config: Configuration\): void/, 'SDK 里回调是 onConfigurationUpdated(别写错名字)');
|
||||
});
|
||||
|
||||
@ -673,8 +674,8 @@ test('★ 主题变化时**我们自己算的值**要跟着重算(pi 的规则
|
||||
|
||||
/** 从 SDK 读出 `sys.color.ohos_id_color_*` 的真实值(名字→id 取编译器那张表,id→值取预览器那张) */
|
||||
function sdkSystemColors() {
|
||||
const sysRes = readFileSync(join(CLT, 'sdk/default/openharmony/ets/build-tools/ets-loader/sysResource.js'), 'utf8');
|
||||
const resTxt = readFileSync(join(CLT, 'sdk/default/openharmony/previewer/common/resources/entry/resources.txt'), 'utf8');
|
||||
const sysRes = code(join(CLT, 'sdk/default/openharmony/ets/build-tools/ets-loader/sysResource.js'));
|
||||
const resTxt = code(join(CLT, 'sdk/default/openharmony/previewer/common/resources/entry/resources.txt'));
|
||||
const name2id = new Map();
|
||||
for (const m of sysRes.matchAll(/'?(ohos_id_color_[a-z_]+)'?:\s*(\d+)/g)) {
|
||||
if (!name2id.has(m[1])) name2id.set(m[1], Number(m[2]));
|
||||
@ -728,14 +729,14 @@ test('★ 遮盖色方向:`mask_*` 两套主题下都是**深色**(模态遮
|
||||
assert.ok(luminanceOf(bgDark) < 0.2, `ohos_id_color_background_dark 应当近黑(实测 ${bgDark})`);
|
||||
|
||||
// 事实三:WebUI 的遮罩方向是"朝底色淡化"(浅色白、深色黑)—— 与页面底色系同向、与 mask 反向
|
||||
const webCss = readFileSync(join(ROOT, 'client/electron/src/index.css'), 'utf8');
|
||||
const webCss = code(join(ROOT, 'client/electron/src/index.css'));
|
||||
const scrimLight = /--bg-scrim:\s*(\d+)\s+(\d+)\s+(\d+);/.exec(webCss);
|
||||
assert.ok(scrimLight, 'CSS 里要有 --bg-scrim');
|
||||
assert.deepEqual([scrimLight[1], scrimLight[2], scrimLight[3]], ['255', '255', '255'],
|
||||
'WebUI 浅色下的遮罩是**白**(把图案洗淡);这是"必须用页面底色系"的依据');
|
||||
|
||||
// 结论落到代码:壁纸遮盖用页面底色系令牌,且**不是** mask
|
||||
const theme = readFileSync(join(HARMONY_ETS, 'common/Theme.ets'), 'utf8');
|
||||
const theme = code(join(HARMONY_ETS, 'common/Theme.ets'));
|
||||
assert.match(theme, /static readonly wallpaperScrim: Resource = \$r\('sys\.color\.ohos_id_color_background'\)/,
|
||||
'壁纸遮盖色要用页面底色系(ohos_id_color_background)');
|
||||
assert.match(theme, /static readonly overlay: Resource = \$r\('sys\.color\.ohos_id_color_mask_regular'\)/,
|
||||
|
||||
@ -18,9 +18,10 @@
|
||||
* 与 WebUI 的对应物:`src/lib/mailGroups.ts`(折叠 / isFlatGroup)、
|
||||
* `src/components/WorkCard.tsx` 的 `BudgetChip`(预算档位)、`ContactPanel.tsx`(视图切换)。
|
||||
*/
|
||||
import { code, prose } from './lib/read.mjs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
@ -34,7 +35,7 @@ const COMM_TS = join(HARMONY_ETS, 'model/CommTabs.ts');
|
||||
const H = await import(pathToFileURL(MODULE_TS).href);
|
||||
const C = await import(pathToFileURL(COMM_TS).href);
|
||||
|
||||
const page = readFileSync(join(HARMONY_ETS, 'pages/MainPage.ets'), 'utf8');
|
||||
const page = code(join(HARMONY_ETS, 'pages/MainPage.ets'));
|
||||
/**
|
||||
* 断言一律读**剥掉注释的源码**。
|
||||
*
|
||||
@ -44,8 +45,8 @@ const page = readFileSync(join(HARMONY_ETS, 'pages/MainPage.ets'), 'utf8');
|
||||
* 已经用过一次(遮罩那段注释里引用了旧值),这里统一成常态。
|
||||
*/
|
||||
const pageCode = page.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
const webGroups = readFileSync(join(ROOT, 'client/electron/src/lib/mailGroups.ts'), 'utf8');
|
||||
const webCard = readFileSync(join(ROOT, 'client/electron/src/components/WorkCard.tsx'), 'utf8');
|
||||
const webGroups = code(join(ROOT, 'client/electron/src/lib/mailGroups.ts'));
|
||||
const webCard = code(join(ROOT, 'client/electron/src/components/WorkCard.tsx'));
|
||||
|
||||
/** 造一封邮件(只带折叠/渲染用得到的字段,字段名与 Models.ets 的 MailSummary 一致) */
|
||||
const mail = (over) => Object.assign(
|
||||
@ -235,7 +236,7 @@ test('底部只剩 通信 / 联系人 两个平级页签,「会话」不再是
|
||||
*/
|
||||
const navLabels = [...pageCode.matchAll(/NAV_ITEMS\.map\([^)]*\.label\)|NAV_ITEMS/g)].length;
|
||||
assert.ok(navLabels > 0, '底栏要由 NAV_ITEMS 驱动');
|
||||
const navSource = readFileSync(join(HARMONY_ETS, 'model/NavItems.ts'), 'utf8');
|
||||
const navSource = code(join(HARMONY_ETS, 'model/NavItems.ts'));
|
||||
const labels = [...navSource.matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]);
|
||||
assert.deepEqual(labels, ['通信', '联系人'], `平级项应只剩两个,实际:${labels.join('、')}`);
|
||||
assert.ok(!/struct\s+SessionsTab/.test(pageCode), 'SessionsTab 已经撤了,不该再留在页面里');
|
||||
@ -265,7 +266,7 @@ test('卡片视图的字段集与 WebUI 的 WorkCard 一致(撤 tab 后"信息
|
||||
// ───────────────────────── 权限档位 + 强制力(与 WebUI PermissionChip 同文案) ─────────────────────────
|
||||
|
||||
test('档位标签与强制力标记:和 WebUI 的 MODE_LABEL / ENFORCEMENT_LABEL 逐字一致', async () => {
|
||||
const webChip = readFileSync(join(ROOT, 'client/electron/src/components/PermissionChip.tsx'), 'utf8');
|
||||
const webChip = code(join(ROOT, 'client/electron/src/components/PermissionChip.tsx'));
|
||||
const labelOf = (name, src) => {
|
||||
const block = src.slice(src.indexOf(`const ${name}`), src.indexOf('};', src.indexOf(`const ${name}`)));
|
||||
return new Map([...block.matchAll(/(\w+):\s*'([^']+)'/g)].map(m => [m[1], m[2]]));
|
||||
@ -300,7 +301,7 @@ test('★ 档位说明文案与 WebUI permissionModeHint 逐字一致(两边
|
||||
* 再要求鸿蒙的 permissionHint 对 3 档 × 3 强制力(外加空/未知)给出的每一句话
|
||||
* 都在那个集合里 —— 任一边改了口径就红。
|
||||
*/
|
||||
const webChip = readFileSync(join(ROOT, 'client/electron/src/components/PermissionChip.tsx'), 'utf8');
|
||||
const webChip = code(join(ROOT, 'client/electron/src/components/PermissionChip.tsx'));
|
||||
const fn = webChip.slice(webChip.indexOf('export function permissionModeHint'));
|
||||
const body = fn.slice(0, fn.indexOf('\n}'));
|
||||
const webHints = [...body.matchAll(/return\s+((?:'(?:[^'\\]|\\.)*'\s*\+?\s*)+);/g)].map(m =>
|
||||
@ -393,7 +394,7 @@ test('★ 不得再用废弃的全局 promptAction.showToast(API 18 起废弃
|
||||
|
||||
const bad = [];
|
||||
for (const f of files) {
|
||||
const src = readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
const src = prose(f).replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
// `getPromptAction().showToast(` 不算违规:它前面必须有 `get`
|
||||
for (const m of src.matchAll(/(?<!get)promptAction\.showToast\(/g)) bad.push(f.slice(ROOT.length + 1));
|
||||
}
|
||||
@ -405,10 +406,10 @@ test('★ 不得再用废弃的全局 promptAction.showToast(API 18 起废弃
|
||||
|
||||
// ───────────────────── 通信页:内部页签 / 徽标 / 三栏分家(P2b) ─────────────────────
|
||||
|
||||
const webUiStore = readFileSync(join(ROOT, 'client/electron/src/stores/uiStore.ts'), 'utf8');
|
||||
const webCommTabs = readFileSync(join(ROOT, 'client/electron/src/components/CommTabs.tsx'), 'utf8');
|
||||
const webMailList = readFileSync(join(ROOT, 'client/electron/src/components/MailList.tsx'), 'utf8');
|
||||
const sendCode = readFileSync(join(HARMONY_ETS, 'pages/MainPage.ets'), 'utf8')
|
||||
const webUiStore = code(join(ROOT, 'client/electron/src/stores/uiStore.ts'));
|
||||
const webCommTabs = code(join(ROOT, 'client/electron/src/components/CommTabs.tsx'));
|
||||
const webMailList = code(join(ROOT, 'client/electron/src/components/MailList.tsx'));
|
||||
const sendCode = code(join(HARMONY_ETS, 'pages/MainPage.ets'))
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
|
||||
test('通信页的页签键与顺序,与 WebUI 的 CommTab / TABS 完全一致', () => {
|
||||
@ -509,7 +510,7 @@ test('三栏的空态都有说明,且主句与 WebUI 的「暂无邮件」逐
|
||||
test('通信页把三栏真的接上了:内部页签 + 徽标 + 悬浮加号 + 收件箱按权限分家', () => {
|
||||
// 底部第一项的标签是「通信」而不是「收件箱」(信息架构变了,标签必须跟着变)
|
||||
// P5:底栏标签现在来自 NAV_ITEMS(自绘浮动条),不是 TabBarBuilder 的参数
|
||||
const navSource2 = readFileSync(join(HARMONY_ETS, 'model/NavItems.ts'), 'utf8');
|
||||
const navSource2 = code(join(HARMONY_ETS, 'model/NavItems.ts'));
|
||||
const tabLabels = [...navSource2.matchAll(/label:\s*'([^']+)'/g)].map(m => m[1]);
|
||||
assert.deepEqual(tabLabels, ['通信', '联系人'], `底部应只剩两项且第一项是通信,实际:${tabLabels.join('、')}`);
|
||||
// 通信页现在还要收一个 `bgActive`(背景开着时让出页面底,否则壁纸全被盖住)——
|
||||
@ -543,7 +544,7 @@ test('通信页把三栏真的接上了:内部页签 + 徽标 + 悬浮加号 +
|
||||
});
|
||||
|
||||
test('发件箱与授权栏走的是与 WebUI 相同的接口(路径、字段、备注都要对)', () => {
|
||||
const api = readFileSync(join(HARMONY_ETS, 'api/MailApi.ets'), 'utf8')
|
||||
const api = code(join(HARMONY_ETS, 'api/MailApi.ets'))
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
assert.match(api, /get<SentResponse>\('\/me\/mail\/sent'\)/, '发件箱接口');
|
||||
assert.match(api, /get<PendingResponse>\('\/permission\/pending'\)/, '待决列表接口(不从收件箱筛)');
|
||||
@ -566,6 +567,6 @@ test('★ 判据自检:把状态机的默认页签改错必须判红', () => {
|
||||
// 自检方式:直接改源码字符串,确认断言会红(而不是"看起来能红")
|
||||
const mutated = sendCode.replace("this.commTab = normalizeCommTab(key);", "this.commTab = key;");
|
||||
assert.ok(!/this\.commTab = normalizeCommTab\(key\)/.test(mutated), '自检:变异没生效');
|
||||
const defaultMutated = readFileSync(COMM_TS, 'utf8').replace("export const COMM_TAB_DEFAULT: string = 'inbox';", "export const COMM_TAB_DEFAULT: string = 'sent';");
|
||||
const defaultMutated = prose(COMM_TS).replace("export const COMM_TAB_DEFAULT: string = 'inbox';", "export const COMM_TAB_DEFAULT: string = 'sent';");
|
||||
assert.ok(!/COMM_TAB_DEFAULT: string = 'inbox'/.test(defaultMutated), '自检:默认页签变异没生效');
|
||||
});
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
// ③ 命中区:≥44vp —— 从 `NavItems.ts` **导入数值**判,不拿正则去源码里猜;
|
||||
// ④ 悬浮与让位:条是自绘的浮动层(留白 + 圆角 + 系统材质),
|
||||
// 且内容底部让出的高度 ≥ 条高 + 离底留白(否则最后一行压在条底下:看得见、点不到)。
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { code, prose, stripComments } from './lib/read.mjs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { test } from 'node:test';
|
||||
@ -19,9 +19,8 @@ const ROOT = join(HERE, '..', '..', '..');
|
||||
const HARMONY_ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
|
||||
const NAV_TS = join(HARMONY_ETS, 'model/NavItems.ts');
|
||||
|
||||
const read = p => readFileSync(join(HARMONY_ETS, p), 'utf8');
|
||||
const read = p => prose(join(HARMONY_ETS, p));
|
||||
/** 剥掉注释与字符串,只看真代码(断言"代码里有什么"时必须这样读) */
|
||||
const code = src => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
|
||||
/** 取某个 `@Builder` 的正文(按行切到下一个成员) */
|
||||
function builderBody(src, signature) {
|
||||
const lines = src.split('\n');
|
||||
@ -148,7 +147,7 @@ test('③-b 命中区是**来源**判据:`.ets` 不许自己写数字,必须
|
||||
* 顺带钉住:算式里不许出现裸数字(余量要有名字)。
|
||||
*/
|
||||
test('④-b 内容让位高度是**派生**的,不是并列常量', () => {
|
||||
const src = readFileSync(NAV_TS, 'utf8');
|
||||
const src = prose(NAV_TS);
|
||||
const decl = /export const NAV_CONTENT_RESERVE: number = ([^;]+);/.exec(src);
|
||||
assert.ok(decl, 'NAV_CONTENT_RESERVE 的声明要能被判据读到(判据跟着改)');
|
||||
const expr = decl[1];
|
||||
@ -174,8 +173,8 @@ test('④ 悬浮 + 让位:自绘浮动层(留白/圆角/系统材质),
|
||||
* 读原文会把它当成"条上还有手写色值"(我第一版就是这样误报的)。
|
||||
* 与规范里那条一致:判"代码里有什么"读剥注释的源码,判"理由写清了没"读原文。
|
||||
*/
|
||||
assert.ok(!/#[0-9A-Fa-f]{6,8}/.test(code(bar)),
|
||||
`条上不许出现手写色值(深浅两套由系统给),实际:${(code(bar).match(/#[0-9A-Fa-f]{6,8}/g) || []).join('、')}`);
|
||||
assert.ok(!/#[0-9A-Fa-f]{6,8}/.test(stripComments(bar)),
|
||||
`条上不许出现手写色值(深浅两套由系统给),实际:${(stripComments(bar).match(/#[0-9A-Fa-f]{6,8}/g) || []).join('、')}`);
|
||||
|
||||
// 内容让位:让出的高度必须够(否则最后一行压在条底下 —— 看得见、点不到)
|
||||
assert.ok(N.NAV_CONTENT_RESERVE >= N.NAV_BAR_HEIGHT + N.NAV_BAR_BOTTOM,
|
||||
@ -197,12 +196,12 @@ test('★ 系统 `Tabs` 的 bar 已经不在(这一期换的就是它),且
|
||||
*/
|
||||
const root = main.slice(main.indexOf('build() {\n /*\n * 底部两项'));
|
||||
assert.ok(root.length > 300, '要能取到根 build() 的正文');
|
||||
const navCode = code(root);
|
||||
const navCode = stripComments(root);
|
||||
assert.ok(!/\bTabs\(/.test(navCode), '根导航里不该再有系统 Tabs');
|
||||
assert.ok(!/TabContent/.test(navCode), '根导航里不该再有 TabContent(内容改为按 index 挂载)');
|
||||
assert.ok(!/\.tabBar\(/.test(navCode), '根导航里不该再有 tabBar()');
|
||||
// 反面之二:也不许把旧 builder 留着不用(留着就是死代码,下一个人会以为它还在生效)
|
||||
assert.ok(!/TabBarBuilder/.test(code(main)), 'TabBarBuilder 已被 NavBar 取代,不该留在文件里');
|
||||
assert.ok(!/TabBarBuilder/.test(stripComments(main)), 'TabBarBuilder 已被 NavBar 取代,不该留在文件里');
|
||||
|
||||
// 平级项仍是两项且顺序不变(信息架构这一期不动)
|
||||
assert.deepEqual(N.NAV_ITEMS.map(i => i.label), ['通信', '联系人'], '平级项与顺序');
|
||||
@ -225,7 +224,7 @@ test('★ 玻璃只在两处、且这一处是"背后有可变内容"(GLASS
|
||||
*/
|
||||
const wallpaper = builderBody(main, 'WallpaperLayer() {');
|
||||
assert.ok(!/backgroundBlurStyle/.test(wallpaper), '壁纸层不许有材质(同一张底糊两遍)');
|
||||
assert.ok(!/blur\(/i.test(code(wallpaper)), '壁纸层不许有任何模糊调用');
|
||||
assert.ok(!/blur\(/i.test(stripComments(wallpaper)), '壁纸层不许有任何模糊调用');
|
||||
const bar = builderBody(main, 'NavBar() {');
|
||||
assert.match(bar, /backgroundBlurStyle\(Theme\.navMaterial\)/, '悬浮条必须有系统材质(背后是滚动内容)');
|
||||
// 理由要写在**原文**(注释会被剥掉,而理由就在注释里)
|
||||
|
||||
@ -14,9 +14,10 @@
|
||||
* BlurStyle 的取值则在 `component/common.d.ts` 的 `declare enum BlurStyle` 里。
|
||||
* 两边都能离线查 —— 所以"名字写对没有"可以变成构建期判据。
|
||||
*/
|
||||
import { prose } from './lib/read.mjs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { existsSync, readdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@ -60,7 +61,7 @@ const collectSources = (dir, acc = []) => {
|
||||
|
||||
/** SDK 名表:name → type */
|
||||
const loadIdTable = () => {
|
||||
const recs = JSON.parse(readFileSync(ID_TABLE, 'utf8')).record;
|
||||
const recs = JSON.parse(prose(ID_TABLE)).record;
|
||||
const byName = new Map();
|
||||
for (const r of recs) byName.set(r.name, r.type);
|
||||
return byName;
|
||||
@ -68,7 +69,7 @@ const loadIdTable = () => {
|
||||
|
||||
/** SDK 的 BlurStyle 成员 */
|
||||
const loadBlurStyleMembers = () => {
|
||||
const src = readFileSync(COMMON_DTS, 'utf8');
|
||||
const src = prose(COMMON_DTS);
|
||||
const at = src.indexOf('declare enum BlurStyle');
|
||||
assert.ok(at > 0, 'SDK 里应能定位到 declare enum BlurStyle');
|
||||
// 枚举体到第一个顶格 `}` 为止;成员形如 ` Thin,` 或 ` COMPONENT_THIN = 6,`
|
||||
@ -83,7 +84,7 @@ const loadBlurStyleMembers = () => {
|
||||
const scanSysResources = (files) => {
|
||||
const found = [];
|
||||
for (const f of files) {
|
||||
const src = readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
|
||||
const src = prose(f).replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
|
||||
for (const m of src.matchAll(/\$r\(\s*'sys\.([a-z]+)\.([A-Za-z0-9_]+)'/g)) {
|
||||
found.push({ file: f.slice(ROOT.length + 1), type: m[1], name: m[2] });
|
||||
}
|
||||
@ -95,7 +96,7 @@ const scanSysResources = (files) => {
|
||||
const scanBlurStyles = (files) => {
|
||||
const found = [];
|
||||
for (const f of files) {
|
||||
const src = readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
|
||||
const src = prose(f).replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
|
||||
for (const m of src.matchAll(/BlurStyle\.([A-Z][A-Z_0-9]*)/g)) {
|
||||
found.push({ file: f.slice(ROOT.length + 1), member: m[1] });
|
||||
}
|
||||
@ -213,7 +214,7 @@ function sdkDtsFiles() {
|
||||
function deprecatedGlobalFunctions() {
|
||||
const names = new Set();
|
||||
for (const f of sdkDtsFiles()) {
|
||||
const src = readFileSync(f, 'utf8');
|
||||
const src = prose(f);
|
||||
// 先切出 depth == 0 的片段
|
||||
let depth = 0;
|
||||
let segStart = 0;
|
||||
@ -297,7 +298,7 @@ test('废弃 API:清单**从 SDK 生成**,源码里不得调用(新增一
|
||||
}
|
||||
const hits = [];
|
||||
for (const f of files) {
|
||||
const src = readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
const src = prose(f).replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
for (const name of deprecated) {
|
||||
// 只算**全局调用**:排除成员调用 `x.name(`(`.animateTo(` 是我们要求的新写法)
|
||||
const re = new RegExp(`(?<![.\\w])${name}\\s*\\(`, 'g');
|
||||
|
||||
41
client/electron/test/lib/read.mjs
Normal file
41
client/electron/test/lib/read.mjs
Normal file
@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 判据目录里**唯一**允许读文本文件的两个入口 —— 名字自己解释该选哪个。
|
||||
*
|
||||
* # 为什么要有这个模块(pi 2026-09-14 §4:同一处坑我踩了两次)
|
||||
*
|
||||
* 规范里写着"判代码读剥离版、判理由读原文",我 P5 写过一次、当天又踩了一次
|
||||
* (`'rejected'` 那段**注释**里正好写着 `allowed-once`,被当成"这里会放行"误报)。
|
||||
* **第二次犯规说明问题不在记性,在形态**:靠人记得执行的规范一定会有下一次。
|
||||
* 所以把"用哪个读取器"从**记忆**变成**代码里的一个词**,并且可以被判据检查。
|
||||
*
|
||||
* - `code(path)`:**剥掉注释**。判"代码里有没有这个调用/这个值"时必须用它 ——
|
||||
* 否则解释性注释("这里写 'rejected' 而不是 'denied',因为只认 allowed-once")
|
||||
* 会被当代码读,产生假红/假绿。
|
||||
* - `prose(path)`:**原文**。判"理由写清了没/文档里有没有这句话"时用它。
|
||||
*
|
||||
* 选错的典型症状:断言里的标识符恰好在同文件的注释里出现过(这类误报几乎都集中在
|
||||
* "解释性注释与它解释的标识符同名"的地方)。
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
/** 剥掉注释与字符串字面量里的注释样式文本之外的东西:只用于"代码里有什么" */
|
||||
export function stripComments(src) {
|
||||
return src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '') // 块注释
|
||||
.replace(/(^|[^:])\/\/[^\n]*/g, '$1'); // 行注释(避开 https:// 这类)
|
||||
}
|
||||
|
||||
/** 读文件并**剥掉注释** —— 判"代码里有什么"用这个 */
|
||||
export function code(path) {
|
||||
return stripComments(readFileSync(path, 'utf8'));
|
||||
}
|
||||
|
||||
/** 读文件**原文** —— 判"注释/文档里写了什么"用这个 */
|
||||
export function prose(path) {
|
||||
return readFileSync(path, 'utf8');
|
||||
}
|
||||
|
||||
/** 读**二进制**(安装包、图片等)—— 需要 Buffer 时用它,别在判据里裸用 readFileSync */
|
||||
export function bytes(path) {
|
||||
return readFileSync(path);
|
||||
}
|
||||
@ -2,10 +2,10 @@
|
||||
//
|
||||
// 不做视觉快照:那需要 headless 浏览器,且像素级比对在字体差异下极脆。
|
||||
// 这里守住几条真正会坏掉的不变量。
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { prose } from './lib/read.mjs';
|
||||
import { check, finish } from './lib/checks.mjs';
|
||||
|
||||
const read = p => readFileSync(new URL(p, import.meta.url), 'utf8');
|
||||
const read = p => prose(new URL(p, import.meta.url));
|
||||
|
||||
console.log('窄屏布局回归:');
|
||||
|
||||
|
||||
@ -10,14 +10,14 @@
|
||||
* 为什么写文件级判据:改完跑老套件 254 条**全绿**,因为它一条都没测导航结构 ——
|
||||
* 我差点把"没红的测试"当成"改对了"。结构类改动必须自己带判据。
|
||||
*/
|
||||
import { code } from './lib/read.mjs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const read = (...p) => readFileSync(join(HERE, '..', 'src', ...p), 'utf8');
|
||||
const read = (...p) => code(join(HERE, '..', 'src', ...p));
|
||||
|
||||
const sidebar = read('components', 'Sidebar.tsx');
|
||||
const narrow = read('components', 'NarrowNav.tsx');
|
||||
|
||||
@ -23,9 +23,10 @@
|
||||
* 装上去的人看到的是旧界面,而 Web 上是新的 —— 两边不一致但谁都不报错。
|
||||
*/
|
||||
|
||||
import { code, prose } from './lib/read.mjs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@ -37,7 +38,7 @@ const ROOT = join(HERE, '..');
|
||||
const DIST_HTML = join(ROOT, 'dist', 'index.html');
|
||||
|
||||
test('★ vite 的 base 必须是相对路径(否则 Electron 白屏)', () => {
|
||||
const cfg = readFileSync(join(ROOT, 'vite.config.ts'), 'utf8');
|
||||
const cfg = code(join(ROOT, 'vite.config.ts'));
|
||||
// 不能只看 `base:` 出现在注释里 —— 断言的是真的有一条 base 配置语句
|
||||
assert.match(
|
||||
cfg,
|
||||
@ -53,7 +54,7 @@ test('★ 构建产物里不能有绝对资源路径(这条能在没浏览器
|
||||
console.log('(dist/index.html 不存在 —— 先 cd client/electron && npm run build 才验得到)');
|
||||
return;
|
||||
}
|
||||
const html = readFileSync(DIST_HTML, 'utf8');
|
||||
const html = prose(DIST_HTML);
|
||||
const abs = [...html.matchAll(/(?:src|href)="(\/[^"]*)"/g)].map(m => m[1]);
|
||||
assert.deepEqual(
|
||||
abs,
|
||||
@ -78,7 +79,7 @@ test('★ 安装包里的 dist 必须与当前构建一致(否则装上去的
|
||||
return;
|
||||
}
|
||||
const list = execFileSync('npx', ['asar', 'list', asar], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
||||
const wanted = [...readFileSync(DIST_HTML, 'utf8').matchAll(/\.\/(assets\/[^"]+)/g)].map(m => m[1]);
|
||||
const wanted = [...prose(DIST_HTML).matchAll(/\.\/(assets\/[^"]+)/g)].map(m => m[1]);
|
||||
|
||||
const missing = wanted.filter(p => !list.includes(`/dist/${p}`));
|
||||
assert.deepEqual(
|
||||
@ -129,9 +130,9 @@ test('★ 发布脚本:构建失败时不打包(注入失败的构建,看
|
||||
});
|
||||
|
||||
test('★ 发布脚本:构建只有一条路(走 npm run build,不能是裸 vite build)', () => {
|
||||
const pkg = JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf8'));
|
||||
const pkg = JSON.parse(prose(join(HERE, '..', 'package.json')));
|
||||
const scriptPath = join(HERE, '..', 'scripts', 'release-linux.sh');
|
||||
const script = readFileSync(scriptPath, 'utf8');
|
||||
const script = prose(scriptPath);
|
||||
/*
|
||||
* 两个真实的坑,各自被这条挡住:
|
||||
* ① `build:linux` 原来直接跑裸 `vite build` —— 那会**跳过 `gen:bg`**
|
||||
|
||||
@ -21,8 +21,9 @@
|
||||
*
|
||||
* 写判据之前先读 `test/CRITERIA.md`(判结构与行为,不判字面与邻接)。
|
||||
*/
|
||||
import { prose } from './lib/read.mjs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { existsSync, readdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@ -48,7 +49,7 @@ if (!existsSync(CRITERIA_DOC)) {
|
||||
console.error('✗ 判据规范 test/CRITERIA.md 不见了(写判据的规矩不能只活在脑子里)');
|
||||
process.exit(1);
|
||||
}
|
||||
const criteriaDoc = readFileSync(CRITERIA_DOC, 'utf8');
|
||||
const criteriaDoc = prose(CRITERIA_DOC);
|
||||
for (const must of ['配对/解析', 'allow-list', '变异验证', '剥掉注释', '按行', '自报条数', '只支撑你看到的那一层', '已经在某个提交里', '自带修法', '按 id 联接', '不要退化成对源码形状的匹配']) {
|
||||
if (!criteriaDoc.includes(must)) {
|
||||
console.error(`✗ 判据规范里少了「${must}」这条 —— 规则被删掉了还是搬走了?`);
|
||||
@ -73,7 +74,9 @@ const SUITE = [
|
||||
['test/appearance-defaults.test.mjs', [], 3],
|
||||
['test/build-stamp.test.mjs', [], 6],
|
||||
['test/packaging.test.mjs', [], 5],
|
||||
['test/commit-hygiene.test.mjs', ['--experimental-strip-types', '--no-warnings'], 2]
|
||||
['test/commit-hygiene.test.mjs', ['--experimental-strip-types', '--no-warnings'], 2],
|
||||
// 判据目录自身的卫生:读文本必须走 test/lib/read.mjs 的具名入口
|
||||
['test/criteria-hygiene.test.mjs', [], 2]
|
||||
];
|
||||
|
||||
// 自检 1:清单里的文件必须真的存在(写错名字 = 那条判据永远不跑)
|
||||
@ -106,7 +109,7 @@ for (const [file, flags] of SUITE) {
|
||||
if (flags.includes('--test')) {
|
||||
continue; // node:test 那几条没有 process.exit,结构上不会踩这个
|
||||
}
|
||||
const src = readFileSync(join(ROOT, file), 'utf8');
|
||||
const src = prose(join(ROOT, file));
|
||||
const exitAt = src.lastIndexOf('process.exit(');
|
||||
if (exitAt >= 0 && /(^|\n)\s*check\(/.test(src.slice(exitAt))) {
|
||||
buried.push(file);
|
||||
@ -134,7 +137,7 @@ if (buried.length) {
|
||||
* 再加一条"跑完必须有输出"(12 条判据现在都有输出),静默成功同样可疑。
|
||||
*/
|
||||
function shapeOf(file) {
|
||||
const src = readFileSync(join(ROOT, file), 'utf8');
|
||||
const src = prose(join(ROOT, file));
|
||||
const usesNodeTest = /from 'node:test'/.test(src);
|
||||
const canFail = usesNodeTest
|
||||
|| /(^|[^.\w])check\(/.test(src)
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { prose } from '../lib/read.mjs';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
@ -306,7 +307,7 @@ describe('默认值 = 服务端契约', () => {
|
||||
* 一定会问"这谁写的、能删吗"。答案必须就在他会看的地方,而不是藏在某个人的记忆里。
|
||||
*/
|
||||
it('★ legacy.bak 的生命周期写在文档里(谁写的、为什么不删、怎么删)', () => {
|
||||
const doc = readFileSync(findDoc('docs/HARMONY-ALIGN-PLAN.md'), 'utf8');
|
||||
const doc = prose(findDoc('docs/HARMONY-ALIGN-PLAN.md'));
|
||||
const row = doc.split('\n').find(l => l.includes('agentmail.background.legacy.bak'));
|
||||
expect(row, '§7.12 里要有一行讲这个键').toBeTruthy();
|
||||
expect(row).toContain('有意永久残留');
|
||||
|
||||
@ -7,12 +7,13 @@
|
||||
* 这些检查存在的理由:深色模式的 bug 形态是**白底白字**,
|
||||
* 它不报错、不影响构建、只有肉眼能发现,而且往往只出现在某个不常开的页面。
|
||||
*/
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { code, prose } from './lib/read.mjs';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const read = p => readFileSync(join(here, p), 'utf8');
|
||||
const read = p => prose(join(here, p));
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
@ -158,7 +159,7 @@ check(
|
||||
const compDir = join(here, '../src/components');
|
||||
const offenders = [];
|
||||
for (const f of readdirSync(compDir).filter(x => x.endsWith('.tsx'))) {
|
||||
const src = readFileSync(join(compDir, f), 'utf8');
|
||||
const src = prose(join(compDir, f));
|
||||
// 只看 className 与 style 里的颜色;SVG 的 currentColor 不算
|
||||
const hits = (src.match(/#[0-9a-fA-F]{3,6}\b/g) || []);
|
||||
if (hits.length) offenders.push(`${f}(${hits.join(',')})`);
|
||||
@ -382,7 +383,7 @@ check(
|
||||
const chromeFiles = ['Sidebar', 'NarrowNav'];
|
||||
const slateLeft = [];
|
||||
for (const f of chromeFiles) {
|
||||
const src = readFileSync(join(here, `../src/components/${f}.tsx`), 'utf8');
|
||||
const src = code(join(here, `../src/components/${f}.tsx`));
|
||||
const hits = src.match(/(?:bg|text|border|hover:bg|hover:text|active:bg|ring)-slate-\d+/g) || [];
|
||||
if (hits.length) slateLeft.push(`${f}: ${hits.join(',')}`);
|
||||
}
|
||||
@ -392,7 +393,7 @@ check('框架组件已全部改用 chrome 色阶', slateLeft.length === 0, slate
|
||||
// 被反转成近白色,`bg-gray-900 text-white` 因而只剩约 1:1。
|
||||
const componentSources = readdirSync(compDir)
|
||||
.filter(x => x.endsWith('.tsx'))
|
||||
.map(f => [f, readFileSync(join(compDir, f), 'utf8')]);
|
||||
.map(f => [f, prose(join(compDir, f))]);
|
||||
const graySolid = componentSources.flatMap(([f, src]) =>
|
||||
(src.match(/(?:bg-gray-(?:700|800|900)[^'"\n]*text-white|text-white[^'"\n]*bg-gray-(?:700|800|900))/g) || [])
|
||||
.map(hit => `${f}:${hit}`)
|
||||
|
||||
Reference in New Issue
Block a user