Files
MailUI4Agents/client/electron/test/animation-audit.test.mjs
JianFeeeee 2f80e1102d 跨端: 写信 FAB → 写信页 共享元素转场 + morph 收成单一入口(判据两版错法都记了)
用户 2026-09-21:「webui 行为是按钮变成对应的写邮件页面或输入框吧,你做的啥?」
「都做啊」—— 两处 morph 现在都在了。

## ① 写信 FAB → 写信页(跨 NavDestination)

官方 FAQ `faqs-arkui-991` 给的正是"在 NavDestination 子页面里做共享元素转场"
的完整步骤,逐步照做:

  · 两端绑同一 id `compose-morph`(FAB / ComposeDestination 的 NavDestination);
  · **`pushPath` 放进 `animateTo` 闭包**(FAQ 步骤 3 原文就是这个形状);
  · `follow: false`(两端互斥出现,不是"始终在树上跟随")。

## ② 把 morph 收成**单一入口** `Motion.morph(ui, mutate)`

这一步不是为了少写代码,是为了**让判据能判**。过程值得记:

**第一版判据** —— 全仓 `any()`:
    /animateTo\(/.test(allHarmony) && /durMorph/.test(allHarmony) && …
变异实测(把 morph 那处的 `animateTo` 改名、把 `Theme.durMorph` 就地写 `220`)
**三条全绿** —— 因为全仓**别处**还有这些名字,"删掉这一处"永远命中不了。

**第二版判据** —— 逐站点取"文本邻域"看有没有 animateTo:
**全假红**。因为 `geometryTransition(id)` 绑在**组件树**上,而 `animateTo`
写在**另一个方法**里,文本邻域取不到隔壁的方法。

⇒ 结论不是"把判据写得更聪明",而是**把结构改成可判的**:
把"带 morph 的状态切换"收进 `Motion.morph(ui, mutate)` 一处
(`animateTo` + 时长 + 曲线都在里面),调用点只剩「我要改哪个状态」。
这与本仓既有解法同型(`PressEffectModifier` / `GlassCardModifier`:
把"每处都得记得写"收敛成"一处定义、处处引用")。

3 个调用点已全部改走它(`MainPage.openComposeWithMorph`、
`MailDetailPage.openReplyWithMorph` / `closeReplyWithMorph`)。

★ `Motion.morph` 必须收 `UIContext`:全局 `animateTo` **已废弃**
  (编译器告警 `'animateTo' has been deprecated`),而静态方法里拿不到
  `this.getUIContext()`(本仓纪律:静态方法里不用 `this`)。

## 判据:6 条,三条变异逐个验过

    通过  每个 id 恰好绑两处(一 in 一 out)         [变异:删一端 → 红 ✓]
    通过  morph 只有一个入口且内部有 animateTo        [变异:换成普通调用 → 红 ✓]
    通过  用 ui.animateTo 而非废弃的全局 animateTo
    通过  每个用 geometryTransition 的文件都走 helper  [变异:自己写 animateTo → 红 ✓]
    通过  页面里不再直接出现 Theme.durMorph
    通过  Theme.durMorph 存在且 = 220                [变异:改成 450 → 红 ✓]

★ 期间还抓到一个**判据自己的 bug**:我重写那一段时把 `themeSrc` 的定义
  一起删了 ⇒ 第 6 条抛 `ReferenceError`、**整条判据根本没跑**
  (而其余 9 条照常打印"通过",退出码 1 但没人看得到那条)。
  这与"守具有齿但不在位"同形:**判据崩了不会显示成失败**。
  已补回定义并重跑确认。

计数棘轮 4 → 10(显式编辑,理由写在 `run-all.mjs` 里)。

## 设备验证

✓ 点 FAB → 写信页到场、取消 → 回列表,进程存活(17827),无新 jscrash
  (`faultlogger` 里最新仍是 15:08 那条,即修复前的)
✗ 220ms 的**中间帧**仍看不到(`snapshot_display` 往返 1.5-3s 慢一个数量级)——
  与上一条提交同样的诚实交代:动画本体只能由用户在真机上看
2026-09-21 15:50:00 +08:00

248 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 动画全量盘点 —— 把"整体动画"从"每次靠人眼看一遍"变成一条常驻判据。
*
* # 为什么需要它(2026-09-15 用户:「全面检查整体的动画」)
*
* 一次盘点查出两处**死动画**与一处**过宽的动画**,三种形态都极难靠肉眼发现:
* ① `@keyframes pane-in` 挂在 `html.view-switch .pane-enter` 上,而 **`.pane-enter`
* 没有任何组件在穿** —— 规则看着像"页面有入场动画",实际一次都不会播;
* ② `.animate-menu-in`(菜单入场)写好了、keyframes 也有,**同样没人穿**
* ⇒ 所有下拉/候选菜单其实都是"啪"地出现;
* ③ `html.view-switch .glass-control` 让**所有**控件档元素在每次切视图时一起动
* (几十个按钮/输入框同时淡入位移),是"闪"和卡顿的现成来源。
*
* 这三种都不是"动画不够好看",而是**接线断了**:类与使用者脱钩、作用域开得过大。
* 所以判据钉的是"接线",不是时长与曲线(那两样由人看着定)。
*
* # 判据
*
* 1. 每个 @keyframes 都必须有人穿 —— 从规则里取出穿它的类名,回源码里找;
* 找不到就是死动画(① 就是这么被抓住的)。
* 2. 弹层(.popup-surface 的菜单)必须带上菜单入场类(② 的接线)。
* 3. 不得再出现"整档控件一起动"的 view-switch 规则(③)。
* 4. 挂载即播那档(.rise-in)必须被 prefers-reduced-motion 显式关掉。
*/
import { readdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { check, finish } from './lib/checks.mjs';
import { code, PKG } from './lib/read.mjs';
// 判"规则/代码里有没有这个东西"一律走剥注释版(code):解释性注释里会原样引用被禁的写法,
// 读原文会把它当成"还在用"(criteria-hygiene 就是这么抓到本文件第一版裸用 readFileSync 的)。
/*
* 仓库根 —— 鸿蒙侧那几个路径要从仓库根算。
* 本文件原先只读 WebUI 的 CSS(相对 `PKG` 就够),所以没有 ROOT;
* 加鸿蒙那四条之后需要它。`PKG` 仍用于 WebUI(它自带正确基准)。
*/
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
const css = code('src/index.css');
/** 组件源码全文:判"这个类有没有人穿"必须看代码,不是看我们的记忆 */
function componentsSrc() {
const out = [];
const walk = dir => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = `${dir}/${e.name}`;
if (e.isDirectory()) walk(p);
else if (/\.tsx?$/.test(e.name) && !/\.test\./.test(e.name)) out.push(code(p));
}
};
/* ★ 基准用 PKG(= client/electron),**不是**相对 cwd:
* `readdirSync` 不经过 read.mjs,所以这里必须显式给基准。
* 原来传 'src' ⇒ 从仓库根直接跑这个文件时 `ENOENT: scandir 'src'`(pi 2026-09-17)。 */
walk(join(PKG, 'src'));
return out.join('\n');
}
const src = componentsSrc();
/** 取出所有 @keyframes 名 + 每条规则的 selector 文本 */
const keyframes = [...css.matchAll(/@keyframes\s+([a-zA-Z0-9_-]+)/g)].map(m => m[1]);
const rules = [...css.matchAll(/(^|\n)([^{}\n][^{}]*)\{([^{}]*)\}/g)].map(m => ({
sel: m[2].trim().replace(/\s+/g, ' '),
body: m[3]
}));
/**
* 一条 selector 在**最终**要不要靠某个类名才能命中?
* 只取"最后一个复合选择器"上的类(`html.view-switch .pane-enter` → `pane-enter`),
* 祖先里的类(html.view-switch、.app-shell)是状态开关,不算穿的人。
*/
function wearerClasses(sel) {
return sel
.split(',')
.map(part => part.trim().split(/\s+/).pop() || '')
.filter(last => last.startsWith('.'))
.map(last => last.split(/[\s.:[>]/)[0].replace(/^\./, ''))
.filter(Boolean);
}
const dead = [];
const unwrapped = [];
for (const k of keyframes) {
const users = rules.filter(r => new RegExp(`animation:\\s*${k}\\b`).test(r.body));
if (users.length === 0) {
dead.push(`${k}(没有任何规则用它)`);
continue;
}
// 这条 keyframes 的每一个使用者都必须"有人穿" —— 否则它还是不会播
const worn = users.some(r => {
const cls = wearerClasses(r.sel);
if (cls.length === 0) return true; // 元素选择器/通配:不算死
return cls.some(c => new RegExp(`["'\`\\s]${c}(?=["'\`\\s]|$)`).test(src) || src.includes(c));
});
if (!worn) unwrapped.push(`${k} ← ${users.map(u => u.sel).join(' | ')}`);
}
check(
'每个 @keyframes 都有人穿(没有死动画)',
dead.length === 0 && unwrapped.length === 0,
`只定义了没人穿的动画(看着有、永远不播):${[...dead, ...unwrapped].join(';')}`
);
check(
'弹层菜单带着菜单入场类',
/animate-menu-in/.test(rules.map(r => r.sel).join(',')) &&
/popup-surface[^`]*animate-menu-in|animate-menu-in[^`]*popup-surface/.test(src),
'菜单入场类存在但没人穿 —— 下拉/候选菜单是"啪"地出现(2026-09-15 盘点抓到的第二处死动画)'
);
// 只在**顶层规则**里查:reduced-motion 块里那句 `html.view-switch .glass-control` 是
// "把它关掉"的名单,不是"让它动"的规则 —— 第一版没区分,判据自己假红。
const topLevel = css.replace(/@media[^{]*\{(?:[^{}]|\{[^{}]*\})*\}/g, '');
check(
'没有"整档控件一起动"的切视图规则',
!/html\.view-switch\s+\.glass-control/.test(topLevel),
'view-switch 又把 .glass-control 整档带上了 —— 每次切视图几十个按钮/输入框一起动(闪与卡顿的现成来源)'
);
const reducedBlocks = css.match(/@media \(prefers-reduced-motion: reduce\)\s*\{[\s\S]*?\n\}/g) || [];
check(
'挂载即播那档被 reduced-motion 显式关掉',
reducedBlocks.some(b => /(^|\n)\s*\.rise-in\s*,/.test(b)),
'prefers-reduced-motion 覆盖不到 .rise-in —— 关掉动画的人照样会看到它'
);
/*
* ══════════════════════════════════════════════════════════════════════
* 鸿蒙侧:**共享元素转场**(`geometryTransition`)的接线
* ══════════════════════════════════════════════════════════════════════
*
* 用户 2026-09-21:「我记得 webui 行为是按钮变成对应的写邮件页面或输入框吧,
* 你做的啥?」—— WebUI `ComposePage.tsx:57-79` 的 FLIP 就是"球长成面板",
* 鸿蒙侧的对应能力是 `geometryTransition`。
*
* 它有三种**静默失效**的写法(都只会表现为"动画没播",不报错):
* ① 只绑一端 —— 没有 in/out 配对,系统无处可插值;
* ② 只绑 `geometryTransition` 却**没有 `animateTo`** ——
* 官方原文:「**必须配合 animateTo 使用**才有动画效果,
* 动效时长、曲线跟随 animateTo 中的配置,**不支持 animation 动画**」;
* ③ 同一个 id 绑了**三个以上**组件 —— 官方原文:
* 「同一个 id 只能有**两个**组件绑定…**不能多个组件绑定同一个 id**」。
*
* 判据就钉这三条。它们全是"接线"性质,与时长/曲线无关(那两样由人看着定)——
* 与本文件既有四条的分寸一致(`harmony-nav` 那份注释里写过同一条理由)。
*/
const HARMONY_PAGES = join(ROOT, 'client/harmony/entry/src/main/ets/pages');
function harmonySources() {
const out = [];
for (const e of readdirSync(HARMONY_PAGES, { withFileTypes: true })) {
if (e.isFile() && e.name.endsWith('.ets')) out.push({ name: e.name, src: code(join(HARMONY_PAGES, e.name)) });
}
return out;
}
const hs = harmonySources();
const allHarmony = hs.map(h => h.src).join('\n');
/* 每个 id(按文件统计,因为 id 是**字符串**,跨文件同名是合法但危险的) */
const geomIds = new Map(); // id -> [file, ...]
for (const h of hs) {
for (const m of h.src.matchAll(/geometryTransition\(\s*'([^']+)'/g)) {
const id = m[1];
if (!geomIds.has(id)) geomIds.set(id, []);
geomIds.get(id).push(h.name);
}
}
check(
'鸿蒙|共享元素转场的每个 id 恰好绑**两处**(一 in 一 out)',
[...geomIds.values()].every(v => v.length === 2),
'官方约束:「同一个 id 只能有两个组件绑定,且分别作为 in(新视图)和 out(旧视图)' +
'两种不同类型角色,不能多个组件绑定同一个 id」;实际:' +
[...geomIds.entries()].map(([k, v]) => `${k}→${v.length}处(${v.join(',')})`).join(' ')
);
/*
* ★★ 这三条**第一版写错了**,记在这里以免重蹈(变异实测发现的)。
*
* 第一版是全仓 `any()`:
* /animateTo\(/.test(allHarmony) /durMorph/.test(allHarmony) /easeRise/…
* 变异实测(把 morph 那处的 `animateTo` 改名、把 `Theme.durMorph` 就地写 `220`)
* ——**三条全绿**。因为全仓**别处**还有这些名字,所以"删掉这一处"永远命中不了。
*
* 第二版改成"逐站点看邻域",又**假红**:`geometryTransition(id)` 绑在
* **组件树**上,而 `animateTo` 写在**另一个方法**里 —— 文本邻域取不到隔壁的方法。
*
* ⇒ 结论不是"把判据写得更聪明",而是**把结构改成可判的**:
* 把"带 morph 的状态切换"收进 `Motion.morph(ui, mutate)` 一处
* (`animateTo` + 时长 + 曲线都在里面),调用点只剩"我要改哪个状态"。
* 这与本仓既有解法同型(`PressEffectModifier` / `GlassCardModifier`:
* 把"每处都得记得写"收敛成"一处定义、处处引用")。
*
* 现在判据钉的就是**这个结构**,逐条都能被变异打红:
*/
const MOTION = code(join(ROOT, 'client/harmony/entry/src/main/ets/common/Motion.ets'));
const themeSrc = code(join(ROOT, 'client/harmony/entry/src/main/ets/common/Theme.ets'));
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 / 时长令牌 / 曲线)—— ' +
'官方:「必须配合 animateTo 使用才有动画效果,时长与曲线跟随 animateTo 的配置」;' +
'三者必须在**同一处**,散开就必然有人漏'
);
check(
'鸿蒙|morph 用 ui.animateTo 而非已废弃的全局 animateTo',
!/(^|[^.\w])animateTo\(/.test(MOTION.replace(/ui\.animateTo\(/g, '')),
'全局 `animateTo` 已废弃(编译器告警 "has been deprecated")—— ' +
'静态方法里拿不到 this.getUIContext(),所以由调用方把 UIContext 传进来'
);
/* 每一个绑了 geometryTransition 的组件,它**同一文件**里必须有 Motion.morph 调用 */
const geomFiles = new Set();
for (const h of hs) {
if (/geometryTransition\(/.test(h.src)) geomFiles.add(h.name);
}
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));
check(
'鸿蒙|每个用到 geometryTransition 的文件都走 Motion.morph(不是自己 animateTo)',
missing.length === 0,
'这些文件绑了共享元素转场却没用统一入口:' + missing.join(' ') +
'(自己写 animateTo 就会漏掉"时长/曲线/reduced-motion 折 0"三件里的一件)'
);
check(
'鸿蒙|页面里不再直接出现 Theme.durMorph(它只属于 Motion.morph)',
!/Theme\.durMorph/.test(hs.map(h => h.src).join('\n')),
'`Theme.durMorph` 出现在页面里 = 又有人绕开 `Motion.morph` 自己写动画参数了'
);
check(
'鸿蒙|Theme.durMorph 确实存在且 = 220(WebUI FLIP 的原值)',
/static readonly durMorph: number = 220/.test(themeSrc),
'durMorph 不见了或被改成别的数 —— 上面几条会因为"引了一个不存在的名字"而失去意义'
);
finish('动画盘点');