★★ 前置事实修正:报告写的「本机无 hvigorw / 无 SDK / HarmonyOS 侧没有编译过」
**已不成立** —— `/opt/huawei/command-line-tools/bin/hvigorw` 6.26.2 可用,
`hvigorw assembleHap --no-daemon` 出 **BUILD SUCCESSFUL**(约 26 s)。
⇒ 本次两条 HIGH 都是**真编译过**的,不是静态推断。这是本次最大的认知变化:
「改 ArkTS 无法验证 ⇒ 只能推给下一个人」这个前提可以取消了。
## ① MailStore:收件箱与发件箱共用同一份快照(HIGH)
`loadInbox` 与 `loadSent` 此前**全部往同一个 `snapshot` 写**,而两者是两个独立
`@Component`(`InboxTab`/`SentTab`),各自 `await` 完才 `applyStoreSnapshot(...)`
⇒ 切页签 / SSE 交错时**发件箱那次把收件箱那份覆盖掉**:`unread = 0`、
`mails` 换成发件箱的、`loading` 互相关闭 ⇒ 症状是「收件箱未读被清零 /
列表短暂空白 / 转圈停了但列表是空的」。
★ 为什么此前没人动(旧报告标 CRITICAL 却长期未修):它当时的理由是
「Navigation 分栏下两个 pane 同时在屏」这个**必然场景**;`commTab` 后来改成
同一时刻只 mount 一个 ⇒ 那个"必然"没了 ⇒ 从"每次都坏"降级成"交错时坏"。
**这是判据缺失导致缺陷降级**——本条判据就是为了不让它再降级。
**修法(两份快照 + 代号守卫,各挡一半,都要有)**:
· 新增 `sentSnapshot`,`loadSent`/`paintSentFromCache` 只写它;
`SentTab` 读 `store.sentSnapshot`(收件箱侧零改动)。
· `inboxGen` / `sentGen` 代号:同一栏**自己**的两次 load 也可能乱序到达
(SSE 叫醒一次、切页签又触发一次)⇒ 旧的**后**到会盖掉新的。
⚠️ 守卫**只拦写快照**,`loading = false` 仍要执行 —— 否则新请求的 spinner 被挂住。
· `clear()` 清两份,**并把两个代号都 +1 作废**:否则**正在飞**的旧请求回来时
`myGen` 仍等于旧值 ⇒ 会把清空后的快照重新填上旧数据(登出/切账号正是此时)。
★★ 顺带修一个**我差点引入的回归**:拆开之前两份共用一份 ⇒ 归档一个会话会
同时抹掉两边的它。拆开后若只动收件箱,归档完**发件箱仍显示该会话**(而服务端已删)。
⇒ `dropSession` 拆成 `dropSessionFromInbox` / `dropSessionFromSent`,
**两份都要剔**;且发件箱那份**不能用** `splitByPermission(...).normal`
(发件箱不做权限分流,那一筛会抹掉「我发出的授权请求」——09-20 修过的
「发件箱一片空白」同一族)。
## ② AppearanceStore:壁纸 PixelMap 泄漏 + 全尺寸解码(HIGH)
`loadWallpaper` 此前既不设 `desiredSize`、也从不 `release()`:
· `PixelMap` 是**原生内存**,每次同步/每次切账号重新解码一张,全部不释放;
而它是**静态单例**、活过登出 ⇒ **换账号这条路必然泄漏**(无任何清理入口)。
· 不带 `desiredSize` ⇒ 按原图尺寸解。`BackgroundPicker` 只把上传边长卡在
`MAX_EDGE = 2560` ⇒ 一张 2560×2560 ARGB ≈ **26 MB** 常驻,
而它永远被合成器降采样着全屏画。
**修法**(照 `BackgroundPicker` 既有形状,不另创一套):
· `desiredSize` 取 `display.getDefaultDisplaySync()` 的 width/height,
不写死魔数(随屏变)。★ 它**会抛**(SDK 注释 1400001)⇒ 必须 catch,
取不到就退回"不限制尺寸",而不是把壁纸整条路断掉。
· 所有权:先 `this.wallpaper = next` 再释放**旧的**,并把局部变量置空 ——
★ 若在 `finally` 里释放 `this.wallpaper`,会把**刚装上的那张**释放掉,
这是最容易写反的一处。
· 新增 `releaseWallpaper()`,并在 `Logout.ets` 里与 `MailStore.clear()` 并排调用
—— 后者是该方法**唯一**的调用点,没有它它就只是"写好了没人用"。
## 判据(harmony-arkts 8 → 10;harmony-logic 补一格)
新两条按**括号配对**取方法体(`balanced()`,不用 `\{[\s\S]{0,N}` 窗口)。
**变异测试 5 个全部抓住**:loadSent 写回 snapshot / SentTab 读错快照 /
归档不剔发件箱 / 释放的是新图而非旧的 / 去掉 desiredSize。
★ 顺带修一条**HEAD 上就在红的判据**(不是本次引入):`harmony-logic` 那条
「先分家、后折叠」只认字面量 `groupMailsBySession(split.normal)`,
而源码**本来就是** `const inboxMails: MailLike[] = split.normal;` 后
`groupMailsBySession(inboxMails)` ⇒ 恒红。
⇒ 补上"经中间变量"这一支;变异验证:把 `inboxMails` 换成 `mergedMails`
(权限邮件混进收件箱)仍**照红** ✓ —— 放宽的是写法假设,不是判定。
## 编译期硬规则(ArkTS 不接受对象字面量当类型)
`targetSize()` 第一版返回 `{ width: number, height: number }` ⇒ 编译报
`arkts-no-obj-literals-as-types` / `arkts-no-untyped-obj-literals`(连
**返回的对象字面量**也要能对应到**具名**类型)。⇒ 必须先声明 interface,
且每个字面量先赋给**显式类型的局部变量**再 return。
★ 这条**编译期硬规则**在本仓无判据覆盖(harmony-arkts 判的是 import 位置那一类)
⇒ 只能靠真编译抓;而它能抓,再次证明「ArkTS 本机无法验证」已不成立。
## 边界 / 未做
· **本条判据证明不了运行时症状**:切页签交错那条要设备才能造,本仓无那种探针。
静态层只把住「两栏各写各的」。设备那半**仍未验**。
· `releaseWallpaper` 的**实际内存回收**未在真机验证(memory profiler)。
· 离屏解码(`@Concurrent`/taskpool)**本轮不做** —— 全仓 0 先例,
单独引入会扩大风险面。
841 lines
46 KiB
JavaScript
841 lines
46 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);
|
||
|
||
/**
|
||
* 从 `openIdx`(某个 `{` 的位置)开始按**大括号配对**取出一个方法体。
|
||
*
|
||
* ★ 为什么不用 `\{[\s\S]{0,N}` 那种固定宽度窗口:`CRITERIA.md` §1 记着
|
||
* 那个形状被"往规则里加一行注释"绕过(本仓已是第三次露头)。
|
||
* 而下面两条判据要的正是"**这个方法体里**有没有某个调用" ——
|
||
* 用窗口就会把**别的方法**里的话算进来(判据的匹配范围 > 它声称的语义范围)。
|
||
*/
|
||
function balanced(src, openIdx) {
|
||
let depth = 0;
|
||
for (let i = openIdx; i < src.length; i += 1) {
|
||
if (src[i] === '{') depth += 1;
|
||
else if (src[i] === '}') {
|
||
depth -= 1;
|
||
if (depth === 0) return src.slice(openIdx + 1, i);
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 逐行扫"最后一个 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('函数表达式');
|
||
/*
|
||
* ★★ 2026-09-20 补:**正则字面量**(`arkts-no-regexp-literals`)。
|
||
*
|
||
* 这条是照 `arkts-grammar-standards` skill 的规则表逐条回扫时发现的缺口:
|
||
* · 编译器对它是**告警级**(不挡构建),所以 build 一直是绿的;
|
||
* · 全仓也没有判据管它 ⇒ `MailDetailPage.budgetDraftValid()` 里那个
|
||
* `/^\d+$/` 从 2026-09-19 一直活到今天。
|
||
* 「编译器只告警 + 判据不管」= 这条规则在本仓**事实上不存在**。
|
||
*
|
||
* 判法要**避开**三类合法的 `/`:
|
||
* ① 注释(先剥掉)
|
||
* ② 除法 `a / b`(前面是标识符/数字/`)`,且后面紧跟空格或标识符)
|
||
* ③ 字符串里的路径(`'../model/X'`)
|
||
* 所以只认这个形状:`/` 前面是**行首/`=`/`(`/`,`/`return`/`:`**,
|
||
* 且到下一个 `/` 之间不含空白与引号 —— 那是正则字面量的典型写法。
|
||
*/
|
||
const noComment = src;
|
||
const reLike = /(?:^|[=(,:]|return)\s*\/(?![/*=\s])(?:[^/\n'"\\]|\\.)+\/[gimsuy]*/gm;
|
||
if (reLike.test(noComment)) hits.push('正则字面量(用 new RegExp)');
|
||
if (hits.length) bad.push(`${rel(f)}:${hits.join('、')}`);
|
||
}
|
||
assert.deepEqual(bad, [], `★ ArkTS 硬坑(编译不过):\n ${bad.join('\n ')}`);
|
||
|
||
/*
|
||
* 自检:正则字面量那一段探测器要**认得错、不误伤对**。
|
||
* 不写自检的话,"没报"与"探测器坏了"结果一样 —— 那正是这条规则
|
||
* 躲过一轮的原因(它此前根本没人探)。
|
||
*/
|
||
const probeRe = (t) => /(?:^|[=(,:]|return)\s*\/(?![/*=\s])(?:[^/\n'"\\]|\\.)+\/[gimsuy]*/gm.test(t);
|
||
assert.equal(probeRe('return /^\\d+$/.test(t);'), true, '要抓得住 return 后的正则字面量');
|
||
assert.equal(probeRe('const ok = /ab/.test(s);'), true, '要抓得住赋值后的正则字面量');
|
||
assert.equal(probeRe('return new RegExp(\'^[0-9]+$\').test(t);'), false, '不许误伤 new RegExp');
|
||
assert.equal(probeRe('const half = total / 2;'), false, '不许误伤除法');
|
||
assert.equal(probeRe("import { X } from '../model/Y';"), false, '不许误伤字符串里的路径');
|
||
assert.equal(probeRe('const p = apiBase + path; // a / b'), false, '剥注释后的行内斜杠不误伤');
|
||
});
|
||
|
||
/* ─────────────────── 图标:不许用 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 而不是 ??,
|
||
* 所以上面那个正则没认出来)。等价且更明确。
|
||
*
|
||
* ⑤ `reason`(`MainPage.ets` 的 `e.reason`,2026-09-21 加):
|
||
* 这里命中纯属**同名词撞车**。判据是把服务端所有 `json:"reason,omitempty"`
|
||
* 的字段名收集起来,再回 `.ets` 里按名字扫 —— 而那个 omitempty 声明属于
|
||
* `RenameProposal`(`rename_proposal.go:39`,一个**完全不同的**接口)。
|
||
* `e` 是 **`AccountError`**,那是鸿蒙**本地类**(`MailStore.ets:73`),
|
||
* 实例只经 `AccountError.of(account, reason)` 构造 ⇒ `reason` 恒为 string。
|
||
* ⇒ 与 `Mail` 的 omitempty **毫无关系**,不存在 undefined。
|
||
*
|
||
* ⑥ `permission_result`(`MainPage.ets` 的 `m.permission_result`,2026-09-21 加):
|
||
* ★ 这一处**原先是真的会抛**,而修法不是在这里加 `??` ——
|
||
* 而是**在解析边界归一化**(本仓那条纪律:`MailDetail` 就是那么修的)。
|
||
* 已给 `MailSummary` 补了 `normalize()`(`Models.ets`),
|
||
* 并在 `MailApi.inbox()` / `sent()` / `sessionMails()` **三处**接上
|
||
* ⇒ 能走到这一行的 `m` 一定是归一过的,`permission_result` 必为 string。
|
||
* ⇒ 判据看不见"上游归一过了",所以这里必须显式豁免,并把理由写清。
|
||
*
|
||
* ★ 为什么不在这一行加 `??`:那会把**根因**(列表这条主流没有解析边界
|
||
* 兜底)继续盖住,而 `MailSummary` 上还有 `mail_type` / `session_alias`
|
||
* 同样带 omitempty —— 逐个调用点加 `??` 就是本仓反复在消的形状
|
||
* ("每加一处就得记得做一次")。归一化只做一次、且覆盖全部字段。
|
||
*/
|
||
/*
|
||
* 豁免 ⑥ 的**前提**:`MailSummary.normalize()` 真的把两个 omitempty 字段补了。
|
||
*
|
||
* 为什么要有这一步:无条件豁免 = 把那一行永久致盲(变异实测过,见 ⑥ 的注释)。
|
||
* 判据要表达的是"**因为上游归一了**,所以这里可以不写 `??`",
|
||
* 而不是"这一行不用管"。
|
||
*/
|
||
const modelsSrc = prose(join(ETS_ROOT, 'model/Models.ets'));
|
||
const summaryCls = (() => {
|
||
const at = modelsSrc.indexOf('export class MailSummary');
|
||
if (at < 0) return '';
|
||
const end = modelsSrc.indexOf('\nexport class ', at + 10);
|
||
return modelsSrc.slice(at, end < 0 ? undefined : end);
|
||
})();
|
||
const normalizeCoversOmitemptyFields =
|
||
/static normalize\(m: MailSummary\)/.test(summaryCls) &&
|
||
/m\.permission_result = str\(m\.permission_result\)/.test(summaryCls) &&
|
||
/m\.mail_type = str\(m\.mail_type\)/.test(summaryCls);
|
||
|
||
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/,
|
||
/* ⑤ 同名词撞车:`AccountError.reason` 是本地类(见上面 ⑤ 的取证) */
|
||
/^MainPage\.ets: parts\.push\(e\.reason\.length/,
|
||
/*
|
||
* ⑥ 已在解析边界归一化(见上面 ⑥)。
|
||
*
|
||
* ★★ 这条豁免**不能无条件** —— 我第一版就是无条件正则,变异实测
|
||
* (把 `MailSummary.normalize` 里那行 `m.permission_result = str(...)` 删掉)
|
||
* **判据照样全绿**:豁免把这一行永久致盲了。
|
||
* 那正是本仓反复消的形状 —— **豁免口自己没人管**。
|
||
*
|
||
* ⇒ 豁免必须**以"上游真的归一了"为前提**:先断言
|
||
* `MailSummary.normalize` 里确实有这两行,命中才豁免。
|
||
* 归一被删 ⇒ 前提不成立 ⇒ 豁免失效 ⇒ 这一行重新报警。
|
||
*/
|
||
...(normalizeCoversOmitemptyFields
|
||
? [
|
||
/*
|
||
* ★★ 2026-09-23 修:这条豁免原来写的是 `^MainPage\.ets: …`,
|
||
* 而那段代码**已从 `MainPage.ets` 抽成 `pages/PermissionTab.ets`**
|
||
* (用户要求"按页面封装组件以便与 WebUI 一一对应")。
|
||
*
|
||
* 抽出之后豁免不再命中 ⇒ 三行重新报警(判据报的是三个 `permission_result.length`)。
|
||
* ★ 这个红是**对的**,而且值得记:豁免写的是**文件 + 行内容**,
|
||
* 组件搬家就会失效 —— 而失效的方向是**变红**(不是变哑),
|
||
* 这正好与那条豁免注释里担心的方向相反、也都可接受:
|
||
* 宁可多报一次让人来看,也不要静默致盲。
|
||
*
|
||
* ★ 注意这里**同时保留了旧路径**:`permission_result` 的同类读法
|
||
* 在 `MainPage.ets` 里也还有(`MailRow` / `SentRow` 的 chip 文案),
|
||
* 两条路径都要豁免,删掉哪一条都会让另一处假红。
|
||
*/
|
||
/^MainPage\.ets: if \(m\.mail_type === 'permission_request' && m\.permission_result\.length/,
|
||
/^PermissionTab\.ets: if \(m\.mail_type === 'permission_request' && m\.permission_result\.length/,
|
||
/^PermissionTab\.ets: \.fontColor\(m\.permission_result\.length/,
|
||
/^PermissionTab\.ets: \.fontWeight\(m\.permission_result\.length/
|
||
]
|
||
: [])
|
||
];
|
||
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, '不许误伤兜底写法');
|
||
});
|
||
|
||
test('★ 不许用已废弃的全局 API(`router` / `promptAction` / `animateTo` / `vp2px`)', () => {
|
||
/*
|
||
* ★★ 2026-09-20 加。这条是照 `arkts-grammar-standards` skill 的规则表
|
||
* **逐条回扫**时发现的:
|
||
*
|
||
* · 编译器对它们只**告警**、不挡构建 ⇒ `hvigorw assembleHap` 一直全绿;
|
||
* · 本仓也**没有判据**管它们;
|
||
* ⇒ 这些规则在本仓**事实上不存在**,靠"记得别写"维持。
|
||
*
|
||
* 实际抓到的:`api/Logout.ets:76` 的 `router.replaceUrl(...)` 全局调用
|
||
* (从 2026-09-19 一直活到今天)。已改成把 `UIContext` 传进 `performLogout`
|
||
* 再走 `ui.getRouter()`。
|
||
*
|
||
* 为什么值得管:这些是**废弃** API,SDK 升级时是最先被移除的一批;
|
||
* 而且它们绕开了 `UIContext`,在多实例/多窗口场景下语义是错的。
|
||
*
|
||
* 判法:找**裸用**(前面不是 `.` 也不是标识符字符),且排除注释与
|
||
* `this.getUIContext()` / `ui.getRouter()` 这类**正确**写法 ——
|
||
* 正确写法里这些词出现在 `getXxx()` 的方法名位置,前面有 `.`。
|
||
*/
|
||
const DEPRECATED = [
|
||
// [正则, 说明]
|
||
[/(^|[^.\w$])router\.(pushUrl|replaceUrl|back|getParams|clear)\s*\(/m, '全局 router.*(用 this.getUIContext().getRouter() 或 ui.getRouter())'],
|
||
[/(^|[^.\w$])promptAction\.\w+\s*\(/m, '全局 promptAction(用 this.getUIContext().getPromptAction())'],
|
||
[/(^|[^.\w$])animateTo\s*\(/m, '全局 animateTo(用 this.getUIContext().animateTo())'],
|
||
[/(^|[^.\w$])(vp2px|px2vp)\s*\(/m, '全局 vp2px/px2vp(用 this.getUIContext().vp2px(),或 display 密度换算)']
|
||
];
|
||
const bad = [];
|
||
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 [re, msg] of DEPRECATED) {
|
||
if (re.test(src)) bad.push(`${e.name}:${msg}`);
|
||
}
|
||
}
|
||
};
|
||
walkDir(ETS_ROOT);
|
||
assert.deepEqual(bad, [],
|
||
`这些地方用了已废弃的全局 API(编译器只告警、不挡构建):\n ${bad.join('\n ')}`);
|
||
|
||
// 自检:探测器要认得裸用,也要放过正确写法
|
||
const hit = (t, i) => DEPRECATED[i][0].test(t);
|
||
assert.equal(hit('router.replaceUrl({ url: x });', 0), true, '要抓得住全局 router 裸用');
|
||
assert.equal(hit('ui.getRouter().replaceUrl({ url: x });', 0), false, '不许误伤 ui.getRouter()');
|
||
assert.equal(hit('this.getUIContext().getRouter().back();', 0), false, '不许误伤 this.getUIContext()');
|
||
assert.equal(hit('await this.getUIContext().animateTo({}, () => {});', 2), false, '不许误伤 ui.animateTo');
|
||
assert.equal(hit('animateTo({}, () => {});', 2), true, '要抓得住全局 animateTo');
|
||
assert.equal(hit('const x = this.getUIContext().vp2px(24);', 3), false, '不许误伤 ui.vp2px');
|
||
});
|
||
|
||
/*
|
||
* ══════════════ ★★ 收件箱/发件箱**必须是两份快照**(2026-10-03)══════════════
|
||
*
|
||
* ## 缺陷是什么
|
||
*
|
||
* `MailStore.loadInbox` 与 `loadSent` 此前**全部往同一个 `snapshot` 写**,
|
||
* 而两者是两个独立 `@Component`(`InboxTab` / `SentTab`),各自 `await` 完
|
||
* 自己的 load 之后才 `applyStoreSnapshot(store.snapshot)`。
|
||
* ⇒ 切页签 / SSE 事件与两者交错时,**发件箱那次会把收件箱那份覆盖掉**:
|
||
* `snap.unread = 0`、`snap.mails` 换成发件箱的、`loading` 互相关闭。
|
||
* 症状:收件箱未读被清零 / 列表短暂空白 / 「转圈停了但列表是空的」。
|
||
*
|
||
* ★ 旧报告(`harmony-state-review.md` #1)把它标成 CRITICAL,理由是
|
||
* 「Navigation 分栏下两个 pane 同时在屏」。但 `commTab` 后来改成
|
||
* 同一时刻只 mount 一个 ⇒ 那个"必然"没了,症状变罕见 ⇒ 没人动。
|
||
* **这就是「判据缺失导致缺陷降级」**:从"每次都坏"降到"交错时坏"。
|
||
*
|
||
* ## 为什么是**结构**判据而不是设备判据
|
||
*
|
||
* 本条断言的是「两栏各写各的那份快照」—— 这是**源码结构**,静态可判。
|
||
* 真正的运行时症状(切页签交错)要设备才能造,而本仓**没有**那种探针。
|
||
* ⇒ 静态层把住结构,运行时那半记在下面那条的说明里(别假装已验)。
|
||
*
|
||
* ## 变异测试(两个方向都实测过)
|
||
*
|
||
* ① 把 loadSent 的 `this.sentSnapshot` 改回 `this.snapshot` ⇒ 本条红
|
||
* ② 把 SentTab 的 `store.sentSnapshot` 改回 `store.snapshot` ⇒ 本条红
|
||
*/
|
||
test('★ 收件箱与发件箱各写各的快照(共用一份 ⇒ 切页签/SSE 交错时互相覆写)', () => {
|
||
const storePath = join(ETS_ROOT, 'common', 'MailStore.ets');
|
||
const pagePath = join(ETS_ROOT, 'pages', 'MainPage.ets');
|
||
const store = code(storePath);
|
||
const page = code(pagePath);
|
||
|
||
// 两份快照字段都在
|
||
assert.match(store, /^\s*snapshot:\s*MailSnapshot\s*=\s*new MailSnapshot\(\);/m,
|
||
'MailStore 要有收件箱那份 `snapshot`');
|
||
assert.match(store, /^\s*sentSnapshot:\s*MailSnapshot\s*=\s*new MailSnapshot\(\);/m,
|
||
'★ MailStore 要**另外**有 `sentSnapshot` —— 只有一份就是那个缺陷本身');
|
||
|
||
// 取 loadSent 的方法体(按大括号配对,不用固定宽度窗口 —— CRITERIA.md §1)
|
||
const loadSent = /async loadSent\([^)]*\)[^{]*\{/.exec(store);
|
||
assert.ok(loadSent, '未找到 loadSent');
|
||
const body = balanced(store, loadSent.index + loadSent[0].length - 1);
|
||
assert.ok(!/\bthis\.snapshot\b/.test(body),
|
||
'★ loadSent 里出现 `this.snapshot` ⇒ 它仍在写收件箱那份 ⇒ 两者会互相覆写。' +
|
||
'应当写 `this.sentSnapshot`。');
|
||
|
||
// clear() 要清两份
|
||
const clearAt = /clear\(\)\s*:\s*void\s*\{/.exec(store);
|
||
assert.ok(clearAt, '未找到 clear()');
|
||
const clearBody = balanced(store, clearAt.index + clearAt[0].length - 1);
|
||
assert.match(clearBody, /this\.snapshot\s*=\s*new MailSnapshot\(\)/,
|
||
'clear() 要清收件箱那份');
|
||
assert.match(clearBody, /this\.sentSnapshot\s*=\s*new MailSnapshot\(\)/,
|
||
'★ clear() 也要清发件箱那份 —— 它同样是跨账号可见的(`SentTab` 直接读它)');
|
||
|
||
// SentTab 的读取点
|
||
assert.match(page, /applyStoreSnapshot\(store\.sentSnapshot\)/,
|
||
'★ SentTab 必须读 `store.sentSnapshot`;读 `store.snapshot` 就是取错了那一份');
|
||
|
||
/*
|
||
* ★ `dropSession` 也要两份都剔 —— 这是**我差点引入的回归**:
|
||
* 拆开之前两份共用一份快照 ⇒ 归档一个会话会同时抹掉两边的它。
|
||
* 拆开之后若只动收件箱,归档完**发件箱仍显示该会话**(而服务端已经没了)。
|
||
* ⇒ 这条是拆分的**必要配套**,不是额外功能。
|
||
*
|
||
* ★ 判据要判**两个 helper 各自拿的是哪一份**,而不是判 `dropSession` 的体里
|
||
* 出现过 `this.snapshot` —— 实现把两份分给了两个私有方法(各自的类型标注
|
||
* 要显式写,ArkTS 不给推断),主方法只做派发。
|
||
* 第一版我判 `dropSession` 的体 ⇒ **假红**(它当然不含 `this.snapshot`)。
|
||
*/
|
||
const dropAt = /dropSession\(sessionId[^{]*\{/.exec(store);
|
||
assert.ok(dropAt, '未找到 dropSession');
|
||
const dropBody = balanced(store, dropAt.index + dropAt[0].length - 1);
|
||
assert.match(dropBody, /dropSessionFromInbox\(/,
|
||
'dropSession 要剔收件箱那份');
|
||
assert.match(dropBody, /dropSessionFromSent\(/,
|
||
'★ dropSession 也要剔发件箱那份 —— 否则归档后发件箱仍显示它(而服务端已删)');
|
||
|
||
const inboxAt = /private dropSessionFromInbox\([^)]*\)[^{]*\{/.exec(store);
|
||
assert.ok(inboxAt, '未找到 dropSessionFromInbox');
|
||
const inboxBody = balanced(store, inboxAt.index + inboxAt[0].length - 1);
|
||
assert.match(inboxBody, /this\.snapshot/,
|
||
'dropSessionFromInbox 要动收件箱那份');
|
||
assert.ok(!/this\.sentSnapshot/.test(inboxBody),
|
||
'dropSessionFromInbox 不该动发件箱那份(那是 Sent 的活)');
|
||
|
||
const sentAt = /private dropSessionFromSent\([^)]*\)[^{]*\{/.exec(store);
|
||
assert.ok(sentAt, '未找到 dropSessionFromSent');
|
||
const sentBody = balanced(store, sentAt.index + sentAt[0].length - 1);
|
||
assert.match(sentBody, /this\.sentSnapshot/,
|
||
'★ dropSessionFromSent 要动发件箱那份');
|
||
/*
|
||
* ★★ 而发件箱那份**不能**复用收件箱的 `splitByPermission(...).normal`:
|
||
* 发件箱不做权限分流,而那一筛会把「我发出的授权请求」挑出去
|
||
* ⇒ 对它们就会**抹掉**(09-20 修过的"发件箱一片空白"同一族)。
|
||
*/
|
||
assert.ok(!/splitByPermission/.test(sentBody),
|
||
'★ dropSessionFromSent 不得用 splitByPermission —— 发件箱不做权限分流(见 loadSent 注释)');
|
||
assert.match(sentBody, /groupMailsBySession\(kept\)/,
|
||
'发件箱那份直接对全部 kept 分组(与 loadSent 同口径)');
|
||
});
|
||
|
||
/*
|
||
* ★★ 壁纸的 `PixelMap` 必须**有释放路径**(2026-10-03)
|
||
*
|
||
* ## 缺陷是什么
|
||
*
|
||
* `AppearanceStore.loadWallpaper` 此前既不设 `desiredSize`、也从不 `release()`:
|
||
* ① `PixelMap` 是**原生内存**,每次同步/每次切账号都重新解码一张,全部不释放;
|
||
* 而 `AppearanceStore` 是**静态单例**、活过登出 ⇒ **换账号这条路必然泄漏**;
|
||
* ② 不带 `desiredSize` ⇒ 按原图尺寸解。`BackgroundPicker` 只把上传边长卡在
|
||
* `MAX_EDGE = 2560` ⇒ 一张 2560×2560 ARGB ≈ **26 MB** 常驻。
|
||
*
|
||
* ## 为什么判"有 release 调用"而不是"内存没涨"
|
||
*
|
||
* 真机上不好直接观测 native 内存用量,而**释放调用是否存在**是静态可判的。
|
||
* 这与 `harmony-appearance` 里那些设备判据是分工:那边看**观感**,
|
||
* 这边看**资源纪律**。
|
||
*
|
||
* ⚠️ 边界:这**证明不了**「真的没有泄漏」—— 只证明"释放的调用写下来了"。
|
||
* 泄漏的实证仍需真机(memory profiler)。
|
||
*/
|
||
test('★ 壁纸 PixelMap 必须释放,且解码要有目标尺寸(不释放 ⇒ 换账号必泄漏)', () => {
|
||
const path = join(ETS_ROOT, 'common', 'AppearanceStore.ets');
|
||
const src = code(path);
|
||
|
||
const at = /async loadWallpaper\([^)]*\)[^{]*\{/.exec(src);
|
||
assert.ok(at, '未找到 loadWallpaper');
|
||
const body = balanced(src, at.index + at[0].length - 1);
|
||
|
||
assert.match(body, /desiredSize/,
|
||
'★ loadWallpaper 必须给 `desiredSize` —— 不给就按原图尺寸解码' +
|
||
'(BackgroundPicker 的 MAX_EDGE=2560 ⇒ 最坏一张 ≈26 MB 常驻)');
|
||
|
||
assert.match(body, /\.release\(\)/,
|
||
'★ loadWallpaper 必须 release —— PixelMap/ImageSource 是原生内存,不释放不回收');
|
||
|
||
assert.match(body, /finally/,
|
||
'释放要放在 `finally` 里(该路径有多个出口,逐处 release 必漏一处或重一处)');
|
||
|
||
/*
|
||
* ★ 所有权:必须是「换引用 → 释放**旧的**」,而不是释放 `this.wallpaper`。
|
||
* 若在 `finally` 里 `this.wallpaper.release()`,会把**刚装上的那张**释放掉
|
||
* —— 那正是最容易写反的一处,也是判据要单独钉它的理由。
|
||
*/
|
||
assert.match(body, /this\.wallpaper\s*=\s*next/,
|
||
'先把新图挂上(转移所有权),再释放旧的那张');
|
||
assert.match(body, /old[^\n]*\.release\(\)|old\.release\(\)/,
|
||
'★ 要释放的是**旧的**那张(`old`),不是 `this.wallpaper`');
|
||
|
||
// 清理入口必须存在且被调用(否则方法只是"写好了没人用")
|
||
assert.match(src, /releaseWallpaper\(\)\s*:\s*void/,
|
||
'要有 releaseWallpaper() 清理入口(AppearanceStore 是静态单例、活过登出)');
|
||
const logout = code(join(ETS_ROOT, 'api', 'Logout.ets'));
|
||
assert.match(logout, /releaseWallpaper\(\)/,
|
||
'★ releaseWallpaper() 必须有调用点(登出)—— 否则这个方法只是摆设');
|
||
});
|