══ ① 更正我 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。附件区的**渲染**(清单外观、下载落盘) 尚未在设备上看过 —— 待模拟器恢复后补。
499 lines
26 KiB
JavaScript
499 lines
26 KiB
JavaScript
/**
|
||
* 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, '不许误伤兜底写法');
|
||
});
|