Files
MailUI4Agents/client/electron/test/harmony-arkts.test.mjs
JianFeeeee 25e7d8f3bf 跨端: 补附件区(两端一直都有这个功能,我上次误判成"死代码")+ 修两个真 bug
══ ① 更正我 2026-09-19 的一个**错误结论**(已写进 docs/DEBTS.json 留档)

那天我审计后写下:`GET /me/mail/inbox` 的回包**既没有 `attachments` 也没有
`has_attachments`** ⇒ `MailList.tsx:261` 的 `mail.attachments?.length ?? 0`
恒为 0、WebUI 那个 📎 是**死代码**。并据此在鸿蒙侧**有意不抄**这个标记。

**这个结论是错的**,错在取证方法:我**只看了一封没有附件的邮件**,
看到 key 不在,就断言服务端从不返回它。事实:
· 服务端 `GetInbox`(`mail.go:586`)**明确调了 `fillAttachments`**,注释还写着
  理由:「Agent 靠收件箱列表得知有哪些附件可下载,否则它不知道该调 attachment_id」
· `Mail.Attachments` 的 tag 带 **`omitempty`** ⇒ **没有附件的邮件根本不输出这个 key**

实测 `limit=200`(96 封):带 `attachments` 的 **2 封**,正是真有附件那两封。

★ 教训:**`omitempty` 字段的"缺失"不等于"服务端不返回"**。
  判「某字段有没有」必须拿**确实有值的那条**去验,而不是拿一条恰好为空的数据。
  这与同一天那个白屏崩溃(`session_workspace` 缺失 → `undefined` → 抛)
  是**同一个坑的两面** —— 那天是"缺失 → 客户端崩",今天是"缺失 → 我误判成不返回"。

══ ② 补附件区(鸿蒙原来完全没有)

· `model/Attachment.ts`(新)—— `formatSize` / `attachmentLabel`,纯逻辑无 SDK 依赖,
  逐字对齐 WebUI `api/client.ts:390`(三档 + 保留一位小数)。
· `MailApi.downloadAttachment` —— 走 `getBytes`(不是 `get<T>`:后者假定 JSON,
  取二进制会炸;壁纸当初踩过)。
· `IcsFile.saveBinaryFile` —— 与既有 `saveIcsText` 同一套流程,只是写 `ArrayBuffer`。
· `MailDetailPage` 正文之后渲染附件清单(回形针 + 文件名 + 大小 + 下载),
  位置/形态对齐 WebUI `Attachments.tsx`(无附件时**整块不渲染**)。
· 列表行的 📎 + 数字(`attach_count`,与 `cc_count` 同形状派生)。

══ ③ 顺带撞出并修掉两个**真 bug**

**bug A(差一点就是 94/96 必崩)**:我第一版写 `mail.attachments.length` ——
而 `Attachments` 带 `omitempty`,96 封里只有 2 封有这个 key ⇒ 其余 94 封是
`undefined` ⇒ `.length` 抛。**与当天早些时候那个白屏崩溃是同一个坑,
我刚修过、还在 `Models.ets` 里写了一大段注释,然后加新字段时照踩。**
⇒ 说明"记住别这么写"不管用,要在每个真正读的地方把 `?? []` 写出来。

**bug B(潜在白屏)**:`PermissionTab` 读 `req.session_alias` ——
而服务端 `PermissionRequest` struct **根本没有这个字段**(`models.go:239-255`),
`ListPendingPermissionsFor` 的 SELECT 也没查它,WebUI 的类型里同样没有。
它是我照"授权卡总得显示会话名"的直觉加出来的。⇒ 恒 `undefined`,
一旦有待办就抛。**一直没暴露只因为当前待办数一直是 0**(实测 `{"requests":[]}`)。
⇒ 改成服务端确实有的 `agent_name`,并**删掉那个字段声明**:
让误用变成**编译错**,而不是运行时白屏。

同样是 `body_preview`(`omitempty`,值是 `Body` 的截断)——
空正文 ⇒ 空串 ⇒ 服务端省略 key ⇒ `undefined.length` 抛。那批 96 封恰好都有正文,
所以"看起来没问题"——那正是这个坑的形态。已加 `?? ''`。

══ ④ 新判据:`omitempty` 字段的读法(形状,不是实例)

从 `server/internal/models` **算出**"只以 omitempty 形式出现过"的字段名
(不在判据里手抄名单),再扫鸿蒙侧对它们的裸成员调用。
★ 关键:**不能按字段名一刀切** —— 我第一版就是这么写的,报了 6 处、4 处误报:
  `session_alias` 在服务端有**两个**声明(`Mail` 上带 omitempty、`repo.Contact` 上不带),
  鸿蒙那 4 处读的全是 `Contact` ⇒ 恒有值、不是 bug。
  ⇒ 只扫"从未不带 omitempty 出现过"的名字,那 4 处自动排除。
已逐个核实 4 处豁免(每条都写了取证理由,不是"看着像就放过")。
变异验证:把 `?? []` 去掉 → 判据转红,且**正是**报 `MailStore.ets: mail.attach_count = mail.attachments.length`。

══ ⑤ 数据路径已实测(模拟器)

临时把 `INBOX_PAGE_SIZE` 提到 200(因为有附件那两封在下标 51/52,
默认 limit=50 **根本取不到** —— 这也解释了为什么之前一直没发现),
加临时 hilog 后拿到:
    AttProbe: mail=531a1629-… attach=1
    AttProbe: mail=b68cbbe8-… attach=1
正好是那两封。验完已撤掉探针、`INBOX_PAGE_SIZE` 恢复 50。

══ ⚠️ 本轮**未能**完成设备端视觉验收

模拟器已卡死(`hdc` 能连上但 `shell` 超时;进程 152% CPU、已跑 32 小时),
导致套件里的设备判据各跑 836 秒后失败("要能拉起应用")。
主机可用内存只剩 ~3.7GB。附件区的**渲染**(清单外观、下载落盘)
尚未在设备上看过 —— 待模拟器恢复后补。
2026-09-20 21:14:20 +08:00

499 lines
26 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, prose } 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, '驼峰写法同样要抓');
});
test('★ 服务端「只以 omitempty 形式出现」的字段:客户端读它时必须有 `??` 兜底', () => {
/*
* ★★ 2026-09-20 加。这条是**同一天踩了两次**之后加的判据。
*
* 第一次(真崩溃,白屏 + 应用重启):
* `session_workspace` 带 `omitempty` ⇒ 服务端**不输出这个 key**
* ⇒ `JSON.parse as T` 之后是 `undefined`
* ⇒ `participantAddress` 里 `workspace.trim()` 抛 TypeError。
* 第二次(差一点就是 94/96 必崩):
* 给列表行加"附件数"时写 `mail.attachments.length` ——
* 而 `Attachments` 也带 `omitempty`,实测 96 封里**只有 2 封**带这个 key。
*
* ★ 为什么"记住别这么写"不管用、要写成判据:
* 两次之间我**刚**在 `Models.ets` 头注释里写了一大段讲这个坑,
* 然后加新字段时照踩 —— 记忆不跨"写下一行代码"这个边界。判据能跨。
*
* ── 判据的关键:**不能按字段名一刀切** ──
*
* 第一版我就是按"名字在 omitempty 名单里"扫的,结果报了 6 处,
* 其中 4 处是**误报**:`session_alias` 在服务端有**两个**声明 ——
* · `models.Mail.SessionAlias` `json:"session_alias,omitempty"` ★会缺失
* · `repo.Contact.SessionAlias` `json:"session_alias"` 恒有
* 而鸿蒙那 4 处读的全是 `Contact`(联系人卡片)⇒ 按名字判根本分不出来。
*
* 正确判法:只扫**"只以 omitempty 形式出现过"的字段名** ——
* 即全仓所有 struct 里,该 json 名**从来没有**不带 omitempty 的声明。
* 那样 `session_alias`/`display_name`/`title`/`mail_count`/`key_token`
* 这几个"两种形式都有"的自动被排除(名单从服务端**算出来**,不手抄)。
*/
const GO_FILES = [];
const walkGo = (dir) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, e.name);
if (e.isDirectory()) { walkGo(full); continue; }
if (e.name.endsWith('.go')) GO_FILES.push(full);
}
};
walkGo(join(ROOT, 'server/internal'));
const omitOnly = new Map(); // json 名 → 类型集合
const alsoPlain = new Set(); // 同时存在"不带 omitempty"声明的名字
for (const f of GO_FILES) {
/*
* 读**原文**(`prose`)而不是剥注释后的 `code()`:
* struct tag 里的 `json:"...,omitempty"` 虽然不是注释,但这几行 Go 源码
* 附近有大量解释性注释,而**判据要的是字段声明的字面形状** ——
* 走 `prose` 语义最直白("我在读这一段字面文本"),也符合本仓
* 「不许裸用 readFileSync」那条(改用它就要说清是 code 还是 prose)。
*/
const goSrc = prose(f);
for (const m of goSrc.matchAll(/^\s+(\w+)\s+(\[\]\w+|\*\w+|\w+)\s+`json:"([a-z_]+)(,omitempty)?"/gm)) {
const jsonName = m[3];
if (m[4]) {
if (!omitOnly.has(jsonName)) omitOnly.set(jsonName, new Set());
omitOnly.get(jsonName).add(m[2]);
} else {
alsoPlain.add(jsonName);
}
}
}
/* 只留下"从未不带 omitempty 出现过"的名字 —— 那些读起来一定要有兜底 */
const risky = [...omitOnly.entries()]
.filter(([name, types]) => {
if (alsoPlain.has(name)) return false;
/* 只有"成员调用会抛"的类型才算:字符串与切片。
数值/bool 缺失时比较不抛(`undefined > 0` 是 false),不在此列。 */
return [...types].some(t => t === 'string' || t.startsWith('[]'));
})
.map(([name]) => name);
assert.ok(risky.length >= 5, `应当算出一批"只在 omitempty 里出现"的字段,实际 ${risky.length}`);
assert.ok(risky.includes('attachments'), '`attachments` 应当在名单里(这是第二次踩的那个)');
assert.ok(risky.includes('session_workspace'), '`session_workspace` 应当在名单里(这是崩溃那个)');
assert.ok(!risky.includes('session_alias'),
'`session_alias` 不该在名单里 —— 它在 `repo.Contact` 上是不带 omitempty 的(恒有)');
const hits = [];
const walkEts = (dir) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, e.name);
if (e.isDirectory()) { walkEts(full); continue; }
if (!/\.(ets|ts)$/.test(e.name)) continue;
const src = code(full);
for (const f of risky) {
const re = new RegExp(`\\.${f}\\s*\\.\\s*(length|trim|slice|substring|toUpperCase|toLowerCase|replace|split|startsWith|endsWith|indexOf)\\b`, 'g');
for (const mm of src.matchAll(re)) {
const lineStart = src.lastIndexOf('\n', mm.index) + 1;
const lineEnd = src.indexOf('\n', mm.index);
const line = src.slice(lineStart, lineEnd === -1 ? undefined : lineEnd);
/* 同一行有兜底就不算(`??` 或 `||`) */
if (/\?\?|\|\|/.test(line)) continue;
hits.push(`${e.name}: ${line.trim().slice(0, 96)}`);
}
}
}
};
for (const sub of ['pages', 'common', 'model']) walkEts(join(ETS_ROOT, sub));
/*
* ── 已逐个核实过的**豁免**(不是"看着像就放过")──
*
* 判据按**跨语言字段名**匹配,所以同一个名字在不同接口上含义不同时会有假阳性。
* 下面每条都写了"为什么它不是那个会缺失的字段",改这里必须先重新取证:
*
* ① `attachments` 在 `this.` 上(`MailDetailPage`):
* `this.attachments` 是 `@State attachments: AttachmentInfo[] = []` ——
* 本地状态,初值是**空数组**、且 `MailDetail.normalize()` 在赋值前
* 已经把服务端的缺失兜成 `[]`(`Models.ets:202`)。不是服务端裸值。
*
* ② `attachments` 在 `mail.` 上(`InboxPage`):
* 那个文件是**死代码** —— 不在 `resources/base/profile/main_pages.json`
* 的页面表里、全仓没有任何 `pushUrl('pages/InboxPage')`。
* (它靠"编译器仍会编译"活着,所以判据还是能扫到它。)
*
* ③ `reason`(`MailDetailPage` 的 `this.renameProposal.reason`):
* 这个不是 `models.Mail.RenameReason`,是
* `GET /sessions/{id}/rename-proposal` 的**那条** proposal。
* 服务端(`sessions.go:236`)返回的是 `map[string]string{"alias":…, "reason":…}`
* —— **Go 的 map 里的空串照样输出**(实测:
* `json.Marshal(map[string]string{"reason":""})` → `{"reason":""}`,
* 而 struct 带 omitempty 才会省略)⇒ 这个 key 恒在。
*
* ④ `last_login`(`SettingsPage`):
* 那里写的是 `this.profile.last_login !== undefined && …` ——
* **已经显式判过 undefined**(只是用了 !== undefined 而不是 ??,
* 所以上面那个正则没认出来)。等价且更明确。
*/
const EXEMPT = [
/^MailDetailPage\.ets: if \(this\.attachments\.length/,
/^MailDetailPage\.ets: Text\('附件 ' \+ this\.attachments\.length\)/,
/^InboxPage\.ets: if \(mail\.attachments\.length/,
/^MailDetailPage\.ets: if \(this\.renameProposal\.reason\.length/,
/^SettingsPage\.ets: \(this\.profile\.last_login !== undefined/
];
const real = hits.filter(h => !EXEMPT.some(re => re.test(h)));
assert.deepEqual(real, [],
`这些地方直接读了服务端带 omitempty 的字段(缺失时是 undefined,会抛):\n ${real.join('\n ')}\n` +
'加 `?? []` / `?? \'\'` 兜底(见 Models.ets 顶部那段教训)');
// 自检:探测器要认得错误形状,也要认得兜底后的正确形状
const probe = (t, f) => {
const re = new RegExp(`\\.${f}\\s*\\.\\s*(length|trim)\\b`, 'g');
return [...t.matchAll(re)].filter(mm => {
const line = t.slice(t.lastIndexOf('\n', mm.index) + 1, t.indexOf('\n', mm.index));
return !/\?\?|\|\|/.test(line);
}).length;
};
assert.equal(probe('const n = mail.attachments.length;', 'attachments'), 1, '要抓得住裸读');
assert.equal(probe('const n = (mail.attachments ?? []).length;', 'attachments'), 0, '不许误伤兜底写法');
});