Files
MailUI4Agents/client/electron/test/commit-hygiene.test.mjs
JianFeeeee 22c9be7181 跨端: 预设色板两套(pi:这不是"观感未验"而是机制上确定不同)+ 手写色清册跨文件 + TMPDIR 按会话分家 + 提交归属可判
pi 读完 `model/Wallpaper.ts` 后指出四处,全部处理。这一提交同时改了
`client/harmony/` 与 `client/electron/`(跨端改动),所以 subject 按新约定自报家门。

## 1 预设色板不随主题 —— **类别判错了:不是"未验",是机制上确定不同**

我上一版把"深色档预设"记成"观感未验"。pi 指出:WebUI 的 `.bg-preset-*` 写的是
`rgb(var(--c-blue-100))`,而 `--c-*` 在 `.dark` 里整体换了一套(blue-100 → `30 43 67`),
所以 **WebUI 的预设自动随主题变**;这边只有浅色那套 = 深色主题下"浅色渐变垫在深色系统表面之下",
正是这一整轮在治的病。**它不需要真机就能判**(机制写在代码里)—— 我把可判的东西
记成了"未验",这跟上一轮把"没做"写成"没验"是同一类错。

选 pi 倾向的那条(跟随主题,与 WebUI 一致):

- 色板两套:`LIGHT_*` 取 CSS `:root`、`DARK_*` 取 CSS `.dark`;`paletteFor(dark)` 选一套,
  `layersFor(id, dark)` 按主题出层;
- `isDarkMode(theme, systemColorMode)` 放在纯逻辑里:选了 dark/light 就照办,
  `system` 看系统当时的 `colorMode`(锚到 SDK:`COLOR_MODE_DARK = 0` / `COLOR_MODE_LIGHT = 1`;
  读不到按浅色,与 WebUI 的 `:root` 默认一致);
- 系统深浅从 `resourceManager.getConfigurationSync().colorMode` 读
  (`Context` 基类没有 `config`;`UIAbilityContext.config` 要转型;两个枚举取值一致,都核过 SDK);
- 判据:两套值与 `:root`/`.dark` **逐个相等**;每个预设的深浅两套**必须真的不同**
  (否则"两套"是抄了两遍);网格线色也要换;`isDarkMode` 五种输入。
- **未做**:运行期间改系统深浅色不会自动重算(要重进页面)——系统侧正确做法是订阅
  `applicationContext.on('environment', …)`,记在 §7.17b。

变异:`DARK_BLUE_100` 偏一位 → 红;`paletteFor` 永远返回浅色(= 我原来那个状态)→ 红;
`isDarkMode` 把系统深浅记反 → 红;页面把深浅写死成 false → 红。

## 2 手写色清册**跨文件按类扫**(原 A2 只保护 `Theme.ets`)

`Wallpaper.ts` 也有手写色。若对照是"按名字枚举"的,第 15 个色就会逃掉 ——
与 A2 要防的是同一件事,只是换了文件。现在一份清册按类扫:全 `ets/` 树里每个
`X: string = '#RRGGBB'` 都必须登记(Theme 的品牌/业务语义色,或预设色板 ——
后者常量名必须带 `LIGHT_`/`DARK_` 前缀,值由 CSS 两段比对负责)。反向也判清册过期。

变异:`Theme.ets` 加未登记色 → 红;`Wallpaper.ts` 加未登记色 → 红;
加一个"看着合规"的 `DARK_EXTRA` → 红。

## 3 `TMPDIR` 互踩(pi 提出)

这个 worktree 可能同时有多个 agent 跑构建,而 fpm 会把 291MB 的 `linux-unpacked`
**整份复制**进 `TMPDIR` —— 撞车就是随机的产物损坏。`whoami` 区分不开(大家都是 root),
所以按**会话**分家:`TMPDIR=$PWD/.tmp/${DSH_SESSION_ID:-$(id -un)-$$}`
(进了 `npm run build:linux` 与 BUILD.md 的手敲命令;普通终端退化成"用户+PID")。

## 4 提交归属变成**跑判据就看得出来**(pi 给的形状)

新的 `test/commit-hygiene.test.mjs`:扫最近 40 条提交,**同时改两侧目录**的提交
必须在 subject 里自报家门(`跨端:`)。两条防腐:基线 = 该判据文件自己的引入提交
(**历史不改**,规则管从今往后);分类逻辑拿合成输入自检
(未标注的混合提交必须判红、标注过的不许红)——否则"解析没跑起来"时它会全绿。
变异:`COMMIT_HYGIENE_BASELINE` 指到老提交 → 历史里那两个被卷进去的提交立刻判红。

## 验证

`hvigorw assembleHap` BUILD SUCCESSFUL;`npm test` 退出码 0
(12 个判据文件全绿 + vitest 258/258;`commit-hygiene` 在本提交落地后基线生效)。
2026-09-14 14:57:18 +08:00

155 lines
7.1 KiB
JavaScript
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.

/**
* 提交归属判据 —— "跨端提交必须自报家门"。
*
* 由来pi 2026-09-14我这轮的鸿蒙改动被**并发写入者**的 `git add -A`
* 整片扫进了两个 WebUI 提交(提交信息写的是"联系人项玻璃卡""手势与横向滚动分家"
* 代码是对的、归属是错的。当时的处置是补一条文档留痕§7.17a)——
* 但"按路径 add"这种做法**靠记性**,而记性不是判据。
*
* 可判的形状pi 给的):同时改了 `client/harmony/` 与 `client/electron/` 的提交,
* **必须在 subject 里显式标注**`跨端:`)。我们本来就有"跨端判据要两侧一起改"的约定,
* 那种提交是合法的 —— 只是必须自报家门。不报的,就是被 `git add -A` 卷进去的。
*
* 两条防腐:
* 1. **历史不改**pi 明确说不用改)。基线 = 本判据文件自己的引入提交,
* 比它更老的提交一律豁免 —— 规则管"从今往后"。
* 2. 分类逻辑拿**合成输入**自检(混合提交无标注 → 必须判红;有 `跨端:` → 不许红)。
* 否则这条判据可能在"解析没跑起来"的情况下全绿。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..', '..');
/** 跨端提交的"自报家门"标记subject 里出现任一个即可 */
const MARKERS = ['跨端:', 'cross-client:', '[跨端]'];
/** 判据扫多少条提交 */
const SCAN = 40;
function git(args) {
return spawnSync('git', args, { cwd: ROOT, encoding: 'utf8' });
}
/**
* 解析 `git log --format=%H%x09%s --name-only` 的输出 → [{hash, subject, files}]。
*
* 纯函数,便于拿合成输入自检(见文件末尾)。
*/
export function parseLog(out) {
const commits = [];
let cur = null;
for (const line of out.split('\n')) {
if (line.startsWith('\t')) continue;
const m = /^([0-9a-f]{40})\t(.*)$/.exec(line);
if (m) {
cur = { hash: m[1], subject: m[2], files: [] };
commits.push(cur);
continue;
}
const f = line.trim();
if (cur !== null && f.length > 0) cur.files.push(f);
}
return commits;
}
/** 一趟提交该不该被要求"自报家门":两侧目录都碰了才算跨端 */
export function isMixed(commit) {
const harmony = commit.files.some(f => f.startsWith('client/harmony/'));
const electron = commit.files.some(f => f.startsWith('client/electron/'));
return harmony && electron;
}
/** 有没有自报家门 */
export function isDeclared(commit) {
return MARKERS.some(m => commit.subject.includes(m));
}
/** 分类:返回该提交的判定结果(供判据与自检共用) */
export function classify(commits, baseline) {
const idx = baseline === null ? -1 : commits.findIndex(c => c.hash === baseline);
// 基线之后的提交才判(基线找不到时:全判,宁可红也不要静默放过)
const inScope = idx >= 0 ? commits.slice(0, idx) : commits;
return inScope
.filter(isMixed)
.map(c => ({ hash: c.hash.slice(0, 8), subject: c.subject, declared: isDeclared(c) }));
}
test('跨端提交必须自报家门(同时改 harmony 与 electron 的提交要标 `跨端:`', () => {
const available = git(['--version']);
assert.equal(available.status, 0, '这条判据要能跑 git');
const log = git(['log', `-n${SCAN}`, '--format=%H%x09%s', '--name-only']);
assert.equal(log.status, 0, 'git log 要能跑(否则这条判据无从判起)');
const commits = parseLog(log.stdout);
assert.ok(commits.length >= 5, `要能解析到提交(实际 ${commits.length} 条)—— 解析退化了就会全绿`);
/*
* 基线:本判据文件自己的**引入提交**。比它更老的提交(含被卷进 WebUI 提交的那两个)
* 一律豁免 —— pi 明确说历史不用改,规则管"从今往后"。
* `COMMIT_HYGIENE_BASELINE` 可覆盖(用于验证判据真的会红)。
*/
const envBaseline = process.env.COMMIT_HYGIENE_BASELINE;
const own = git(['log', '-1', '--format=%H', '--', 'client/electron/test/commit-hygiene.test.mjs']);
const baselineHash = envBaseline !== undefined
? (envBaseline === '' ? null : envBaseline)
: (own.status === 0 && own.stdout.trim().length === 40 ? own.stdout.trim() : null);
const verdicts = classify(commits, baselineHash);
const undeclared = verdicts.filter(v => !v.declared);
assert.deepEqual(undeclared.map(v => `${v.hash} ${v.subject}`), [],
'这些提交同时改了 client/harmony/ 与 client/electron/ 却没说自己是跨端提交 —— ' +
'要么在 subject 里写明(`跨端: …`),要么别用 `git add -A` 把别人的改动卷进来(按路径 add');
});
test('自检:分类逻辑对合成输入有分辨力(否则这条判据可能在"解析没跑起来"时全绿)', () => {
const mixed = {
hash: 'a'.repeat(40),
subject: 'fix(webui): 联系人项改为玻璃卡',
files: ['client/electron/src/x.tsx', 'client/harmony/entry/src/main/ets/pages/MainPage.ets']
};
const declared = {
hash: 'b'.repeat(40),
subject: '跨端: 通信页签两边一起改',
files: ['client/electron/src/x.tsx', 'client/harmony/entry/src/main/ets/pages/MainPage.ets']
};
const single = {
hash: 'c'.repeat(40),
subject: 'fix(webui): 只改 WebUI',
files: ['client/electron/src/x.tsx']
};
assert.equal(isMixed(mixed), true, '两侧都改 = 跨端');
assert.equal(isMixed(declared), true);
assert.equal(isMixed(single), false, '只改一侧不算跨端(否则这条会天天误报)');
assert.equal(isDeclared(mixed), false);
assert.equal(isDeclared(declared), true, '`跨端:` 要认出来');
// 基线之后:混合未标注 → 判红;标注过 → 不判
const v = classify([mixed, declared, single], null);
assert.deepEqual(v.map(x => `${x.hash.slice(0, 2)}:${x.declared}`), ['aa:false', 'bb:true']);
/*
* 基线语义:**比基线新的**才判git log 是新的在前)。
* 所以数组里 mixed 在前(比 declared 新)→ 它要判declared 自己与更老的 single 豁免。
*/
const scoped = classify([mixed, declared, single], declared.hash);
assert.deepEqual(scoped.map(x => x.hash.slice(0, 2)), ['aa'], '基线之前的提交要豁免(历史不改)');
// 反向:基线就是最新那条时,没有任何提交在范围内(= 刚引入这条判据时的正常状态)
assert.deepEqual(classify([declared, mixed, single], declared.hash).filter(x => x.hash.startsWith('bb')), []);
// 解析器:真实的 git log 形状要能解开
const parsed = parseLog([
`${'d'.repeat(40)}\t跨端: 某次跨端改动`,
'',
'client/electron/src/a.ts',
'client/harmony/entry/src/main/ets/a.ets',
`${'e'.repeat(40)}\tfix(webui): 只改 WebUI`,
'',
'client/electron/src/b.ts',
''
].join('\n'));
assert.equal(parsed.length, 2);
assert.equal(parsed[0].files.length, 2, '文件清单要解析到(否则 isMixed 永远 false');
assert.equal(parsed[1].files.length, 1);
});