跨端对齐:授权栏 navigator_only + 组件按页拆分 + 服务器补 permission_options

用户两项裁定落地(均为 ask_user 明确选择):

① 授权栏口径 = navigator_only(照 WebUI 架构)
   · 新建 pages/PermissionPanel.ets —— 详情页的决策面板,
     对应 MailView.tsx:693 的 PermissionPanel(审批型 / 主动提问 / 已处理 三态)
   · 决策入口从授权栏移到 MailDetailPage;MailDetailPage 原来只显示一个
     「权限请求」小标签、根本没有决策入口(比 WebUI 少一整块,且反了:
     栏里能决策、点进详情反而不能)
   · PermissionTab 删掉内联「同意/拒绝」+ 备注框 + decide():
     整卡可点 → onOpenMail(对齐 WebUI PermissionList.tsx:81 的 pick())
   · PermissionRequest 补 source_account_id(客户端侧记来源,跳详情要定位网关)

② 服务器补 permission_options —— 修一条真实的、跨端共有的缺口
   · mails.permission_options 从 INSERT 起就写进去,但**从来没有任何读路径
     选过它** ⇒ 详情端点永远返回空。WebUI 的决策面板读 mail.permission_options,
     所以提问型的预设选项**两端全部落空**(审批型靠 ['同意','拒绝'] 兜底蒙混)
   · GetMailByID 补选该列 + JSON 反序列化(与 cc_list 同款)

③ 组件按页封装(用户要求「以便与 WebUI 一一对应」)
   MainPage.ets 4592 → 3192 行
   · pages/PermissionTab.ets    720 行  ↔ PermissionList.tsx
   · pages/ContactsTab.ets      796 行  ↔ ContactPanel.tsx
   · pages/NavDestinations.ets  181 行  ↔ Navigation 壳
   · pages/NavShared.ets         65 行  ↔ 跨栏共用件

④ 判据跟着组件搬家(否则静默失效,不是红)
   harmony-logic 的 pageCode / harmony-nav 的 navSrc 改为显式文件名单;
   harmony-appearance 的 PANE_SOURCES 补 ContactsTab;harmony-contacts 三个
   test 并入 ContactsTab;harmony-logic 的决策断言改指 PermissionPanel,
   并新增「授权栏不许再有内联决策」两条(navigator_only 的正形状)。

   animation-audit:共享元素转场判据从「同文件共址」改为「按 id 找驱动」。
   旧形状把 in/out 端必须在同一文件当成代理,而两端**天然在两处**;
   抽出写信页(NavDestinations 持有 in 端)后误报。新判据仍要求每个 id
   都有 Motion.morph 驱动 —— 变异实测:把驱动换成裸 animateTo 仍判红。

判据:files=34 checks=556 red=1(仅 build-stamp,产物待重构建)
This commit is contained in:
2026-09-24 10:10:32 +08:00
parent 487c1c222b
commit 65de1c3884
31 changed files with 4147 additions and 1684 deletions

View File

@ -200,12 +200,62 @@ const themeSrc = code(join(ROOT, 'client/harmony/entry/src/main/ets/common/Theme
check(
'鸿蒙|morph 只有**一个**入口且它内部有 animateTo(时长/曲线同处)',
/static morph\(ui: UIContext, mutate: \(\) => void\): void \{/.test(MOTION) &&
/ui\.animateTo\(/.test(MOTION) &&
/duration:\s*Motion\.dur\(Theme\.durMorph\)/.test(MOTION) &&
/curve:\s*Theme\.easeRise/.test(MOTION),
'`Motion.morph` 的形状变了(少了 animateTo / 时长令牌 / 曲线)—— ' +
/ui\.animateTo\(/.test(MOTION),
'`Motion.morph` 的形状变了(少了 animateTo 或统一入口)—— ' +
'官方:「必须配合 animateTo 使用才有动画效果,时长与曲线跟随 animateTo 的配置」;' +
'三者必须在**同一处**,散开就必然有人漏'
'时长/曲线与 animateTo 必须在**同一处**,散开就必然有人漏'
);
/*
* ★★ 2026-09-21 改:从"钉 `duration: Motion.dur(Theme.durMorph)` + `curve: Theme.easeRise`"
* 改为钉**官方弹簧曲线的契约**。
*
* 为什么旧钉法必须改(而不是把字面量改成新字面量):
* 用户指出「webui 是 webui,app 是 app…APP 存在大量系统预制动效,为什么不用?」
* —— 我此前把 WebUI 的 CSS 数值(220ms + cubic-bezier)当成了规格,
* 而那只是它的**带宽约束下的上限**。改用系统弹簧曲线后,
* `duration` 按 SDK 原文**根本不再生效**:
* 「The **duration** parameter does not take effect when springMotion /
* responsiveSpringMotion / interpolatingSpring are configured for **curve**.」
* ⇒ 继续断言"有 duration"等于在钉一个**不再成立的前提**。
*
* 新钉法看**三件真的事**:
* ① 走官方弹簧曲线(`Theme.springResponsive` / `springMotion`);
* ② 经过 `Motion.anim` 那道统一入口(它负责"关动画时连曲线一起换");
* ③ 不自己写 `duration`(弹簧曲线下那是**静默无效**的写法)。
* ③ 单独可变异:把 `Motion.anim(...)` 换回 `{ duration: 220, curve: ... }` ⇒ 红。
*/
check(
'鸿蒙|morph 走**官方弹簧曲线**且经过 `Motion.anim` 统一入口',
/ui\.animateTo\(Motion\.anim\(\s*Theme\.spring/.test(MOTION),
'`Motion.morph` 不再用官方弹簧曲线(或没走 `Motion.anim`)—— '
);
check(
'鸿蒙|弹簧曲线下**不得**自己写 duration(SDK:它不生效)',
!/ui\.animateTo\(\{/.test(MOTION),
'`animateTo` 直接收字面对象 `{ duration: … }` —— 若配的是弹簧曲线,' +
'按 SDK 原文 duration **不生效**;若配的是 cubic-bezier,则丢掉系统预制动效。' +
'两种都不对 ⇒ 必须走 `Motion.anim(spring)`'
);
check(
'鸿蒙|`Motion.anim` 在"减弱动效"时同时换掉曲线(不是只把 duration 折 0)',
/*
* ★ 这里必须断言"换成了**不是** spring 参数的曲线"。
* 第一版写成 `…duration: 0,…curve:` —— 那个 `curve:` 后面接什么都行,
* 于是变异"把 curl: Curve.Linear 换成 curve: spring"**测不出来**
* (实测:变异后仍然 13/13 绿)。
* 判据自己对变异不敏感 = 它实际没在守卫那件事。
*
* 现在钉两件事同时成立:
* ① 减弱分支里存在 `duration: 0`;
* ② 同一分支的 `curve:` **不是** `spring`(即真的换掉了)。
*/
/static anim\(spring: ICurve\): AnimateParam/.test(MOTION) &&
/duration: 0,[\s\S]{0,80}?curve: (?!spring\b)/.test(MOTION),
'只把 duration 折 0 而保留弹簧曲线 ⇒ **动画照放**(弹簧曲线下 duration 无效)' +
'⇒ 静默破掉无障碍开关。必须连曲线一起换回可时长控制的那个'
);
check(
@ -215,33 +265,79 @@ check(
'静态方法里拿不到 this.getUIContext(),所以由调用方把 UIContext 传进来'
);
/* 每一个绑了 geometryTransition 的组件,它**同一文件**里必须有 Motion.morph 调用 */
const geomFiles = new Set();
for (const h of hs) {
if (/geometryTransition\(/.test(h.src)) geomFiles.add(h.name);
/*
* ★★ 2026-09-23 改:从「**同文件**共址」改为「按 morph **id** 找驱动」。
*
* 旧判据:`geomFiles ⊆ filesUsingHelper` —— 绑了 `geometryTransition` 的
* 文件自己必须也有 `Motion.morph` 调用。
*
* ★ 为什么它现在必须改(不是判据变宽,是它守的东西变了):
* 一个共享元素转场的 **in/out 两端天然在两处**——`compose-morph` 的 out 端
* 是列表里的加号(`MainPage.ets:1760`),in 端是全屏写信页。用户要求
* 「把组件按页面封装以便与 WebUI 一一对应」,于是 in 端随写信页搬进了
* `NavDestinations.ets`,而**驱动(唯一的 `Motion.morph`)合理地仍留在
* `MainPage.ets:1512`** —— 它就在 out 端旁边。
* ⇒ 旧判据报 `NavDestinations.ets`「没走统一入口」,而事实是它**根本不需要**
* 自己驱动:它只是终点。这是判据的代理失效,不是代码退化。
*
* ★ 新判据仍按原先的**安全目标**:每个 morph 都必须由 `Motion.morph` 驱动
* (不能谁自己写 `animateTo`,否则就漏掉「时长/曲线/reduced-motion 折 0」)。
* 改问的是:**每个 id 的所有绑定文件里,至少有一个含 `Motion.morph`**。
* 这样:
* · 驱动与它绑的那一端同文件 ✓(仍是原来的意图);
* · 另一端随组件搬家不会误报 ✓;
* · 若有人新绑一个 id 却**哪里都没驱动**,或把驱动换成裸 `animateTo`,仍会红 ✓
* —— 没有变宽:它照旧要求每个 id 有且只有一处统一入口的驱动。
*/
const idHasDriver = new Map(); // id -> 是否有任一绑定文件含 Motion.morph
for (const [id, files] of geomIds) {
idHasDriver.set(id, files.some(f => {
const h = hs.find(x => x.name === f);
return h !== undefined && /Motion\.morph\(/.test(h.src);
}));
}
const filesUsingHelper = new Set();
for (const h of hs) {
if (/Motion\.morph\(/.test(h.src)) filesUsingHelper.add(h.name);
}
const missing = [...geomFiles].filter(f => !filesUsingHelper.has(f));
const undrivenIds = [...idHasDriver.entries()].filter(([, ok]) => !ok).map(([id]) => id);
check(
'鸿蒙|每个用到 geometryTransition 的文件都走 Motion.morph(不是自己 animateTo)',
missing.length === 0,
'这些文件绑了共享元素转场却没用统一入口:' + missing.join(' ') +
'鸿蒙|每个共享元素转场 id 都有 `Motion.morph` 驱动(不是只绑不驱、或自己写 animateTo)',
undrivenIds.length === 0,
'这些 id 的两端都不在含 `Motion.morph` 的文件里 —— 没人用统一入口驱动它:' +
undrivenIds.join(' ') +
'(自己写 animateTo 就会漏掉"时长/曲线/reduced-motion 折 0"三件里的一件)'
);
/*
* ★★ 2026-09-21 改:从「页面里不再直接出现 `Theme.durMorph`」改为
* 钉**弹簧曲线令牌**不得下沉到页面。
*
* 原来那条守的是 `durMorph` —— 令牌已删(morph 改用 `springResponsive`,
* 弹簧曲线下 duration 不生效,保留那是误导)。
*
* ★ 我第一版改成了"所有曲线令牌都不得在页面出现" —— **过宽**,实测当场红:
* · `easeOutSoft` 在页面里是**正当**的:控件状态变色
* (`.animation({ duration: Motion.dur(Theme.durFast), curve: Theme.easeOutSoft })`)
* 本来就该按控件写,它没有"统一入口"也不需要弹簧(微交互要短、可控)。
* · `easeRise` 在 PageTransitionEnter/Exit 里也是正当的:那是**每页自己的**
* 路由转场声明,只能写在页面里。
*
* ⇒ 真正必须禁止的是**弹簧曲线**:它们让 `duration` 失效,必须走
* `Motion.effectAnim` / `Motion.anim`(那里统一处理"关动画时连曲线一起换")。
* 页面里直接写弹簧曲线 = 绕开了无障碍开关。
*/
check(
'鸿蒙|页面里不再直接出现 Theme.durMorph(它只属于 Motion.morph)',
!/Theme\.durMorph/.test(hs.map(h => h.src).join('\n')),
'`Theme.durMorph` 出现在页面里 = 又有人绕开 `Motion.morph` 自己写动画参数了'
'鸿蒙|页面里不得直接写**弹簧曲线**(必须走 Theme 的过渡方法或 Motion)',
!/Theme\.(springIn|springResponsive)\b/.test(hs.map(h => h.src).join('\n')),
'弹簧曲线出现在**页面**里 = 绕开了 `Motion.effectAnim`(它负责"关动画时连曲线一起换")\n' +
'⇒ 系统里开了"减弱动效"时动画照放 —— 静默破掉无障碍开关'
);
check(
'鸿蒙|Theme.durMorph 确实存在且 = 220(WebUI FLIP 的原值)',
/static readonly durMorph: number = 220/.test(themeSrc),
'durMorph 不见了或被改成别的数 —— 上面几条会因为"引了一个不存在的名字"而失去意义'
);
/*
* ★★ 2026-09-21 删除两条陈旧断言:
* · 「页面里不再直接出现 `Theme.durMorph`」
* · 「`Theme.durMorph` 确实存在且 = 220」
*
* 令牌**已被删除**(morph 改用 `responsiveSpringMotion`,弹簧曲线下
* duration 不生效,保留那是误导)⇒ 这两条现在守的是一个不存在的东西。
* 上面那条改为钉**曲线令牌**的"不得下沉到页面",与原来的意图同一件事。
*/
finish('动画盘点');

View File

@ -463,6 +463,72 @@ check('新组件未使用未映射色族', unmapped.length === 0, unmapped.join(
navFgLum !== null && navFgLum > 180,
navFgLum === null ? '缺 --nav-fg' : `--nav-fg 平均亮度 ${navFgLum}(应 >180)`
);
/*
* ★★ 2026-09-23 **堵上两条逾期欠债**(`docs/DEBTS.json` 的
* `nav-dark-route-b-unguarded` 与 `nav-blur-route-c-unguarded`)。
*
* ── 为什么现在堵 ──
* 这两条欠债的 `due` 写的是「**引入深色主题(或第一次给导航组件加 dark: 变体)时**
* 必须一并堵」。而深色主题 **2026-09-17 就落地了**(见上面那段 `.dark` 的注释 ——
* 那一次把判据从「.dark 里不许有 --nav-*」翻转成「必须有深色导航令牌」)。
* ⇒ 到期条件**在当时就成立了**,但那一次只修了令牌这条路,A/B/C 三条逃逸路
* 一直没堵 —— 欠债逾期到现在。
*
* ── 三条逃逸路的现状(判据逐条看)──
* · A `.dark .nav-rail { background-color: … }`(选择器作用域)—— **上一条已堵**:
* 它搜 `.dark{}` 块体,而 A 是**另一条选择器**,落在块体之外 ⇒ 逃掉。
* 等等 —— 那一条现在也堵不了 A。见下面 A 条新判据。
* · B `className="nav-item dark:bg-slate-900"`(Tailwind 的 dark: 变体)——
* 产出的不是 `.dark{--nav-*}`,**当前完全无判据**。
* · C `className="nav-item backdrop-blur-lg"`(元素级工具类模糊)——
* 不在 `html[data-bg='on'] .nav-rail{…}` 那条选择器下,**当前完全无判据**。
*
* ── 堵法为什么是「窄豁免」而不是一刀切(欠债里点名警告过)──
*
* 欠债原文:「堵法按**文件窄豁免**写,不许写成『导航目录不许出现 dark:』
* (`bg-chrome-600` plain 档徽标那个先例我踩过一次)」。
*
* 那个先例的形状是:一条"某目录不许出现 X"的宽断言,会把**合法的**
* 非导航用途一起判红 —— 判据一红,下一个人就会去放宽它,最后什么都守不住。
*
* ⇒ 这两条**只扫 `className` 里出现 `nav-rail`/`nav-item` 的那些字符串**。
* 换句话说:问的不是"这个文件里有没有 dark: 背景",而是
* **"挂在导航那个元素上的那个类串里有没有"** —— 与逃逸路的形状同构。
* 同一个文件里的别的元素(徽标、头像…)写什么都不影响。
*/
const navComponents = ['../src/components/Sidebar.tsx', '../src/components/NarrowNav.tsx']
.map((p) => read(p));
/** 把所有 className 字符串里的"导航类串"取出来(支持 "…" 与 `…` 两种写法) */
const navClassStrings = [];
for (const src of navComponents) {
for (const m of src.matchAll(/className=(?:"([^"]*)"|\{`([^`]*)`\})/g)) {
const cls = m[1] || m[2] || '';
if (/\bnav-(?:rail|item)\b/.test(cls)) navClassStrings.push(cls);
}
}
check(
'B 条已堵:挂在导航元素上的类串里不许出现 `dark:bg-*`(Tailwind 深色变体绕过 --nav-bg)',
navClassStrings.length > 0 && !navClassStrings.some((c) => /\bdark:bg-/.test(c)),
navClassStrings.length === 0
? '一个导航类串都没扫到 —— 判据的锚点失效了(组件改名要一起改本判据)'
: `有导航类串写了 dark:bg-* ⇒ 深色下导航会绕过 --nav-bg 令牌自己变色:` +
navClassStrings.filter((c) => /\bdark:bg-/.test(c)).join(' | ')
);
check(
'C 条已堵:挂在导航元素上的类串里不许出现 `backdrop-blur-*`(元素级模糊叠在壁纸模糊上)',
navClassStrings.length > 0 && !navClassStrings.some((c) => /\bbackdrop-blur/.test(c)),
navClassStrings.length === 0
? '一个导航类串都没扫到 —— 判据的锚点失效了'
: `有导航类串写了 backdrop-blur-* ⇒ 壁纸已经模糊过,导航再糊一次(更脏更掉帧):` +
navClassStrings.filter((c) => /\bbackdrop-blur/.test(c)).join(' | ')
);
check(
'A 条已堵:不许用 `.dark .nav-rail` / `.dark .nav-item` 这类**选择器**给导航单独换色',
!/\.dark\s+[^{]*\.nav-(?:rail|item)/.test(css),
'深色下导航的底色只有一个来源(`.dark` 块里的 `--nav-bg` 令牌);' +
'另开一条 `.dark .nav-rail{…}` 选择器会让它绕过令牌 —— 那正是「导航黑、正文白」那个老问题的形状'
);
check(
'导航自叠模糊之一已堵:壁纸模式下 .nav-rail 规则里不许写 backdrop-filter(元素级工具类那条路未覆盖)',
/\.app-backdrop \{[\s\S]*?filter: blur\(/.test(css) &&

View File

@ -152,6 +152,36 @@ const PAIRS = [
latestMailId: g.latest?.mail_id ?? null,
mailIds: (g.mails ?? []).map(m => m.mail_id),
unreadCount: g.unreadCount
})),
/*
* ★★ 2026-09-23 新增:`groupPermissions` 的双边投影。
*
* ── 为什么必须加这一条 ──
* 上面那段注释(原文)说 "`groupPermissions` 不是改名了……
* **已决策的授权记录在鸿蒙上完全看不到**",然后把
* `harmony-permission-history` 登记进 `gaps`。2026-09-21 鸿蒙补上了
* 这个函数,`gaps` 清空了 —— 但**从没有人把两边的 `groupPermissions`
* 真正比过一次**:`gaps: []` 只意味着"名字都在",
* 不意味着"行为一致"。
*
* 实际后果:我刚要对比时才发现,两边都在算 `path`,
* 而鸿蒙那边是**硬编码空串**(`g.path = ''`,理由写着"MailLike 没这个字段"),
* electron 是真取 `latest.session_workspace`。
* ⇒ 鸿蒙的授权栏**永远显示不出 `agent@path`**。
* 这不是"少一个导出",是**同一个函数两边算得不一样** ——
* 而现有判据的形状只能抓前者。
*
* ⇒ 投影里**保留 `path`**(不归一掉),并补上用例(见下面 cases)。
* 它现在是这条判据的守具:谁再把它写成空串,这里会红。
*/
groupPermissions: (v) => (v ?? []).map(g => ({
sessionId: g.sessionId ?? g.session_id,
alias: g.alias,
agentName: g.agentName,
path: g.path,
pending: (g.pending ?? []).map(m => m.mail_id),
settled: (g.settled ?? []).map(m => m.mail_id),
latestMailId: g.latest?.mail_id ?? null
}))
},
cases: [
@ -174,7 +204,28 @@ const PAIRS = [
['拆分:空列表', 'splitByPermission', [[]]],
['未决权限计数', 'countPendingPermissions', [[mkMail({ mail_type: 'permission_request' })]]],
['未决权限计数(已决策)', 'countPendingPermissions',
[[mkMail({ mail_type: 'permission_request', permission_result: 'allow' })]]]
[[mkMail({ mail_type: 'permission_request', permission_result: 'allow' })]]],
/*
* ── groupPermissions:两边逐字段算得一样吗 ──
*
* 用例特意覆盖 `path`(会话工作目录):
* 这是它与 WebUI 真分叉过的那个字段(鸿蒙曾硬编码空串)。
* 若只拿"有权限请求"的普通用例测,两组都不报 path 差异也看不出来。
*/
['权限分组:单会话待决', 'groupPermissions',
[[mkMail({ mail_id: 'p1', mail_type: 'permission_request' })]]],
['权限分组:待决 + 已决策同会话', 'groupPermissions',
[[mkMail({ mail_id: 'p1', mail_type: 'permission_request' }),
mkMail({ mail_id: 'p2', mail_type: 'permission_request', permission_result: 'allow' })]]],
['★ 权限分组:path 取会话的 workspace', 'groupPermissions',
[[mkMail({ mail_id: 'p1', mail_type: 'permission_request',
session_workspace: '/home/program/agentmail' })]]],
['★ 权限分组:workspace 缺失时 path 为空(不是 undefined)', 'groupPermissions',
[[mkMail({ mail_id: 'p1', mail_type: 'permission_request',
session_workspace: undefined })]]],
['权限分组:非权限邮件被过滤', 'groupPermissions',
[[mkMail({ mail_id: 'm1' }), mkMail({ mail_id: 'p1', mail_type: 'permission_request' })]]],
['权限分组:空列表', 'groupPermissions', [[]]]
]
},
{

View File

@ -936,8 +936,31 @@ test('遮罩:交给系统的遮罩语义色("随主题换向"这件事现在
* 由那个方法自己的使用点担保)。
*/
const themeSrc = code(themePath);
const declared = [...themeSrc.matchAll(/static readonly (\w+)\s*[:=]/g)].map(m => m[1]);
assert.ok(declared.length > 20, `要从 Theme.ets 里读到令牌清单(读到 ${declared.length} 个)`);
/*
* ★★ 2026-09-21 补:清单从"只有 `static readonly` 字段"扩到 **`static` 方法**。
*
* ── 怎么发现的 ──
* 我把两个账号选择器换成官方 `bindMenu`(系统自带转场)之后,
* `Theme.menuIn()` 就**没有任何调用点了** —— 它是个 `static` 方法,
* 而这份清单当时只匹配 `static readonly NAME`,于是它**从来不在这份清单里**,
* 这条判据一路绿着看它变成死代码。
*
* 讽刺的是本条判据的出身正是同一个形状:pi 2026-09-15 那一刀是为了
* "`navMaterial` 没人用了却没被抓"(一个**字段**)。今天同一个洞
* 长在**方法**上 —— 覆盖面按"声明种类"划,就必然漏掉另一种声明。
* ⇒ 清单改成"字段 + 方法",两者用同一套可达性规则。
*
* ★ 方法名不带 `()`:比对用的是 `Theme.<name>\b`,
* 而调用点写 `Theme.menuIn()` —— `\b` 在 `n` 与 `(` 之间成立,匹配得上。
*/
const fieldTokens = [...themeSrc.matchAll(/static readonly (\w+)\s*[:=]/g)].map(m => m[1]);
const methodTokens = [...themeSrc.matchAll(/^\s*static (\w+)\(/gm)].map(m => m[1]);
const declared = [...new Set([...fieldTokens, ...methodTokens])];
assert.ok(fieldTokens.length > 20,
`要从 Theme.ets 里读到令牌清单(读到 ${fieldTokens.length} 个)`);
assert.ok(methodTokens.length > 10,
`也要读到 static 方法清单(读到 ${methodTokens.length} 个)—— ` +
'少了它,`menuIn` 那种"方法型的死代码"就永远不在射程内');
const others = collectEts(etsRoot).filter(f => f !== themePath);
const othersSrc = others.map(f => ({ f: f.slice(etsRoot.length + 1), src: code(f) }));
/*

View File

@ -261,17 +261,46 @@ test('2in1 快捷键|只响应 KeyType.Down(Down/Up 都处理会让一次按
assert.match(body, /e\.type\s*!==\s*KeyType\.Down/, '要先挡掉非 Down 的按键事件');
});
test('2in1 快捷键|候选列表内联渲染,不用 bindPopup/bindMenu', () => {
test('2in1 快捷键|地址候选列表内联渲染,不用 bindPopup/bindMenu', () => {
/*
* 这两者各有自己的焦点体系 —— 用户的按键会先被它们吃掉,
* "↑↓ 切换候选"就落不到 `onToKey` 上(菜单收不到、输入框也收不到)。
*
* 内联渲染(条件挂载)能让焦点一直留在输入框里 —— 这是键盘可达的前提。
*
* 改坏会红:把候选改成 `bindPopup(...)`。
* ★★ 2026-09-21 改:原来这两条断言扫的是**整个文件**
* (`!/\.bindMenu\(/.test(COMPOSE)`)—— 那把**不属于候选列表**的菜单
* 也一并禁了。我在账号选择器上用了官方 `bindMenu`(那是正确的:
* 它是个"点开选一个"的菜单,没有 ↑↓ 候选导航),结果被判据误报。
*
* 两者性质完全不同:
* · 地址候选(`suggestOpen`):**要 ↑↓/Enter 导航** ⇒ 必须内联,
* 这是无障碍与键盘可达的硬要求;
* · 账号选择器(`showAccountPicker`):**没有键盘导航**,就是个下拉
* ⇒ 官方 `bindMenu` 正合适(还自带点外部关闭/边缘避让)。
*
* ⇒ 断言改为**只看候选那一段**(从 `suggestOpen` 条件挂载处取到下一段结束)。
* 这样它仍然拦得住"把候选改成 bindMenu"(真正要防的那件事)。
*/
assert.ok(!/\.bindPopup\(/.test(COMPOSE), '不得用 bindPopup(会吃掉按键)');
assert.ok(!/\.bindMenu\(/.test(COMPOSE), '不得用 bindMenu(会吃掉按键)');
const suggestAt = COMPOSE.indexOf('if (this.suggestOpen && this.suggestItems.length > 0)');
assert.ok(suggestAt > 0, '要能找到地址候选的挂载点(它必须内联渲染)');
/*
* ★ 窗口要**从候选的开头往前多取一段**,不能从 `if` 那个位置开始。
*
* 实测(变异验证时发现的):我第一版从 `suggestAt` 开始切,
* 而"把候选改成 `bindMenu`"最自然的写法是把 `.bindMenu(...)` 写在
* **那个 `if` 之前**(作为一个链式修饰符)—— 于是它落在窗口之外,
* 变异**测不出来**(实测确实绿)。
*
* 判据自己对变异不敏感 = 它没在守卫那件事。
* ⇒ 往前取 600 字符(够包住同一节点上的修饰符链),往后到该块结束。
*/
const from = Math.max(0, suggestAt - 600);
const tail = COMPOSE.slice(suggestAt);
const cut = tail.indexOf('Divider()');
const suggestBlock = COMPOSE.slice(from, suggestAt + (cut > 0 ? cut : 3000));
assert.ok(!/\.bindPopup\(/.test(suggestBlock), '地址候选不得用 bindPopup(会吃掉按键)');
assert.ok(!/\.bindMenu\(/.test(suggestBlock), '地址候选不得用 bindMenu(会吃掉按键)');
assert.match(COMPOSE, /if \(this\.suggestOpen && this\.suggestItems\.length > 0\)/, '候选要在布局里内联挂载');
});

View File

@ -621,8 +621,26 @@ test('★ 设备:管理页能从「我的」页打开,且列表真的渲染
if (a.clickable !== 'true') return false;
const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || '');
if (!m) return false;
const [, , y1, x2, y2] = m.map(Number);
return x2 <= scrW * 0.08 && y1 > scrH * 0.5 && (y2 - y1) > 80;
const [, x1, y1, x2, y2] = m.map(Number);
/*
* ★★ 2026-09-21 修:加宽高比约束(与 `harmony-appearance` 两处同一个隐患)。
*
* 侧栏下半部有**三个**可点方块,实测(3184×2232,density 2.875):
* 头像 40×40vp(115×115px)ratio 1.000
* 主题切换 36×28vp(104× 81px)ratio 1.284
* 退出登录 36×28vp(104× 81px)ratio 1.284
* `(y2-y1) > 80` **三者全中**,`.find()` 靠遍历顺序才取到头像。
*
* 一旦顺序变了就会点到**退出登录** ⇒ 设备被登出、账号被清空
* (`performLogout` → `clearAll()`,实测 `accounts_json` 变 `[]`)。
* 我这次调试期间设备真的被登出过一次。
*
* ⇒ 用宽高比把"正方形头像"与"扁矩形按钮"分开。阈值 1.15 落在
* 1.000 与 1.284 之间,两边都有余量。
*/
const w = x2 - x1;
const h = y2 - y1;
return x2 <= scrW * 0.08 && y1 > scrH * 0.5 && h > 80 && w / h <= 1.15;
});
if (!avatar) return t.skip('宽屏侧栏头像找不到 —— 无法进「我的」');
const c = D.boundsCenter(avatar.attributes.bounds);
@ -747,6 +765,87 @@ test('★ 设备:管理页能从「我的」页打开,且列表真的渲染
assert.ok(userRows.length > 0,
'★ 管理页要真的渲染出**用户行**(带角色/状态的那种)—— ' +
`只有标题而没有行,说明数据没渲染。实际读到:${texts.slice(0, 25).join(' | ')}`);
/*
* ★★ 2026-09-21 补:**把应用放回主界面再退出**。
*
* ── 真 bug(连着两轮套件都因此红)──
*
* 本条爬到「管理」子页(`AdminUsersPage`,push 在 `MainPage` 之上的路由)
* 之后**就地结束**,应用就停在那里。下一个跑的设备判据
* (`harmony-nav` 的「底栏真渲染了可点的导航项」)直接 `dumpLayout` 量现场
* ⇒ 量到的是**管理页**,那一页上根本没有底栏/侧栏 ⇒ 报
* 「宽屏左侧栏要渲染出 ≥1 个可点的导航项(实际 0)」
* 侧栏明明好好的。**那不是功能缺失,是我的判据留下的现场。**
*
* `backToMain` 的注释里早写着同一个根因("宽屏侧栏找不到 ⇒ 双双 skip"),
* 只是本条的属主当时没顺手收尾。
*
* ★ 判据有义务**清理自己造成的状态**,不能指望"下一条会自己归位" ——
* 那等于把顺序耦合藏进设备状态里(谁先跑谁后跑决定了绿红)。
* `harmony-nav` 那边我也补了自保(它现在会先 `backToMain`),
* 但两边都做才是对的:一条产生脏状态、一条消化脏状态,
* 任何一边缺失都只是"碰巧还能过"。
*/
await D.launchOurApp(hdc, { settle: 400, tries: 20 }).catch(() => {});
await D.backToMain(hdc).catch(() => {});
});
test('★ 两个表单型半模态必须**同形**(同类交互不能一个能叉、一个不能)', () => {
/*
* ★★ 2026-09-21 加:用户「修改密码也应该做成弹窗吧」之后,
* `SettingsPage` 里出现了**两个同类**的表单型半模态:
* · 新增账号(`AddAccountSheet`)
* · 修改密码(`PasswordSheet`)
*
* 第一版我给新增账号写了 `showClose: false`(理由是"内容里已经有「取消」按钮"),
* 给修改密码写了 `true` —— 于是两个**同类**一个能叉掉、一个不能。
* 用户看到的是"为什么这个能关、那个不能"。
*
* ── 为什么这条值得单独钉 ──
*
* 这是用户这一路反复指出的那个形状:**同类交互必须是同一种形状**。
* (「修改密码也应该做成弹窗吧」的 `也应该` 本身就是这个意思 ——
* 不是"这个要弹窗",而是"两个表单别一个内联一个弹窗"。)
*
* 靠人记得"改一个要顺手改另一个"是不成立的 —— 这条判据把它变成**结构约束**:
* 两份 sheet 的选项必须逐字相同。以后谁只改其中一个,这里立刻红。
*
* ★ 为什么用「逐字对比两份选项块」而不是逐项 assert:
* 逐项 assert 只能钉我**今天想到的那几项**(height / showClose),
* 而"同形"的含义是**整块相同** —— 明天加个 `dragBar: false`
* 只加在一个上,逐项 assert 照样绿。对比整块才真的守住"同形"。
*/
const src = code(SETTINGS_PAGE);
const grabOpts = (marker) => {
const at = src.indexOf(marker);
if (at < 0) return null;
/* 取 `.bindSheet(...)` 的**最后一个参数**(选项对象):从 marker 起配对 `{}` */
const open = src.indexOf('{', src.indexOf(',', at + marker.length));
if (open < 0) return null;
let depth = 0;
for (let i = open; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') { depth--; if (depth === 0) return src.slice(open, i + 1); }
}
return null;
};
const add = grabOpts('.bindSheet($$this.showAddDialog');
const pw = grabOpts('.bindSheet($$this.showPasswordSheet');
assert.ok(add, '要能找到「新增账号」半模态(`.bindSheet($$this.showAddDialog`)');
assert.ok(pw, '要能找到「修改密码」半模态(`.bindSheet($$this.showPasswordSheet`)');
/* 选项里的值各自不同(回调名),所以只对比**键**与**非回调字面量** */
const normalize = (o) => o
.replace(/\/\*[\s\S]*?\*\//g, '') // 去注释(两份注释必然不同)
.replace(/onDisappear\s*:\s*\(\)\s*=>\s*\{[^}]*\}/g, 'onDisappear:<cb>')
.replace(/\s+/g, ' ')
.trim();
assert.equal(normalize(pw), normalize(add),
'★ 两个表单型半模态的选项必须**整块相同** —— 同类交互同一种形状。\n' +
` 新增账号:${normalize(add)}\n` +
` 修改密码:${normalize(pw)}\n` +
' 只改其中一个会让两个同类面板长得不一样(用户看到的"割裂"就是这么来的)。');
});
test('★ 详情页的两个动作球必须**分开摆**(Stack 的同一个角 + 各自 margin = 重叠)', () => {
@ -882,17 +981,22 @@ test('★ 底部弹层必须有明确高度(否则键盘一弹,按钮全被
'★ 不要显式设成 `OFFSET`(那就是"整页上移",正是要避免的那个模式)');
/*
* 逐个**仍然存在的**覆盖式弹层:从 `borderRadius({ topLeft` 往回找高度声明。
* 逐个**仍然自绘的**覆盖式弹层:从 `borderRadius({ topLeft` 往回找高度声明。
*
* ★ 2026-09-20:回复/转发已改成内联底栏(不再是弹层),这道检查现在只剩
* **对话树弹层**一个对象。它仍然该有明确高度 —— 理由没变:
* 尺寸由内容决定的覆盖层,键盘弹起时内容会被挤出可视区。
* (内联底栏那半改用上面的 `KeyboardAvoidMode.RESIZE` 承担。)
* ★ 2026-09-20:回复/转发已改成内联底栏(不再是弹层)。
* ★★ 2026-09-21:**对话树弹层已换成官方 `bindSheet`**(用户:「现在最割裂的
* 就是弹出效果」)⇒ 自绘的覆盖式弹层现在是 **0 个**。
*
* 「明确高度」这条要求**依然成立、且被满足得更好**:
* · 旧自绘版:`height('60%')` —— 拍脑袋的百分比,内容多时会被挤出可视区;
* · 官方 `bindSheet`:高度档位由系统定义(`SheetSize.LARGE`),
* 并有 `keyboardAvoidMode` 专门做键盘避让。
*
* ⇒ 所以这里不再要求"至少 1 个自绘弹层"(那会把回退到自绘当成优点),
* 改为:**若还有自绘弹层,它必须有明确高度**;同时断言换过去的那一个
* 确实还在官方容器里(否则就是"既没自绘也没官方"= 硬弹且无避让)。
*/
const sheets = [...src.matchAll(/\.borderRadius\(\{\s*topLeft:\s*\d+/g)];
assert.ok(sheets.length >= 1,
`详情页应还有覆盖式弹层(对话树)—— 实际找到 ${sheets.length} 个。\n` +
' 若回复/转发已全变内联底栏,至少对话树那一个还在;一个都没有说明结构被改坏了。');
const noHeight = [];
for (const m of sheets) {
const before = src.slice(Math.max(0, m.index - 1200), m.index);
@ -906,6 +1010,13 @@ test('★ 底部弹层必须有明确高度(否则键盘一弹,按钮全被
'内容一多就会被挤出可视区(旧版回复/转发弹层实测过:按钮点不到)。\n' +
`缺高度的弹层:\n ${noHeight.join('\n ')}`);
/* 换到官方容器的那一个必须真的在(不能"两边都不在") */
assert.match(src, /\.bindSheet\(\$\$this\.showThread/,
'★ 详情页的**覆盖式弹层**(对话树)必须二选一:\n' +
' · 官方 `bindSheet`(推荐:自带拖拽条/下滑关闭/遮罩/键盘避让),或\n' +
' · 自绘 `borderRadius({topLeft})` + 明确高度 + `transition`。\n' +
' 两者都没有 = 旧弹层被删了却什么都没换上去。');
/*
* ② 底栏内部各元素用**内容尺寸/百分比**,不得再用 `layoutWeight(1)` 抢空间。
*

View File

@ -519,10 +519,37 @@ test('★ 背景画出来了还不够:每个页面要**让出**页面底,否
}
// 开关必须由**背景计划**驱动(不是写死的 true/false)
assert.match(main, /this\.bgActive = this\.bgPlan\.kind !== 'none';/, 'bgActive 要由背景计划决定');
// 每个组件都要声明这个 @Prop(漏一个就编译不过,但判据先钉住意图)
for (const comp of ['CommPage', 'ContactsTab', 'InboxTab', 'SentTab', 'PermissionTab']) {
const at = main.indexOf(`struct ${comp} {`);
const head = main.slice(at, at + 400);
/*
* 每个组件都要声明这个 @Prop(漏一个就编译不过,但判据先钉住意图)。
*
* ★★ 2026-09-23 修:`PermissionTab` **已从 `MainPage.ets` 抽成独立组件**
* (`pages/PermissionTab.ets`)。原来这份名单一律在 `main` 里找
* `struct X {` —— 抽出后 `PermissionTab` 不在 `main` 里了,
* `indexOf` 返回 -1 ⇒ `main.slice(-1, 399)` 得到**空串** ⇒ 断言红。
*
* ★ 值得记的是这个红**是好红**:它立刻指出了"有个组件搬家了"。
* 而 `harmony-logic` 那条同形状的判据当时**静默变成了空断言**
* (它用的是 `slice(indexOf(...))`,-1 会变成"从末尾取一个字符")
* —— 两者差别只在于 `slice` 的参数形状。**同一类搬家,一个红一个哑。**
* ⇒ 这次把"组件在哪个文件"显式写进名单,而不是靠"它恰好在 main 里"。
*/
const PANE_SOURCES = {
CommPage: main, ContactsTab: main, InboxTab: main, SentTab: main,
/*
* ★ 已抽出,读它们自己的文件(搬家的地方只在这里写一次)。
*
* 2026-09-23:按用户「把组件按页面封装以便与 WebUI 一一对应」的要求,
* `PermissionTab` 与 `ContactsTab` 都从 `MainPage.ets` 抽成了独立文件。
* 这份名单必须跟着改 —— 否则判据报"找不到",而组件其实好好的。
*/
PermissionTab: read('pages/PermissionTab.ets'),
ContactsTab: read('pages/ContactsTab.ets')
};
for (const comp of Object.keys(PANE_SOURCES)) {
const src = PANE_SOURCES[comp];
const at = src.indexOf(`struct ${comp} {`);
assert.ok(at >= 0, `${comp} 要能在它该在的文件里找到(组件搬家要一起改本判据)`);
const head = src.slice(at, at + 400);
assert.match(head, /@Prop bgActive: boolean = false;/, `${comp} 要声明 @Prop bgActive`);
}
});
@ -800,10 +827,33 @@ test('★ 遮盖色方向:`mask_*` 两套主题下都是**深色**(模态遮
}
// 两个语义不许共用一个令牌(这个仓库撞过四次的那个模式)
assert.notEqual('wallpaperScrim', 'overlay');
// 模态弹层仍然用 mask(那里的语义确实是"压暗背后")
const settings = read('pages/SettingsPage.ets');
assert.match(settings, /Theme\.overlay/, '自绘弹层的遮罩仍然用 mask(那里的语义是压暗背后)');
assert.ok(!/wallpaperScrim/.test(settings), '弹层不该用壁纸遮盖色(两个语义别混)');
/*
* ★★ 2026-09-21 改:不再要求 `SettingsPage` 里出现 `Theme.overlay`。
*
* 原来这两条是:
* assert.match(settings, /Theme\.overlay/, '自绘弹层的遮罩仍然用 mask')
* assert.ok(!/wallpaperScrim/.test(settings), '弹层不该用壁纸遮盖色')
*
* 它们守的**真实意图**是「两个语义别混」(`overlay`=模态遮罩 / `wallpaperScrim`=壁纸压暗)——
* 这个仓库为此撞过四次。而现在 SettingsPage **已经不自绘遮罩了**
* (新增账号与修改密码都换成了官方 `bindSheet`,遮罩由系统画)
* ⇒ `Theme.overlay` 在这份文件里**只应出现在说明它被删掉的注释里**。
*
* ★ 不能简单删掉这两条:那会丢掉"不许把壁纸遮盖色当弹层遮罩"这个约束。
* 改成:
* ① `wallpaperScrim` 仍然**绝不得**出现在 SettingsPage(语义不混);
* ② 弹层必须走官方容器(`bindSheet`)—— 系统自己会用遮罩,
* 不需要也不应该再铺一层 `overlay`(两层遮罩会叠成双倍变暗)。
*
* ★ 为什么"不许出现 overlay"是对的:若有人往 `bindSheet` 内容里
* 再铺一层 `Theme.overlay`,那就是**系统遮罩 + 自绘遮罩叠两层**,
* 背景会暗两倍。这条断言正是拦这个。
*/
const settings2 = read('pages/SettingsPage.ets');
assert.match(settings2, /\.bindSheet\(\$\$this\./, '弹层应走官方 bindSheet(系统自带遮罩/圆角/动画)');
assert.ok(!/wallpaperScrim/.test(settings2), '弹层不该用壁纸遮盖色(两个语义别混)');
assert.ok(!/\.backgroundColor\(Theme\.overlay\)/.test(settings2),
'已交给 `bindSheet` 的弹层**不得**再自铺一层 overlay —— 系统遮罩 + 自绘遮罩 = 双倍变暗');
});
/**
@ -994,14 +1044,74 @@ test('★ 设备:打开背景开关后,壁纸上真的出现了(不是只
const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || '');
if (!m) return false;
const [, x1, y1, x2, y2] = m.map(Number);
return x2 <= scrW * 0.08 && y1 > screenH * 0.5 && (x2 - x1) > 80 && (y2 - y1) > 80;
/*
* ★★ 2026-09-21 修(真隐患):加 `宽高比 ≈ 1` 这一条。
*
* ── 原来只要求「宽>80 且 高>80」,而侧栏下半部有**三个**可点方块 ──
* 设备实测(屏幕 3184×2232,density 2.875):
* #0 头像 [57,1791][172,1906] 115×115px = 40×40vp ratio 1.000
* #1 主题切换 [63,1918][167,1999] 104× 81px = 36×28vp ratio 1.284
* #2 退出登录 [63,2010][167,2091] 104× 81px = 36×28vp ratio 1.284
* **三个全都满足**「宽>80 且 高>80」。
*
* 今天它没出事,纯靠 `.find()` 返回**第一个**(walk 序恰好是头像)——
* 也就是说这条判据的正确性**不取决于形状判得准**,而取决于
* "渲染树的遍历顺序恰好把头像排在前面"。那种保证是会失效的:
* 谁调整一下侧栏底部簇的子元素顺序(把主题/退出提到前面),
* 这里就会**点中「退出登录」** ⇒ 设备被登出、账号列表被清空
* (`performLogout` → `AccountManager.clearAll()`,实测
* `accounts_json` 会变成 `[]`)。
*
* ★ 这不是理论风险:我这一次调试期间设备**真的被登出了**
* (09:21 `agentmail_accounts` 变成 `accounts_json=[]`),
* 排查了一圈才定位到"形状判据太宽 + 依赖遍历顺序"这个形状。
*
* ⇒ 加宽高比约束:头像 40×40(1.000),另两个 36×28(1.284)。
* 阈值 1.15 落在两者之间,且留了余量(不写 1.0 的精确等号 ——
* 那是拿"渲染出来的像素"当"源码里的 vp",两者不该硬等)。
*/
const w = x2 - x1;
const h = y2 - y1;
return x2 <= scrW * 0.08 && y1 > screenH * 0.5 &&
w > 80 && h > 80 && w / h <= 1.15;
});
assert.ok(avatar,
'宽屏侧栏底部要有可点的头像方块(「我的」的入口)—— 找不到它说明入口没了');
const ac = D.boundsCenter(avatar.attributes.bounds);
assert.ok(D.tap(hdc, ac.cx, ac.cy), '要能点到侧栏头像');
} else {
assert.ok(D.tapText(hdc, '我的'), '要能点到「我的」窗格(底栏第 4 项)');
/*
* 窄屏:点底栏第 4 项。
*
* ★★ 2026-09-21 修(真 bug):原来用的是 `D.tapText(hdc, '我的')`,
* 而 "我的" 这个文案在**同屏出现两次**:
* · 页面头部标题 `Text('我的')`(`SettingsPage` 的 AppHeader),
* 它的 bounds 是 **[99,201][783,255]**(实测)—— 在屏**上半部**;
* · 底部导航项的文字,**[819,2061][877,2096]**。
* `tapText` 的实现是「取第一个可点的;都不可点就取第一个」——
* 两者都 `clickable=false`(点击挂在祖先容器上)⇒ 它取到的是**页头标题**,
* 点在那儿什么都不会发生。
*
* 症状极具误导性:报"点完之后应真的在「我的」页",
* 看起来像页面切不过去,实际是**点错了地方**(而且点的就是当前页的标题)。
*
* ⇒ 按位置挑:只取下半个屏幕、且 x 在屏宽右侧(底栏第 4 项)。
* 与宽屏那一分支的"按形状找"同一套思路 —— 文案会重复,位置不会。
*/
const scrH = screenOf(root0).h;
const navItem = [...D.walk(root0)].find((n) => {
const a = n.attributes || {};
if ((a.text || '').trim() !== '我的') return false;
const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || '');
if (!m) return false;
const [, x1, y1, x2, y2] = m.map(Number);
return y1 > scrH * 0.75 && x1 > scrW * 0.6 && (x2 - x1) < 200 && (y2 - y1) < 200;
});
assert.ok(navItem,
'窄屏底栏第 4 项要有「我的」文字(按位置找,不按文案 —— 页头标题同名)');
const nc = D.boundsCenter(navItem.attributes.bounds);
/* 文字节点通常不可点(点击挂在祖先),但点它的中心会冒泡到那一项 */
assert.ok(D.tap(hdc, nc.cx, Math.round(nc.cy - 20)), '要能点到底栏的「我的」');
}
/*
* ★ 等页面真的切过去(**轮询**,不是睡固定时长)。
@ -1026,11 +1136,40 @@ test('★ 设备:打开背景开关后,壁纸上真的出现了(不是只
* (这正是"设备判据必须自己搭现场"的另一个理由。)
*/
let meRoot = null;
for (let i = 0; i < 20; i++) {
/*
* ★★ 2026-09-21 补:**先滑回顶部再轮询**(顺序很重要)。
*
* `SettingsPane` 在 `MainPage` 里是**常驻挂载**的 ⇒ 它的 `Scroll`
* 保留上一次的位置。上一次跑这套判据时滑到了底,这一次点进「我的」
* 直接就是滚到底的屏,而 `inMePane` 找的「用户名 / 连接密钥」在**上半部**
* ⇒ 报「点完之后应真的在「我的」页」,而实际**已经在了**。
* ("判据必须自己搭现场" —— 这条纪律本文件自己的注释里就写着。)
*/
const scrNav = screenOf(root0);
for (let i = 0; i < 3; i++) {
/* 回顶:手指从上往下拉(y 小→大) */
D.swipe(hdc, Math.round(scrNav.w / 2), Math.round(scrNav.h * 0.25),
Math.round(scrNav.w / 2), Math.round(scrNav.h * 0.75));
await new Promise((r) => setTimeout(r, 250));
} for (let i = 0; i < 20; i++) {
await new Promise((r) => setTimeout(r, 500));
const snap = D.dumpLayout(hdc);
if (inMePane(snap)) { landed = true; meRoot = snap; break; }
}
/*
* ★★ 2026-09-21 补:点进去之后**先回到顶部**再找「背景」。
*
* 上面那个 `inMePane` 找的是「用户名 / 连接密钥 / 主题由系统」——
* 而这三样都在页面**上半部**。而 `SettingsPane` 在 `MainPage` 里是
* **常驻挂载**的(宽屏/窄屏都不重建)⇒ 它的 `Scroll` **保留上一次的位置**。
*
* 后果:上一次跑这套判据时滑到底了,这一次点进「我的」就直接是滚到底的屏,
* `用户名` 不在可见节点里 ⇒ 报「点完之后应真的在「我的」页」。
* 而实际**已经在了** —— 这是"判据没自己搭现场"(本仓已有这条纪律,
* `harmony-appearance` 自己的注释里就写着)。
*
* ⇒ 点完之后一律先滑回顶部(用屏幕尺寸算坐标,不写死宽屏值)。
*/
assert.ok(landed && meRoot !== null,
'点完之后应真的**在「我的」页**(要能看到「用户名」这类只属于该页的字段)—— ' +
'只断言"点到了坐标"证明不了页面切过去了');
@ -1066,13 +1205,41 @@ test('★ 设备:打开背景开关后,壁纸上真的出现了(不是只
* 滑了但可视内容不变 = 这个 Scroll 是死的。
*/
if (!hasBg(meRoot)) {
/*
* ★★ 2026-09-21 修(真 bug,套件红 / 单独跑也红):**滑动坐标写死了宽屏的**。
*
* 原来这两行是:
* D.swipe(hdc, 1600, 900, 1600, 1500); // 回顶
* D.swipe(hdc, 1600, 1500, 1600, 900); // 下扫
*
* `x=1600` 是**宽屏(3184px)**下的坐标。而窄屏只有 **1008px** 宽
* ⇒ x=1600 在屏外,`uitest uiInput swipe` 不接受、什么都不做。
* 于是"回顶"没回、"下扫"没扫,`meRoot` 一直是那一屏
* ⇒ 实测报「滚过 6 屏仍没有背景那一节」,而**手动滑两下就能看到「背景」**
* (它就在「客户端连接密钥」上面)。
*
* 危害不止假红:这段注释本身写着
* 「滑了但可视内容不变 = 这个 Scroll 是死的」——
* 也就是说这条判据**本意要顺便验证 Scroll 能不能滚**,
* 而坐标写死后它连"滚"这个动作都没发生,
* 那个顺带的验证也从来没生效过(一直是"空变量")。
*
* ⇒ 从实测屏幕宽高算中心,不写死:宽屏/窄屏/折叠态都能跑。
* 横向取屏幕中心,纵向取上/下各 1/5 处(比贴边稳,不碰系统手势区)。
*/
const scr = screenOf(meRoot);
const cx = Math.round(scr.w / 2);
const topY = Math.round(scr.h * 0.25);
const botY = Math.round(scr.h * 0.75);
for (let i = 0; i < 5; i++) { // ① 回到顶部
D.swipe(hdc, 1600, 900, 1600, 1500);
/* 从下往上**不行** —— 回顶要"内容下移" ⇒ 手指从上往下拉(y 小→大) */
D.swipe(hdc, cx, topY, cx, botY);
await new Promise((r) => setTimeout(r, 300));
}
meRoot = D.dumpLayout(hdc);
for (let i = 0; i < 8 && !hasBg(meRoot); i++) { // ② 逐屏往下扫
D.swipe(hdc, 1600, 1500, 1600, 900);
/* 往下看 = 内容上移 ⇒ 手指从下往上推(y 大→小) */
D.swipe(hdc, cx, botY, cx, topY);
await new Promise((r) => setTimeout(r, 400));
meRoot = D.dumpLayout(hdc);
}
@ -1335,8 +1502,25 @@ test('★ 设备:窗格内容不得超出屏幕(「我的」页滑杆数值
if (a.clickable !== 'true') return false;
const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(a.bounds || '');
if (!m) return false;
const [, , y1, x2, y2] = m.map(Number);
return x2 <= scrW * 0.08 && y1 > scrH * 0.5 && (y2 - y1) > 80;
const [, x1, y1, x2, y2] = m.map(Number);
/*
* ★★ 2026-09-21 修:与文件里另一处「找头像」同一个隐患 —— 见 `:1020` 那段注释。
* 侧栏下半部三个可点方块(头像 40×40 / 主题 36×28 / 退出 36×28)**都**满足
* `(y2-y1) > 80`,`.find()` 取第一个才碰巧是头像。加宽高比约束把它变成
* **形状判得准**,而不是"依赖遍历顺序"。
*
* 这里尤其要紧:这一处找到之后点它进「我的」页,若点到「退出登录」,
* 设备会被登出并清空账号(`clearAll()`)—— 实测 `accounts_json` 变 `[]`。
*
* ⚠️ 注意这里必须解构出 **`x1`**(原来写的是 `[, , y1, x2, y2]`,
* 跳过了 `x1`)—— 我加宽高比时需要它算 `w`,漏了就会在**运行期**
* `ReferenceError: x1 is not defined`。它不是断言失败:整个文件
* **一条都跑不了**(套件把它记成 `broken=1`,而 broken 证明不了任何事)。
* 实测就是这么红的(`ran=34 checks=553 … broken=1`)。
*/
const w = x2 - x1;
const h = y2 - y1;
return x2 <= scrW * 0.08 && y1 > scrH * 0.5 && h > 80 && w / h <= 1.15;
});
if (!avatar) {
return t.skip('宽屏侧栏头像找不到(可能不在宽屏形态)—— 本次不跑');

View File

@ -566,7 +566,27 @@ test('★ 服务端「只以 omitempty 形式出现」的字段:客户端读
* 归一被删 ⇒ 前提不成立 ⇒ 豁免失效 ⇒ 这一行重新报警。
*/
...(normalizeCoversOmitemptyFields
? [/^MainPage\.ets: if \(m\.mail_type === 'permission_request' && m\.permission_result\.length/]
? [
/*
* ★★ 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)));

View File

@ -44,7 +44,16 @@ const ETS = join(ROOT, 'client', 'harmony', 'entry', 'src', 'main', 'ets');
*/
/*
* ★★ 2026-09-23 修:`ContactsTab` 已从 `MainPage.ets` 抽出
* (`pages/ContactsTab.ets`)。下面三个判据原来只在 `MAIN` 上找,
* 抽出后落在 `ContactsTab` 的代码全部失踪 ⇒ 静默变绿(判了空串)。
* 改成把两边并起来 —— 与 `harmony-logic`/`harmony-nav` 的 `PAGE_SOURCES`
* 同一个做法(显式名单,不扫整个目录,避免作用域过宽)。
*/
const MAIN = join(ETS, 'pages', 'MainPage.ets');
const CT = join(ETS, 'pages', 'ContactsTab.ets');
const ctAndMain = (src) => src; // 占位,下面直接用 code(MAIN)+code(CT)
const API = join(ETS, 'api', 'MailApi.ets');
const LOGIN = join(ETS, 'pages', 'LoginPage.ets');
const GROUP = join(ETS, 'model', 'MailGrouping.ts');
@ -79,7 +88,7 @@ test('★ 「写信 / 归档」两个动作在**两种视图**里都有(WebUI
* 换个视图就换套确认 UI 只会让人对「自己点了点什么」更没底)。
* 鸿蒙两视图都要有 —— 只加一边的话,切到另一边就像"这个功能没了"。
*/
const src = code(MAIN);
const src = code(MAIN) + '\n' + code(CT);
const writes = [...src.matchAll(/this\.CardAction\('写信'[\s\S]{0,80}?composeTo\(c\)/g)].length;
const archives = [...src.matchAll(/this\.CardAction\('归档'[\s\S]{0,80}?requestArchive\(c\)/g)].length;
assert.equal(writes, 2, `「写信」要在卡片视图与列表视图各一处,实际 ${writes} 处`);
@ -97,7 +106,7 @@ test('★ 归档要**先确认**,且两种视图共用同一个确认框(不
* 文案就会各自漂移,而漂移之后没人会同时看两边。
* 判据:确认框的 builder 只有一个定义,两个视图都调它。
*/
const src = code(MAIN);
const src = code(MAIN) + '\n' + code(CT);
const defs = [...src.matchAll(/@Builder\s+ArchiveConfirmCard\(/g)].length;
assert.equal(defs, 1, `ArchiveConfirmCard 只应有 1 个定义,实际 ${defs} 个`);
const calls = [...src.matchAll(/this\.ArchiveConfirmCard\(c\)/g)].length;
@ -122,7 +131,7 @@ test('★ 时间戳要按 WebUI 的口径格式化(不是把 ISO 串原样印
* —— 不许 `.slice()` 切字符串(那是拿 UTC 的月/日当本地时刻用,
* UTC+8 的 09-15 00:30 会显示成 09-14 16:30,而格子/表头全都正常)。
*/
const main = code(MAIN);
const main = code(MAIN) + '\n' + code(CT);
assert.doesNotMatch(main, /\+ c\.last_activity\b/,
'last_activity 不许原样拼接显示(会印出 ISO 串),要走 shortTimeOf');
assert.match(main, /shortTimeOf\(c\.last_activity\)/, '要用 shortTimeOf 格式化');

View File

@ -41,7 +41,33 @@ const ATT_TS = join(HARMONY_ETS, 'model/Attachment.ts');
const ATT = await import(pathToFileURL(ATT_TS).href);
const C = await import(pathToFileURL(COMM_TS).href);
const page = code(join(HARMONY_ETS, 'pages/MainPage.ets'));
/*
* ★★ 2026-09-23 修:把**从 MainPage 抽出去的组件文件**也并进来。
*
* 用户要求「把鸿蒙的组件按页面封装,以便与 WebUI 一一对应」,
* 于是 `PermissionTab` / `ContactsTab` / `NavDestinations` / `NavShared`
* 都从 `MainPage.ets` 抽成了独立文件。而本文件的 `pageCode` 只看
* `MainPage.ets` ⇒ 那些断言("卡片上要显示预算"、"切换按钮要走这条判据"…)
* 全部落空变红 —— 它们问的是**渲染层有没有这个调用**,
* 而那个渲染层已经搬家了。
*
* ⇒ 判据该问的是"这个应用里有没有",不是"它在哪个文件里"。
* 把抽出去的那几个一起读进来。
*
* ★ 有意**不**做成"扫描 pages/ 下所有文件":那会让断言变得过宽
* (任何文件里出现过一次就算数),而本仓反复在消的正是"作用域比声称的宽"。
* 这份名单是显式的 —— 组件再搬家就一起改,与 `harmony-appearance` 里
* 那份 `PANE_SOURCES` 同一个做法。
*/
const PAGE_SOURCES = [
'pages/MainPage.ets',
/* ★ 2026-09-23 抽出的组件(与 WebUI 的 ContactPanel / PermissionList 一一对应) */
'pages/ContactsTab.ets',
'pages/PermissionTab.ets',
'pages/NavDestinations.ets',
'pages/NavShared.ets'
];
const page = PAGE_SOURCES.map((f) => code(join(HARMONY_ETS, f))).join('\n');
/**
* 断言一律读**剥掉注释的源码**。
*
@ -663,12 +689,53 @@ test('发件箱与授权栏走的是与 WebUI 相同的接口(路径、字段
assert.ok(new RegExp(`payload\\.${field} =`).test(decide), `决策体要带 ${field}(后端按这三个字段解析)`);
}
// 备注必须真的送出:拒绝路径要传 noteText(WebUI 踩过"界面能填、其实没发出去"的坑)
const permTab = sendCode.slice(sendCode.indexOf('struct PermissionTab'));
assert.match(permTab, /this\.decide\(req, 'deny', this\.noteText\)/, '拒绝要把备注送出去');
assert.match(permTab, /this\.noteText = v;/, '备注框要真的收集输入');
/*
* ★★ 2026-09-23 修:`PermissionTab` **已从 `MainPage.ets` 抽成独立组件**
* (`pages/PermissionTab.ets`)。
*
* 原来这里写的是 `sendCode.slice(sendCode.indexOf('struct PermissionTab'))`
* —— 而 `sendCode` 是 `MainPage.ets` 的源码。抽出之后 `indexOf` 返回 -1,
* `slice(-1)` 是**末一个字符**,于是下面四条断言全部落在空串上 ⇒
* **整条判据静默失效**(不是红,是变成永远绿的空断言)。
*
* ★ 这正是本仓反复在消的形状:**判据盯着一个会移动的位置**。
* 组件一旦搬家,它不会红,只会不再检查任何东西。
* ⇒ 改成读**那个组件自己的文件**,并把"搬家"这件事也钉住
* (下面先断言它在新位置,否则给一句能看懂的失败)。
*/
const permTabSrc = code(join(HARMONY_ETS, 'pages/PermissionTab.ets'));
assert.ok(permTabSrc.includes('struct PermissionTab'),
'PermissionTab 应在 pages/PermissionTab.ets —— 它若再次搬家,本条要一起改');
/*
* ★★★ 2026-09-23 重大修:`navigator_only` 后,**决策不在授权栏了**。
*
* 用户裁定授权栏走 WebUI 架构(纯导航器):点一条 → 详情页决策。
* 于是原来断言在 `PermissionTab` 里的「拒绝要送备注 / 备注框收集输入 /
* 过期要说清后果」全部搬到了详情页的 `PermissionPanel`(新组件)。
*
* ⇒ 判据对象从 `PermissionTab` 换成 `PermissionPanel`。
* 断言**没变宽**:还是那三件必须如实做的事(备注送出、输入收集、过期说清)。
* 只是位置跟组件一起搬了 —— 这正是本文件上面那条注释反复在消的形状。
*/
const panelSrc = code(join(HARMONY_ETS, 'pages/PermissionPanel.ets'));
assert.ok(panelSrc.includes('struct PermissionPanel'),
'PermissionPanel 应在 pages/PermissionPanel.ets —— 决策面板搬到详情页了');
assert.match(panelSrc, /this\.submit\(decision, noteText\)|this\.submit\(label, this\.note\)/,
'决策要带着备注一起送出(拒绝往往要说明理由,理由要真的到 Agent 手里)');
assert.match(panelSrc, /this\.note = v;/, '备注框要真的收集输入');
// 过期必须当场说清:审批不会让那次调用继续
assert.match(permTab, /resp\.expired/, '过期分支要处理');
assert.match(permTab, /不会让那次调用继续/, '过期时必须说清后果(否则人会以为 Agent 接着跑了)');
assert.match(panelSrc, /resp\.expired/, '过期分支要处理');
assert.match(panelSrc, /staleWarning|已超过等待窗口/, '过期时必须说清后果(否则人会以为 Agent 接着跑了)');
/*
* ★ `navigator_only` 的正形状:授权栏**不再有**内联决策,只导航。
* 漏删一个按钮/备注框就是"一半改了"(用户裁定最恨的形状)。
*/
assert.ok(!/Button\('同意'\)/.test(permTabSrc),
'授权栏不许再有内联「同意」按钮 —— 决策在详情页(navigator_only)');
assert.ok(!/this\.decide\(/.test(permTabSrc),
'授权栏不许再有 decide() 调用 —— 那是内联决策时代的残留');
assert.match(permTabSrc, /this\.onOpenMail\(req\.mail_id, req\.source_account_id\)/,
'卡片要能把点按变成导航(onOpenMail 带 mail_id + 来源账号)—— navigator_only 的形状');
});
test('★ 判据自检:把状态机的默认页签改错必须判红', () => {

View File

@ -13,7 +13,7 @@ import { readdirSync } from 'node:fs';
import { code, prose, stripComments } from './lib/read.mjs';
import { findHdc, hasTarget, foregroundBundle, dumpLayout, walk,
noteBusySkip, noteRan, busyLimit, busyStreak,
noteBehavioralRan, behavioralRanAt, ourBundle } from './lib/harmony-device.mjs';
noteBehavioralRan, behavioralRanAt, ourBundle, backToMain } from './lib/harmony-device.mjs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { test } from 'node:test';
@ -283,7 +283,22 @@ test('④ 悬浮 + 让位:自绘浮动层(留白/圆角/系统材质),
* 让位加在各滚动容器的**内容末尾**(`contentEndOffset(this.navReserve)`)。
* 与 WebUI 同构:`.narrow-shell` 是 flex-col,列表满高、`.narrow-nav` 叠在上面。
*/
const contentOffsets = [...main.matchAll(/\.contentEndOffset\(this\.navReserve\)/g)];
/*
* ★★ 2026-09-23 修:数让位时要把**抽出去的组件文件**一起算。
*
* 用户要求「按页面封装组件以便与 WebUI 一一对应」,于是 `PermissionTab`
* 与 `ContactsTab` 搬到了自己的文件里。原来只在 `main`(= `MainPage.ets`)
* 上数 ⇒ 从 5 处落到 2 处而红。
*
* ★ 这条判据**问的是"各滚动容器有没有在末尾让位"**,不是"它们在哪个文件"。
* 所以把抽出去的文件并进搜索面 —— 与 `harmony-logic` 的 `PAGE_SOURCES`
* 同一个做法(显式名单,不做"扫 pages/ 全部"那种过宽的作用域)。
*/
const navSrc = [main,
code(join(HARMONY_ETS, 'pages/PermissionTab.ets')),
code(join(HARMONY_ETS, 'pages/ContactsTab.ets'))
].join('\n');
const contentOffsets = [...navSrc.matchAll(/\.contentEndOffset\(this\.navReserve\)/g)];
assert.ok(contentOffsets.length >= 5,
`各滚动容器都要在**内容末尾**让位(至少 5 处:收件箱/发件箱/授权/联系人×2/日历),实际 ${contentOffsets.length} 处`);
assert.ok(!/\.padding\(\{\s*bottom:\s*this\.isWide\s*\?\s*0\s*:\s*NAV_CONTENT_RESERVE\s*\}\)/.test(main),
@ -483,13 +498,14 @@ test('★ 窗格切换有真的过场动画(transition 挂在会换的那棵
* ⇒ `Motion.dur(Theme.durBase)` 这种"包一层来蒙混"**照样红**。
* (即:放宽的是**包装**,不是**令牌**。混起来的写法不会因为这次放宽而漏。)
*/
assert.match(body, /duration:\s*(?:Motion\.dur\()?Theme\.durRise/,
'★ `paneRiseIn` 里入场时长必须引用 `Theme.durRise`(= WebUI rise-in 的 150ms)。' +
'写成 `Theme.durBase` 是拿“壁纸淡入的时长”当“面板入场的时长” —— 正是 2026-09-18 修的那个 bug。' +
'(外面允许包一层 `Motion.dur(...)`:那是无障碍动效开关,包了也仍是在引用 `durRise`。)');
assert.match(body, /curve:\s*Theme\.easeRise/,
'★ `paneRiseIn` 里曲线必须引用 `Theme.easeRise`(= rise-in 那条 0.22,0.61,0.36,1)。' +
'写成 `Theme.easeOutSoft` 是**另一根**曲线(那是给 transition 用的)。');
assert.match(body, /Motion\.effectAnim\(\s*Theme\.spring/,
'★ `paneRiseIn` 里必须走 `Motion.effectAnim(Theme.springIn)`。\n' +
' —— 2026-09-21 改用**系统弹簧曲线**(用户:「APP 存在大量系统预制动效,为什么不用?」)。\n' +
' 原来的断言钉的是 `duration: Motion.dur(Theme.durRise)` + `Theme.easeRise`;\n' +
' 弹簧曲线下 **duration 按 SDK 原文不生效**(`AnimateParam.duration`),\n' +
' 继续钉"有 duration"等于在钉一个不再成立的前提。\n' +
' ★ 仍然保留原判据的**区分力**:走 `Motion.effectAnim`(而不是自己写 curve)\n' +
' 是硬要求 —— 它负责"关动画时连曲线一起换",自己写会静默破掉无障碍开关。');
assert.ok(!/Theme\.durBase/.test(body),
'★ paneRiseIn 里不得出现 `durBase`(180ms 是壁纸淡入的时长,不是面板入场)');
assert.ok(!/Theme\.easeOutSoft/.test(body),
@ -607,12 +623,42 @@ test('★ 出现/消失的那类元素真的挂了过渡(`if` 包的弹层不
* 名单显式列出,加了新的浮层就要一起改 —— 与 GLASS_REGISTRY 同一套纪律。
*/
const targets = [
{ file: 'pages/MainPage.ets', needle: 'if (this.showAccountPicker && this.accountList.length > 1) {', why: '账号选择器下拉' },
{ file: 'pages/SettingsPage.ets', needle: 'if (this.showAddDialog) {', why: '新增账号弹层' },
{ file: 'pages/AdminUsersPage.ets', needle: 'if (this.showCreate) {', why: '新建用户表单' },
{ file: 'pages/MailDetailPage.ets', needle: 'if (this.showReplyBox) {', why: '回复弹层' },
{ file: 'pages/MailDetailPage.ets', needle: 'if (this.showForwardBox) {', why: '转发弹层' }
{ file: 'pages/MailDetailPage.ets', needle: 'if (this.showForwardBox) {', why: '转发弹层' },
/*
* ★★ 2026-09-21 补:**两个地址候选下拉**。
*
* 它们一直是"条件挂载的浮层",却**从来不在上面这份名单里** ——
* 于是判据绿着看它们硬弹了很久。这正是 `Theme.menuIn()` 注释里
* 写着要覆盖"账号选择器、**下拉候选**"、而候选那一半从没兑现的原因:
* 没人把候选登记进这份名单,令牌自然也不会被挂上去。
*
* ── 怎么发现的 ──
* 不是靠人眼巡视,是"反查死代码"反出来的:两个账号选择器换成官方
* `bindMenu` 后 `menuIn()` 变成零调用,去问"要不要删掉这个死方法"时
* 拿它的注释对了一遍实际用法,才发现候选那一半是空的。
*
* ★ 这份名单是**枚举**("所有会被条件挂载的浮层")。枚举漏一项,
* 那一项就永久脱离守卫 —— 而且不会报任何错。
* WebUI 侧同一处挂着 `animate-menu-in`(`AddressInput.tsx:261`),
* 所以这不是"我们选择不做",是真实漏项。
*/
{ file: 'pages/ComposePage.ets',
needle: 'if (this.suggestOpen && this.suggestItems.length > 0) {',
why: '写信页地址候选下拉' },
{ file: 'pages/MailDetailPage.ets',
needle: 'if (this.fwdSuggestOpen && this.fwdSuggestItems.length > 0) {',
why: '转发弹层的地址候选下拉' }
];
/*
* ★★ 2026-09-21 改:`MainPage` 账号选择器与 `SettingsPage` 新增账号弹层
* 都已换成官方容器 —— 不再在 `if` 里自绘,所以从这份名单里移出,
* 改由下面 `migrated` 那份断言守"它们必须真的走官方容器"。
*
* ★ 不能直接删掉它们(那会留下空洞:改回自绘且忘挂过渡 ⇒ 全绿),
* 所以"换过去的那几个"单独有一份名单。
*/
const missing = [];
for (const t of targets) {
const src = code(join(HARMONY_ETS, t.file));
@ -638,6 +684,49 @@ test('★ 出现/消失的那类元素真的挂了过渡(`if` 包的弹层不
if (!/\.transition\(Theme\.(paneRiseIn|menuIn)\(\)\)/.test(window)) missing.push(t.why);
}
assert.deepEqual(missing, [], `这些位置会硬弹:${missing.join('、')}`);
/*
* ★★ 2026-09-21 新增:两个**换成官方容器**的弹层。
*
* 它们不再需要 `transition` —— 出入场由系统容器负责(那是系统的
* "半模态转场",比手写的 `paneRiseIn` 多了拖拽条、下滑关闭、
* 遮罩浓度、键盘避让)。但它们**仍然必须"有动画"**,
* 所以不能从名单里简单地删掉 —— 要换成断言"它走的是官方容器"。
*
* 否则会出现这个空洞:把 `bindSheet` 改回手写遮罩+固定高度、
* 且**忘了**挂 transition ⇒ 上面那个列表里已经没有它了 ⇒ 判据全绿。
*/
/*
* ★★ 2026-09-21 补:needle 从**字面串**改成**正则**。
*
* 原来写的是 `.bindMenu(this.showAccountPicker` —— 而我随后把两处菜单
* 换成了 **API 11 的 `bindMenu($$isShow, content)`**(显隐作为显式参数,
* `$$` 两向绑定让"点外部关闭"能自动回写状态)。
* `$` 一加,字面 needle 就不再匹配 ⇒ 判据报「它会硬弹」,
* 而它**明明走的是官方容器**。
*
* 这正是这类判据的典型老化方式:`needle` 钉的是**某个版本的写法**,
* 而它想守的是"这类东西走官方容器"。写法演进(API 7 → API 11)时,
* 它就从"守具"变成"绊脚石"。
*
* ⇒ 正则里把 `$$` 写成可选(`\$\$?`),两种重载都算数。
* 这样以后无论用哪一版绑定,判据都还在守**同一件事**。
*/
const migrated = [
{ file: 'pages/SettingsPage.ets', re: /\.bindSheet\(\$\$?this\.showAddDialog/, why: '新增账号弹层' },
{ file: 'pages/MailDetailPage.ets', re: /\.bindSheet\(\$\$?this\.showThread/, why: '对话树弹层' },
{ file: 'pages/MainPage.ets', re: /\.bindMenu\(\$\$?this\.showAccountPicker/, why: '账号选择器下拉' },
{ file: 'pages/ComposePage.ets', re: /\.bindMenu\(\$\$?this\.showAccountPicker/, why: '写信页账号选择' }
];
const lostAnim = [];
for (const t of migrated) {
const src = code(join(HARMONY_ETS, t.file));
if (!t.re.test(src)) lostAnim.push(t.why);
}
assert.deepEqual(lostAnim, [],
`这些弹层既不是官方容器也没有 transition,会硬弹:${lostAnim.join('、')}\n` +
' 两选一:要么用官方 `bindSheet`/`bindContentCover`(推荐,自带系统转场),' +
'要么回手写遮罩 + `.transition(Theme.paneRiseIn())`。两者都没有 = 硬弹。');
});
test('★ 顶部页签条与底部导航条同一族(都是悬浮玻璃)—— 一块实心白就是"割裂"', () => {
@ -954,17 +1043,32 @@ test('★ 滚动列表上下边缘渐隐:所有列表容器都挂 fadingEdge
'渐隐高度取 12vp:与 WebUI 的 `min(12px, 10%)` 同量级、也接近列表内边距(10vp)。' +
'改这个数要有理由(它直接决定"切得有交代"的观感强度)');
// 五个列表容器:收件箱 / 发件箱 / 授权 / 联系人卡片 / 联系人列表
const lists = main.match(/List\(\{[^)]*\}\)\s*\{/g) ?? [];
assert.ok(lists.length >= 5, `MainPage 里应当有 5 个列表容器,实际 ${lists.length} 个(判据前提要复核)`);
/*
* 五个列表容器:收件箱 / 发件箱 / 授权 / 联系人卡片 / 联系人列表。
*
* ★★ 2026-09-23 修:与上面那条让位计数同一个成因 —— `PermissionTab` 与
* `ContactsTab` 已从 `MainPage.ets` 抽成独立文件,这里只在 `main` 上数
* 就只剩 2 个。改用上面那份 `navSrc`(已并入抽出的文件)。
*/
/*
* 与上一条同一个搜索面:把从 `MainPage.ets` 抽出去的组件文件也并进来
* (`PermissionTab` / `ContactsTab` 已各自成文件)。
*/
const navSrc = [main,
code(join(HARMONY_ETS, 'pages/PermissionTab.ets')),
code(join(HARMONY_ETS, 'pages/ContactsTab.ets'))
].join('\n');
const lists = navSrc.match(/List\(\{[^)]*\}\)\s*\{/g) ?? [];
assert.ok(lists.length >= 5,
`应当有 5 个列表容器(收件箱/发件箱/授权/联系人卡片/联系人列表),实际 ${lists.length} 个(判据前提要复核)`);
const fades = main.match(/\.fadingEdge\(/g) ?? [];
const fades = navSrc.match(/\.fadingEdge\(/g) ?? [];
assert.equal(fades.length, lists.length,
`每个列表容器都要挂 fadingEdge(实际 ${fades.length} 个 / ${lists.length} 个列表)。` +
'漏掉的那个就是"上下还是硬截断" —— 用户 2026-09-17 原话。');
// 每个 fadingEdge 都要带上显式长度,且走 LengthMetrics.vp(不是裸数字)
const withLen = main.match(/\.fadingEdge\(true,\s*\{\s*fadingEdgeLength:\s*LengthMetrics\.vp\(LIST_FADE_LENGTH\)\s*\}\)/g) ?? [];
const withLen = navSrc.match(/\.fadingEdge\(true,\s*\{\s*fadingEdgeLength:\s*LengthMetrics\.vp\(LIST_FADE_LENGTH\)\s*\}\)/g) ?? [];
assert.equal(withLen.length, fades.length,
`每个 fadingEdge 都要写 \`{ fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) }\`。` +
'不写长度会落回默认 32vp —— 在 58px 高的小容器里会把内容洗白' +
@ -1277,6 +1381,35 @@ test('★ 行为(设备):底栏真渲染了可点的导航项(dumpLayout
`(连续第 ${streak}/${busyLimit()} 轮,超过就要变红)`);
}
noteRan(CRIT); // 真跑成了 ⇒ 清掉连续计数
/*
* ★★ 2026-09-21 补:**先把应用摆回主界面再量**。
*
* ── 真 bug(套件里红、单独跑绿,查了半天的那个)──
*
* 本条原来直接 `dumpLayout` 量当前界面。它假设"此刻在主界面",
* 但**这个假设不成立**:`harmony-admin` 的设备判据会点进「管理」子页
* (`AdminUsersPage`),而那是个 **push 在 MainPage 之上的路由** ——
* 底栏/侧栏在那页上根本不存在。它跑完**不返回**,
* 于是本条的 dump 量到的是**管理页**:
*
* 实测:`navItems.length === 0`,报
* 「宽屏左侧栏要渲染出 ≥1 个可点的导航项(实际 0)」
* —— 侧栏明明好好的,只是量错了页面。
*
* `launchOurApp` 治不了这个(它只把应用切到前台,不弹栈)——
* 这正是 `backToMain` 存在的理由,它的注释里记着同一个根因,
* 只是本条的属主当时没顺手用上。
*
* ★ 判据自己建立起始状态,别继承上一条留下的现场。
* (`cross-client-gesture:309` 早就这么写了 —— 它今天就是绿的。)
*/
const fgNow = foregroundBundle(hdc);
if (fgNow === ourBundle()) {
/* 只有我们在前台时才按返回键 —— 别去动别人会话的界面 */
assert.ok(await backToMain(hdc),
'要能把应用摆回主界面(侧栏/底栏可见)才算量得准;' +
'上一条判据可能把它留在了子页上(那是判据间干扰,不是功能缺失)');
}
const root = dumpLayout(hdc);
assert.ok(screenHeightOf(root) > 100,
`要能从 dumpLayout 里量出屏幕高度(实际 ${screenHeightOf(root)})—— 拿不到说明树是空的`);
@ -1606,3 +1739,97 @@ test('★ 登录/退出必须用同一个原语(`replaceUrl`)—— 只改
assert.ok(!/pushUrl\(\{[^}]*MainPage[^}]*\}/.test(ok),
'★ 自检失败:正确写法(replaceUrl)被误判为违规');
});
test('★ 判据自检:设备判据「按形状找元素」必须排除会**改变设备状态**的邻居', () => {
/*
* ★★ 2026-09-23 加(真隐患 —— 调试期间设备**真的被登出过一次**)。
*
* ── 形状 ──
*
* 三处设备判据都用同一套"按形状找宽屏侧栏头像"的判据:
* `harmony-admin.test.mjs`(宽屏支)、
* `harmony-appearance.test.mjs`(两处)
*
* 原条件只有 `clickable && x2 <= scrW*0.08 && y1 > scrH*0.5 && (y2-y1) > 80`。
* 而侧栏下半部有**三个**可点方块 —— 设备实测(3184×2232,density 2.875):
*
* 头像 115×115px = 40×40vp ratio 1.000
* 主题切换 104× 81px = 36×28vp ratio 1.284
* 退出登录 104× 81px = 36×28vp ratio 1.284
*
* `(y2-y1) > 80` **三者全中**。它今天没出事,纯靠 `.find()` 返回 walk 序的
* **第一个**(恰好是头像)—— 也就是这条判据的正确性**不取决于形状判得准**,
* 而取决于"渲染树遍历顺序恰好把头像排在前面"。
*
* 后果不是"判据红",是**判据造成破坏**:
* `performLogout` → `AccountManager.clearAll()` ⇒ 设备被登出、账号清空
* (实测 `accounts_json` 变成 `[]`,要手工重建 preferences 才恢复)。
*
* ── 为什么值得单独一条判据 ──
*
* 这族问题的形状是:**判据的作用域比它声称的宽**,而"多出来的那部分"
* 恰好是有破坏性的动作。靠人记得"找头像时要排除退出按钮"不成立 ——
* 这次三个人都没想到。所以把它变成**结构约束**。
*
* ★ 判法用**显式名单 + 语法边界**,不做"通用模式扫描":
* 我第一版写了个扫描器,用固定字符窗口(900)判断约束在不在,
* 而**注释把 `w / h` 推出了窗口** ⇒ 对刚修好的代码假红。
* 那正是我在 `harmony-2in1` 里刚踩过的同一个坑
* ("拍脑袋的固定窗口会随时间失效")。名单是显式的,与
* `GLASS_REGISTRY`/弹层名单同一套纪律(加到第四处就一起改)。
*/
const LOOKUPS = [
{ file: 'test/harmony-admin.test.mjs', why: '宽屏侧栏头像(进「我的」页)' },
{ file: 'test/harmony-appearance.test.mjs', why: '宽屏侧栏头像(外观页)' }
];
const bad = [];
for (const t of LOOKUPS) {
/*
* 取该文件里"按位置+尺寸挑可点元素"的那段条件,**按 return 语句划边界**
* (不是按字符数)。`code()` 剥注释,所以判的是代码本身。
*/
const src = code(join(HERE, '..', t.file));
/*
* 取"过滤回调"的**整体**:从含 `clickable !== 'true'` 的那一行起,
* 到该回调的收尾 `})` 为止。
*
* ★ 不能用 `([\s\S]*?)return [^;]*;`:那是**非贪婪**的,
* 会停在回调里第一个 `return`(`if (!m) return false;`)上 ——
* 于是真正的过滤条件(在最后那个 `return` 里)根本不在窗口内,
* 判据对刚修好的代码假红(我实测撞到了)。
* ⇒ 按**回调结束**划边界,不按 return 划。
*/
const hits = [];
const anchor = /clickable\s*!==\s*'true'/g;
let a;
while ((a = anchor.exec(src)) !== null) {
const end = src.indexOf('});', a.index);
if (end < 0) continue;
hits.push(src.slice(a.index, end));
anchor.lastIndex = end; // 别在同一段里重复命中
}
if (hits.length === 0) {
bad.push(`${t.file}: 找不到「${t.why}」的过滤条件(改结构要一起改本判据)`);
continue;
}
for (const body of hits) {
/*
* 必须同时有 ① 宽高比(把「退出登录」那个扁矩形排除掉的那一条)
* 和 ② 位置/尺寸约束仍在(说明没被整个换掉)。
*/
const hasRatio = /w\s*\/\s*h|width\s*\/\s*height|aspectRatio/.test(body);
const hasPos = /x2\s*<=|y1\s*>/.test(body);
if (!hasRatio) {
bad.push(`${t.file} 的「${t.why}」缺少**宽高比**约束 —— ` +
'侧栏底部「头像(40×40, ratio 1.000) / 主题切换(36×28, 1.284) / 退出登录(36×28, 1.284)」' +
'三个都满足「够大、在左侧下半部」,`.find()` 只靠遍历顺序才取到头像;' +
'顺序一变就会点到**退出登录** ⇒ `clearAll()` ⇒ 设备被登出、账号清空');
}
if (!hasPos) {
bad.push(`${t.file} 的「${t.why}」缺少位置约束 —— ` +
'那就不再是"侧栏头像"的判据了(会挑到页面上任意地方的大方块)');
}
}
}
assert.deepEqual(bad, [], `★ 按形状找元素的判据必须能排除破坏性邻居:\n ${bad.join('\n ')}`);
});

View File

@ -145,10 +145,20 @@
# ⇒ 这是同一形状的第 N 次:**"清单没跟上代码"不会自己报警**,
# 得靠 `hits=0` 那条判据;而它本次确实报出来了(`diag=mutant-anchor-stale`)。
# 2026-09-23 09:5x baseline 重算(第 11 次)—— 用户:「修改密码也应该做成弹窗吧」一批弹层迁移。
# ① 三个文件漂移,**两类成因已分别取证**(这一步不能省 —— 底本过期与变异残留在 sha256 眼里一样):
# · AdminUsersPage.ets、BackgroundPicker.ets —— `git diff --quiet HEAD` ⇒ **空**(与 HEAD 逐字节相同),
# 最近提交 `aa10427` / `9064ead` ⇒ **底本过期(stale)**,不是残留。
# · SettingsPage.ets —— `git diff --quiet HEAD` ⇒ **非空** ⇒ **我的有意编辑**,逐块核过 diff 只有重构、无半截/占位。
# ② 有意编辑的内容(用户:「修改密码也应该做成弹窗吧」):
# 修改密码 / 新增账号 → 官方 `bindSheet`(`FIT_CONTENT` + `showClose: true`,两者同形);
# 删掉自绘遮罩(该文件里 `Theme.overlay` 只剩注释引用);
# 候选列表补 `Theme.menuIn()`(WebUI `AddressInput.tsx:261` 同款,鸿蒙此前是硬弹)。
# ③ 复算后 `sha256sum -c baseline.sha` 应全 OK(11/11)。
4f3e0802346ba93740d7a6989fa6a9ef7dce16d1db59ea7402ff554127b07e3e client/harmony/entry/src/main/ets/model/AdminUsers.ts
c465b178ec1853ba66ac619e0d5614f48aef66db2ed2fecba25a4ae10e3dd13b client/harmony/entry/src/main/ets/model/ImagePrep.ts
d774fc494c21d4f68314b2a4953ba5a369744d75574c7f88f679497ff27a5ce0 client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets
73c66c7045f579c3eb8b8e0aa07803e7c9363b7ae8375972b251633d3ce969be client/harmony/entry/src/main/ets/common/BackgroundPicker.ets
399d3a67ac78cb02bd6fafe73c0aec27122a2b02154d30f9f94e7d20ce942a19 client/harmony/entry/src/main/ets/pages/SettingsPage.ets
54458e8010b10dc8b057fd399f0ad2b33149c5f051ee120580faa154a6ad0771 client/harmony/entry/src/main/ets/pages/AdminUsersPage.ets
a41ffefd6c4ba383075ccfcc8c80d8cb1fadb2cb03177601d81061a03f6c2b6e client/harmony/entry/src/main/ets/common/BackgroundPicker.ets
b64d09d2e29c10d2361e5eb9ade7fbf4d78631ca94db5d07d25cfd8d9685f566 client/harmony/entry/src/main/ets/pages/SettingsPage.ets
f3c7c3de22acaa94c8c3603fdd387547090807ee13e1e9fe35514d2028f5647e client/harmony/entry/src/main/ets/api/ApiClient.ets
6550e1892d1ebea82fb75a3d2cf8eaf826186199ff4a0ecf0430b1e3517d8681 client/harmony/entry/src/main/ets/api/AppearanceApi.ets

View File

@ -36,8 +36,8 @@
},
{
"file": "client/harmony/entry/src/main/ets/common/Theme.ets",
"pat": " return TransitionEffect\\.opacity\\(0\\)\\.combine\\(\\n TransitionEffect\\.translate\\(\\{ y: Theme\\.riseInOffset \\}\\)\\n \\)\\.animation\\(\\{ duration: Motion\\.dur\\(Theme\\.durRise\\), curve: Theme\\.easeRise \\}\\);",
"repl": " return TransitionEffect.asymmetric(TransitionEffect.opacity(0).combine(TransitionEffect.translate({ y: Theme.riseInOffset })).animation({ duration: Motion.dur(Theme.durRise), curve: Theme.easeRise }), 0, 0, 0, 0);",
"pat": " return TransitionEffect\\.opacity\\(0\\)\\.combine\\(\\n TransitionEffect\\.translate\\(\\{ y: Theme\\.riseInOffset \\}\\)\\n \\)\\.animation\\(Motion\\.effectAnim\\(Theme\\.springIn\\)\\);",
"repl": " return TransitionEffect.asymmetric(TransitionEffect.opacity(0).combine(TransitionEffect.translate({ y: Theme.riseInOffset })).animation(Motion.effectAnim(Theme.springIn)), 0, 0, 0, 0);",
"test": "nav",
"why": "★ paneRiseIn 改回 asymmetric(与 WebUI 的 both 不一致,判据该红)"
},

View File

@ -68,11 +68,11 @@ const SUITE = [
* 的接线判据 —— 用户「webui 行为是按钮变成对应的写邮件页面或输入框吧」。
* 详细理由见该文件里那段「这三条第一版写错了」的注释(两版错法都记了)。
*/
['test/animation-audit.test.mjs', [], 10], // 动画全量盘点:死动画/过宽作用域/弹层接线/reduced-motion
['test/animation-audit.test.mjs', [], 12], // 动画全量盘点:死动画/过宽作用域/弹层接线/reduced-motion/系统弹簧曲线
// 深色模式:色板反转 + 玻璃 alpha 档 + 底必须是暗的(2026-09-17 那次
// 「只有通信页深色正常」的回归锁 —— 38 条里后 8 条是这次新增)。
['test/theme.test.mjs', [], 38],
['test/background.test.mjs', [], 44],
['test/background.test.mjs', [], 47],
['test/cross-client-theme.test.mjs', [], 21],
// 左右滑动翻页的**语义契约**(两端逐项相同、数值各自定)—— 这是
// `docs/DEBTS.json` 的 `gesture-semantics` 那条债:它写着「鸿蒙侧出现滑动
@ -108,7 +108,7 @@ const SUITE = [
// P4 外观同步:跑 model/Appearance.ts(纯逻辑),所以也要 strip-types
['test/harmony-appearance.test.mjs', ['--experimental-strip-types', '--no-warnings'], 27],
// P5 悬浮玻璃导航:点击配对 / index 决定挂载 / 命中区 ≥44vp / 悬浮与让位
['test/harmony-nav.test.mjs', ['--experimental-strip-types', '--no-warnings'], 21],
['test/harmony-nav.test.mjs', ['--experimental-strip-types', '--no-warnings'], 22],
// 上下黑边(用户 2026-09-17/18 报过两次)——钉的是一整套东西的两半:
// `setWindowLayoutFullScreen(true)`(消黑边)+ `getWindowAvoidArea`(让开时钟/手势条)。
// 只做前半 ⇒ 页签被时钟盖住(上一次就是这样退回去的,黑边于是留了三天);
@ -140,7 +140,7 @@ const SUITE = [
['test/criteria-hygiene.test.mjs', [], 7],
// 用户管理页(P4c 同批):动作↔服务端调用同名 / 门禁只认严格 admin /
// 启停只发 status / 「受限」徽标口径 / 页面零写死色值 / 接线(纯逻辑真被调用)
['test/harmony-admin.test.mjs', ['--experimental-strip-types', '--no-warnings'], 30],
['test/harmony-admin.test.mjs', ['--experimental-strip-types', '--no-warnings'], 31],
// P4c 图片上传:阈值与两档策略 / 失败必带原因 / 退档判定只有一处 /
// release 都 await / 解码按目标尺寸 / multipart 字段名 / 上传后重新同步
['test/harmony-imageprep.test.mjs', ['--experimental-strip-types', '--no-warnings'], 31],