Files
MailUI4Agents/client/electron/test/harmony-arkts.test.mjs
JianFeeeee f1db99ef41 跨端: 发件箱接上 MailStore(A 的剩余)+ 修 3 处把原始 ISO 印到界面上的时间
══ ① A 的剩余:发件箱走了 store(顺带撞出 store 里的一个真 bug)

`SentTab.load()` 原来把收件箱那 107 行**抄了一遍**(注释里还写着"同收件箱"
—— 抄的时候就知道是重复)。现在改成 3 行调用 `MailStore.loadSent`。

★ 接上之后**立刻炸出一个真 bug**:`MailStore.loadSent` 里写的是
      snap.groups = [];
  而发件箱的列表**就是按会话分组渲染的**(`ForEach(this.groups, …)`)
  ⇒ 接上 store 之后发件箱会**一片空白**,而"接口有返回、loaded > 0"
  会让症状看起来像"数据没到"。

  为什么会写成空数组:它是照收件箱那半抄的,而收件箱的 `groups` 来自
  `splitByPermission` **筛完之后**的 `inboxMails`(授权邮件要挑出去单独成栏)。
  抄的时候只看到"要赋值",没注意发件箱没有那一步筛选 ⇒ 把 `[]` 抄了过来。
  发件箱也**不能**照搬那个筛选:那是为"授权待办栏"服务的,发件箱不显示那栏。

★ 这段经历本身值得记:**死代码不会自己暴露错误**。
  `loadSent` 写完到今天我接上它之前,那行 `groups = []` 一直没人执行过。
  "写完就搁着"和"接上一个调用点"是两件事 —— 后者才算验过。

══ ② 3 处把原始 ISO 直接印到界面上(看到屏幕才发现的)

发件箱那张卡的时间格显示的是
      2026-09-19T02:55:33.10099Z
(23 个字符,把「致 homeagent」那行挤到换行)。

WebUI **三种卡片全部格式化**、一个没漏:`MailList.tsx:151/262`、
`PermissionList.tsx:143/251`,全是 `toLocaleString('zh-CN', {month,day,hour,minute})`
⇒ `MM/DD HH:mm`。鸿蒙的 `compactMailTime` 就是那个实现,收件箱一直在用 ——
所以这不是"要不要格式化"的分歧,是**三处漏调**(收件箱 / 发件箱 / 授权栏)。

══ ③ 补判据(形状而不是实例)

新判据:`Text(<x>.created_at)` 这个**形状**不许出现(`Text` 只负责画,
不做格式化)。为什么枚举形状而不是列举调用点:漏的三处分布在**三个不同 struct**,
按名字枚举一定会再漏第四个。
自检 + 变异验证都做了(把 SentRow 那处改回裸字段 → 判据转红;还原 → 绿)。

设备验证:发件箱正常渲染(会话组 + 条目,非空白),
时间显示 `09/19 10:55` 而非 ISO。
2026-09-20 18:43:35 +08:00

349 lines
18 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]$/;
/*
* ★★ 2026-09-20 补第二类:**排版符号当图标**(上面那一段只覆盖 emoji 那一类)。
*
* 实测漏网 4 处:`Text('‹')`(两处返回键)、`Text('›')`(会话别名前的小箭头)、
* `Text(' ▾')`(账号下拉指示)—— 全都在上面那三个区间**之外**,所以一条都没抓到。
*
* 它们的问题与 emoji 不同、但同样是"看着像图标其实不是":
* · `‹`/`›` 的字形宽窄**由字体决定**,与旁边 20 的 `AmIcon` 对不齐;
* 而且它们**不是** WebUI 用的东西 —— WebUI 那里是
* `ChevronLeftIcon` / `ChevronRightIcon`(SVG,见 `icons.tsx:135`),
* 所以这属于"两端不一致",不只是"字形不好看"。
* · `▾`(U+25BE)同理,WebUI `AccountSwitcher.tsx:84` 用的是
* 同一个 `ChevronRightIcon` **转 90°**(不是一个专门的"下箭头")。
*
* 覆盖 U+2039/203A(single/double guillemets)、U+25B2–U+25CF(几何图形:
* ▴▾◂▪● 这一类常被拿来当箭头/圆点)、U+2190–U+21FF 里的**单字符箭头**
* 仍**不**扫(`→`/`←` 在正文里是标点,上面那段注释已经说明为什么不能扫)。
* ★ 注意 `→` 在 `Text('a → b')` 这种**多条字符**里本来就不匹配
* (正则要求 1–3 个字符**整体**是个符号),所以这里加 `←→` 也不会误伤;
* 但为了不与上面那段注释自相矛盾,这一段仍然只管"几何/引号类"。
*/
const GLYPH_ISH = /^[\u2039\u203A\u00AB\u00BB\u25A0-\u25CF\u25B2\u25BC\u25C0\u25B6]$/;
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];
const trimmed = ch.trim();
if (EMOJI_ISH.test(ch) || GLYPH_ISH.test(trimmed)) {
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 的正确写法');
});
test('★ 时间戳不许把原始 ISO 直接印到界面上(`Text(mail.created_at)`)', () => {
/*
* ★★ 2026-09-20 加。这条是**先看到屏幕上印着原始 ISO** 才回来补的判据:
*
* 发件箱那张卡的时间那一格显示的是
* 2026-09-19T02:55:33.10099Z
* (23 个字符,把「致 homeagent」那一行挤到换行)。
*
* WebUI **三种卡片全部格式化**,一个都没漏:
* · `MailList.tsx:151`(会话组头)与 `:262`(组内条目)
* · `PermissionList.tsx:143`(授权组头)与 `:251`(授权条目)
* 全部是 `toLocaleString('zh-CN', { month:'2-digit', day:'2-digit',
* hour:'2-digit', minute:'2-digit' })` ⇒ `MM/DD HH:mm`。
*
* 鸿蒙的 `compactMailTime` 就是那个格式的实现,收件箱一直在用 ——
* 所以这不是"要不要格式化"的分歧,是**三处漏调**。
*
* ★ 判据形状为什么是"不许出现 `Text(<x>.created_at)`"而不是列举调用点:
* 漏的那三处分布在三个不同的 struct(收件箱/发件箱/授权),
* 按"名字"枚举一定会再漏第四个。而"把 ISO 交给 Text"这个**形状**
* 本身就是错的(`Text` 只负责画,不做格式化)——
* 枚举形状才挡得住漂移。
*
* ★ 边界:`*For()` 那种"取值函数"不算,这里只管**直接**把字段喂给 Text 的。
* 允许 `created_at` 作为**参数**传给格式化函数(那正是正确写法)。
*/
const src = code(join(ETS_ROOT, 'pages/MainPage.ets'));
// Text( 里直接出现 .created_at / .createdAt,且中间没有函数调用
const bad = [...src.matchAll(/Text\(\s*[\w.]*\.(created_at|createdAt)\s*\)/g)]
.map(m => m[0]);
assert.deepEqual(bad, [],
`这些地方把原始 ISO 直接交给 Text 了(屏上会印出 23 个字符的时间戳):\n ${bad.join('\n ')}\n` +
'改用 compactMailTime(...)(收件箱行一直是这么写的)');
// 自检:探测器要认得错误形状,且不误伤正确写法
const probe = (t) => [...t.matchAll(/Text\(\s*[\w.]*\.(created_at|createdAt)\s*\)/g)].length;
assert.equal(probe("Text(mail.created_at)"), 1, '探测器要认得出裸字段');
assert.equal(probe("Text(compactMailTime(mail.created_at))"), 0, '不许误伤传参给格式化函数的写法');
assert.equal(probe("Text(mail.createdAt)"), 1, '驼峰写法同样要抓');
});