Files
MailUI4Agents/client/electron/test/harmony-arkts.test.mjs
JianFeeeee ff172ed2c1 判据: 修 AmIcon 尺寸检查的假红 —— 它跨过 } 吃到了下一个组件的修饰符
这条判据抓的是真形状(`AmIcon({…}).width(48)` 会让图标贴左上角),
但正则写宽了:`[\s\S]{0,200}?` 允许**跨过 `}`**,于是这种**正确**写法被判红:

    Row({ space: 4 }) {
      AmIcon({ … })
      Text('新建')
    }
    .height(26)      ← 这是 Row 的,不是 AmIcon 的

实测(日历工具栏新加的「+ 新建」按钮):报 `CalendarPage.ets:1223 → .height(`,
而那一行链在 `Row` 上。**是判据误报,不是代码错。**

修法:扫描区间**不允许出现 `{`/`}`**。`AmIcon({…}).width(48)` 里参数花括号已被
`)` 收尾,而跨到下一个组件的路径会被排除。

★ 修完**立刻做了变异验证**(这类放宽最容易顺手把判据改废):
   把真实的 `AmIcon({…})` 改成 `AmIcon({…}).width(48)` ⇒ **判红**;
   还原 ⇒ 绿。既要"不误报",也要"仍能抓真错",两半都得验。
2026-09-19 15:22:34 +08:00

285 lines
14 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.

/**
* ArkTS **编译期**硬规则的判据(本机可跑,不需要设备)。
*
* ── 这一整个文件的来历 ──
*
* `hvigorw assembleHap` 在 `f31bc02` / `7647c24` 上都红了一条:
*
* ERROR: ArkTS:ERROR File: …/MainPage.ets
* "import" statements after other statements are not allowed (arkts-no-misplaced-imports)
*
* 原因是**我**在 `MainPage.ets` 里把 `NAV_MATERIAL_OF` 那张(带注释的)常量表
* **插在了既有 import 之前** —— 而这个仓库里**没有一条判据会跑 ArkTS 的编译规则**:
* 我那一笔的判据判的是"表达式对不对/接没接上",它们全绿,因为**文本层面没问题**,
* 问题只有编译器知道。pi 是构建时撞上的。
*
* ⇒ 教训不是"下次小心",是**把编译器能抓、而判据不抓的那一类固化下来**。
* 一组 import 位置、解构、`any`、函数表达式这些**都不需要设备**、纯文本就能判,
* 所以它们**不该**待在"等设备才能验"的欠账里。
*
* ⚠️ 这个文件**不能**替代 `hvigorw`:它覆盖的是"能静态判出来的那几类"。
* ArkTS 还有大量只有编译器知道的事(类型推断、重载解析、Sendable…)——
* 那部分仍然只有 build 能验,不许把这个文件的存在读成"编译已经验过了"。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { code } from './lib/read.mjs';
// 与其它鸿蒙判据同口径(见 `harmony-admin.test.mjs` 的文件头)
/*
* ★ 仓库根必须**从本文件的位置推**,不许硬编码绝对路径。
*
* 原来这里写的是 `const ROOT = '/home/program/agentmail';` —— pi 2026-09-15 实测出后果:
* 把带违规的提交检出到**别的目录**再跑,判据**读的仍是 `/home/program/agentmail`**,
* 于是"在一个 import 顺序明显违规的检出上 3/3 全绿"。
* 两层后果,第二层最糟:
* ① 它**永远无法验证任何别的 checkout / CI / 镜像**(换目录不是"红",是 readdirSync 直接抛);
* ② 在本机做 worktree 复核时,它会**静默读另一棵树并报绿** —— 正是我们这几轮在消的形状,
* 这次长在判据自己身上。**"规则进来了,对象没进来"**。
* 修法照邻居(10 个鸿蒙判据都是 `join(HERE, '..', '..', '..')`)。
*/
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..', '..');
const ETS_ROOT = join(ROOT, 'client/harmony/entry/src/main/ets');
function allEts(dir = ETS_ROOT) {
const out = [];
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) { out.push(...allEts(p)); continue; }
if (e.name.endsWith('.ets')) out.push(p);
}
return out;
}
const rel = (p) => p.slice(ETS_ROOT.length + 1);
/**
* 逐行扫"最后一个 import"与"第一个非 import 语句"的位置。
*
* 要注意 import 可能是**多行**的(`import {\n a,\n b\n} from '…';`),
* 所以不能只看以 `import` 开头的行 —— 多行块里的成员名会被误判成"语句"。
* (我第一版就这么误判过:把 `NAV_BAR_BOTTOM,` 当成了一条语句。)
*/
function importOrder(src) {
const lines = src.split('\n');
let lastImport = 0;
let firstOther = null;
let inBlock = false;
for (let i = 0; i < lines.length; i++) {
const ls = lines[i].trim();
if (!ls || ls.startsWith('//') || ls.startsWith('*') || ls.startsWith('/*') || ls.startsWith('*/')) continue;
if (ls.startsWith('import ')) {
lastImport = i + 1;
inBlock = !ls.replace(/\s+$/, '').endsWith(';');
continue;
}
if (inBlock) {
// import 块内:`} from '…';` 或成员行
if (ls.startsWith('}') || ls.endsWith(';')) { lastImport = i + 1; inBlock = false; }
continue;
}
if (firstOther === null) firstOther = { line: i + 1, text: ls.slice(0, 60) };
}
return { lastImport, firstOther };
}
test('★ ArkTS:所有 import 必须在任何其它语句之前(arkts-no-misplaced-imports)', () => {
/*
* 这条是**构建时撞出来的**(见文件头)。它判的是"文件级语句顺序",
* 与"import 写全没写全""路径对不对"是不同的事 —— 那些别处判。
*/
const bad = [];
for (const f of allEts()) {
const { lastImport, firstOther } = importOrder(code(f));
if (firstOther && firstOther.line < lastImport) {
bad.push(`${rel(f)}:最后一个 import 在第 ${lastImport} 行,`
+ `但第 ${firstOther.line} 行已是语句「${firstOther.text}」`);
}
}
assert.deepEqual(bad, [],
`★ 这些文件把 import 写在了其它语句**之后** —— ArkTS 编译器会直接报错,构建不过:\n ${bad.join('\n ')}\n` +
' 修法:把 import 全部挪到文件最前(常量表、类、函数都要在它们之后)。');
});
test('★ 判据自检:import 顺序检查必须能抓到"常量插在 import 之前"', () => {
/*
* 这条自检是**必须的**:上面那条今天全绿,而它绿的原因可能是"真的没问题",
* 也可能是"我的扫描逻辑失效了"(把整段当注释跳过、多行 import 判错…)。
* 造一个**已知坏样本**,确认它会被判红 —— 这正是这次事故的形状。
*/
const badSample = [
"import { a } from './a';",
'',
'const TABLE: Record<string, number> = {',
" 'x': 1",
'};',
'',
"import { b } from './b';",
'',
'export function use(): number { return TABLE.x + b; }'
].join('\n');
const r = importOrder(badSample);
assert.ok(r.firstOther && r.firstOther.line < r.lastImport,
'自检失败:检查逻辑抓不到"常量插在两组 import 之间"——那正是本次构建报错的形状');
// 反向:合法样本不许误报
const goodSample = [
"import { a } from './a';",
"import { b } from './b';",
'',
'const TABLE: Record<string, number> = {',
" 'x': 1",
'};'
].join('\n');
const g = importOrder(goodSample);
assert.ok(g.firstOther && g.firstOther.line > g.lastImport, '自检失败:合法样本被误报');
});
test('ArkTS 词汇层硬坑:全仓 .ets 不许出现解构 / any / unknown / 函数表达式', () => {
/*
* 这几条此前**只在我新写的那个页面里**判(`harmony-admin.test.mjs` ⑨),
* 也就是说"我自己新写的文件"有判据、"别的文件"没有 —— 而我恰恰是在
* **改既有文件**(`MainPage.ets`)时犯的下一个错。
* ⇒ 铺到全部 `.ets`:编译期硬规则不该按"谁写的"分覆盖。
*/
const bad = [];
for (const f of allEts()) {
const src = code(f);
const hits = [];
if (/const\s*\{[^}]*\}\s*=/.test(src) || /let\s*\{[^}]*\}\s*=/.test(src)) hits.push('对象解构');
if (/\bany\b/.test(src)) hits.push('any');
if (/\bunknown\b/.test(src)) hits.push('unknown');
if (/\bfunction\s*\(/.test(src)) hits.push('函数表达式');
if (hits.length) bad.push(`${rel(f)}:${hits.join('、')}`);
}
assert.deepEqual(bad, [], `★ ArkTS 硬坑(编译不过):\n ${bad.join('\n ')}`);
});
/* ─────────────────── 图标:不许用 Unicode 符号,且尺寸不许直接加在 AmIcon 上 ─────────────────── */
/**
* 这两条都是 2026-09-18 用户**看着界面**报出来的,而且都不是「代码不合法」——
* 编译器与所有既有判据全绿,只有屏幕上是错的。
*/
test('★ 图标不许用 Unicode 符号充当(`Text(\'✉\')` 会被系统渲染成彩色 emoji)', () => {
/*
* 登录页原本写的是 `Text('✉')`(U+2709)当品牌图标。
*
* HarmonyOS 的字体链里有**彩色 emoji 字体**,U+2709 自带 emoji 字形 ⇒
* 它被渲染成一枚**彩色 emoji**(黄白色风信封)而不是单色图标:
* ① `.fontColor(Theme.accent)` 对彩色 emoji **无效**(界面显示的是 emoji 自带颜色);
* ② 与底栏/侧栏那些 `AmIcon` 线描图标不是同一套视觉语言。
* 实测截图硬证:大屏下那枚 emoji 比旁边的文字还显眼。
*
* 本仓**早就有** `ICON_PATHS.brandMark`(就是 App 图标那个信封),
* 所以这条纪律的成本是零:界面里所有图标都走 `AmIcon`。
*
* 判据只扫 `Text('…')` 里**单独一个**符号的情况 —— 正文里的标点/箭头不算
* (例如提示语里的「·」或「→」),否则会误伤大量正常文案。
*/
const ETS = join(ROOT, 'client', 'harmony', 'entry', 'src', 'main', 'ets');
/*
* 会被 emoji 字体接管的**典型图标类**符号:
* U+2600–U+27BF(Misc Symbols / Dingbats:☀ ☂ ✈ ✉ ✏ ✔ ❤ …)
* U+2B00–U+2BFF(杂项符号与箭头)
* U+FE0F(变体选择符,把字符变成 emoji 呈现)
* ★ 刻意**不含 U+2190–U+21FF(基本箭头)**:`→`/`←` 在正文里是**标点**,
* 不是图标 —— 把它们也扫进来会误伤大量正常文案。
* (我第一版把箭头段也框了进去,结果自检自己就先红了:
* `!EMOJI_ISH.test('→')` 不成立 —— 断言写错就是断言写错,不能靠放宽它来「修」。)
*/
const EMOJI_ISH = /^[\u2600-\u27BF\u2B00-\u2BFF\uFE0F]$/;
const hits = [];
const walkDir = (dir) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, e.name);
if (e.isDirectory()) { walkDir(full); continue; }
if (!/\.ets$/.test(e.name)) continue;
const src = code(full);
for (const m of src.matchAll(/Text\(\s*'([^']{1,3})'\s*\)/g)) {
const ch = m[1];
if (EMOJI_ISH.test(ch)) {
hits.push(`${e.name}: Text('${ch}')(U+${ch.codePointAt(0).toString(16).toUpperCase()})`);
}
}
}
};
walkDir(ETS);
assert.deepEqual(hits, [],
`用 Unicode 符号当图标会被系统渲染成 emoji,且不吃 fontColor:\n ${hits.join('\n ')}\n` +
'改用 AmIcon(图标表在 common/Icons.ets,品牌标是 brandMark)');
// 自检:探测器要真能认出这个形状(否则"没有命中"与"探测器坏了"结果一样)
assert.ok(EMOJI_ISH.test('\u2709') && EMOJI_ISH.test('\u2764'),
'探测器要认得出 U+2709 / U+2764 这类符号');
assert.ok(!EMOJI_ISH.test('·') && !EMOJI_ISH.test('→'),
'探测器不许把正文标点当成图标(会误伤大量文案)');
});
test('★ 尺寸/底色不许直接链在 `AmIcon` 上(内层容器固定 iconSize 且靠左上 ⇒ 图标贴左上角)', () => {
/*
* `AmIcon` 内部那个 `Stack` 是 `iconSize` 那么大、默认靠左上排版。
* 调用方写 `AmIcon({…}).width(48).height(48)` 想要个大点的可上色盒子时,
* **外层盒子变大、图标不动** ⇒ 图标贴在盒子左上角。
*
* 实测(登录页品牌卡,三折叠 3.5 密度,`dumpLayout` 读的实际 bounds):
* 卡片 [1523,521][1661,659] 138×138px
* 图标 [1526,524][1589,587] 63×63px ← 左边距/上边距都只有 3px
* 中心偏 10vp。用户原话:「你自己看看那个图标的位置正常吗」。
*
* 正确写法是套一层居中的容器(仓里回复球与悬浮加号本来就是这么做的):
* Stack({ alignContent: Alignment.Center }) { AmIcon({…}) }.width(48).height(48)
*
* 判据形状:同一表达式里,`AmIcon({…})` 之后**紧跟着** `.width(` / `.height(`
* 就算命中(中间只允许换行与空白)。套了 `Stack` 的写法中间隔着 `}`,
* 所以不会被误判。
*/
const ETS = join(ROOT, 'client', 'harmony', 'entry', 'src', 'main', 'ets');
const hits = [];
const walkDir = (dir) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, e.name);
if (e.isDirectory()) { walkDir(full); continue; }
if (!/\.ets$/.test(e.name)) continue;
const src = code(full);
/*
* `AmIcon({ ... })` 后面直接跟 `.width(` 或 `.height(`。
*
* ★★ 2026-09-19 修(判据自己撞出来的假红):原先写的是
* `AmIcon\(\{[\s\S]{0,200}?\}\)\s*\.\s*(width|height)\s*\(`
* —— `[\s\S]{0,200}?` 可以**跨过 `}`**。于是这种**正确**写法被判红:
*
* Row({ space: 4 }) {
* AmIcon({ ... })
* Text('新建')
* }
* .height(26) ← 这是 Row 的,不是 AmIcon 的
*
* 实测(日历工具栏的「+ 新建」按钮):报 `CalendarPage.ets:1223 → .height(`,
* 而那一行链在 `Row` 上。
*
* 修法:扫描区间里**不允许出现 `}` 或 `{`** —— 只有同一表达式内的
* 换行/空白/参数才算。`AmIcon({…}).width(48)` 里 `{…}` 已被 `\)` 收尾,
* 而跨过 `}` 到下一个组件的路径会被这条排除。
*/
for (const m of src.matchAll(/AmIcon\(\{[^{}]{0,200}?\}\)\s*\.\s*(width|height)\s*\(/g)) {
const line = src.slice(0, m.index).split('\n').length;
hits.push(`${e.name}:${line} → .${m[1]}(`);
}
}
};
walkDir(ETS);
assert.deepEqual(hits, [],
`尺寸直接加在 AmIcon 上会让图标贴左上角(内层容器固定 iconSize):\n ${hits.join('\n ')}\n` +
'改成 Stack({ alignContent: Alignment.Center }) { AmIcon({…}) }.width(N).height(N)');
// 自检:探测器必须认得这个形状,且不误伤正确的嵌套写法
const bad = "AmIcon({ iconName: 'x', iconSize: 24 }).width(48)";
const good = "Stack({ alignContent: Alignment.Center }) {\n AmIcon({ iconName: 'x', iconSize: 24 })\n}\n.width(48)";
const probe = (t) => [...t.matchAll(/AmIcon\(\{[\s\S]{0,200}?\}\)\s*\.\s*(width|height)\s*\(/g)].length;
assert.equal(probe(bad), 1, '探测器要认得出错误写法');
assert.equal(probe(good), 0, '探测器不许误伤套了 Stack 的正确写法');
});