Files
MailUI4Agents/client/electron/test/narrow-layout.test.mjs
JianFeeeee ec90cba129 test(criteria): 抽出共享 check/finish(marker 不再靠记性)+ 失败信息自带修法
pi 的三条增量,前两条落地:

1. **错误信息自带修法**:受众不只是读过规范的人 —— 并发写 WebUI 的 agent 新加判据时不会打开
   CRITERIA.md,看到红的第一反应可能是"套件坏了"。所以把可照抄的修法写进那条错误本身
   (共享 helper 的用法 + 样板文件路径),并说明 node:test 的判据不用管。
   **red 是 ta 一定会看到的,文档不一定被打开。**

2. **marker 由共享 helper 打印**:新增 test/lib/checks.mjs(导出 check/finish),
   计数只可能在该模块内发生 → "漏打 marker"与"计数写错位置"这两类在新文件上不可能发生。
   为避免"写了没人用"(本仓踩过的坑),同时把两个手工计数的判据改用它:
   narrow-layout(原来只有 failed 计数)与 markdown-xss(原来根本没有计数器)——
   条数不变(52 / 9),套件仍全绿。

未回改其余 10 个文件:run-all 的 marker 检查已经覆盖它们。
2026-09-14 15:11:47 +08:00

231 lines
11 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.

// 窄屏布局的结构性回归测试。
//
// 不做视觉快照:那需要 headless 浏览器,且像素级比对在字体差异下极脆。
// 这里守住几条真正会坏掉的不变量。
import { readFileSync } from 'node:fs';
import { check, finish } from './lib/checks.mjs';
const read = p => readFileSync(new URL(p, import.meta.url), 'utf8');
console.log('窄屏布局回归:');
// 1) 覆盖式而非分栏NarrowStack 必须同时挂载 base 与 overlay
const stack = read('../src/components/NarrowStack.tsx');
check(
'覆盖层与底层同时在 DOM 里(底层不卸载,滚动位置与选中态才能保留)',
stack.includes('{base}') && stack.includes('{overlay}') && stack.includes('absolute inset-0')
);
check(
'关闭时延迟卸载,退出动画才有东西可播',
/setTimeout\(/.test(stack) && stack.includes('setMounted(false)')
);
check(
'入场用双层 rAF避免与挂载合帧导致 transition 不触发',
(stack.match(/requestAnimationFrame/g) || []).length >= 2
);
check(
'尊重 prefers-reduced-motion',
stack.includes('motion-reduce:transition-none')
);
// 2) 手机与竖屏平板统一用单栏;三栏只在 Tailwind lg1024px启用。
const narrowHook = read('../src/hooks/useIsNarrow.ts');
check('JS 单栏断点与 lg 一致', narrowHook.includes('(max-width: 1023px)'));
for (const f of ['MailList', 'PermissionList', 'ContactPanel', 'CalendarView']) {
const src = read(`../src/components/${f}.tsx`);
const narrowFullWidth = src.includes('w-full');
const bareFixed = (src.match(/(?<![-\w])w-\[(\d+)px\]/g) || []).filter(m => {
const px = Number(m.match(/\d+/)?.[0] || 0);
return px >= 200 && !src.includes('lg:' + m);
});
check(
`${f} 平板单栏全宽,固定宽度仅在 lg 之后`,
narrowFullWidth && bareFixed.length === 0 && !/md:w-\[/.test(src),
bareFixed.length ? `裸固定宽度:${bareFixed.join(', ')}` : '存在 md 固定栏或缺少 w-full'
);
}
// 3) 详情页必须有返回出口,否则窄屏进去就出不来
const view = read('../src/components/MailView.tsx');
check('邮件详情有返回按钮', view.includes('<BackButton'));
const compose = read('../src/components/ComposePage.tsx');
check('写信页有返回出口', compose.includes('cancelCompose') && compose.includes('NarrowOnly'));
// 4) 窄屏专属控件不能只靠 CSS 隐藏 —— 那样宽屏 Tab 会聚焦到看不见的按钮。
// 注释里提到 md:hidden 是在解释「为什么不用它」,所以先剥掉注释再查。
const stripComments = src =>
src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
for (const f of ['BackButton', 'NarrowOnly']) {
const src = read(`../src/components/${f}.tsx`);
const code = stripComments(src);
check(
`${f} 用 useIsNarrow 条件渲染而非 md:hidden`,
src.includes('useIsNarrow') && /\bnull\b/.test(code) && !code.includes('md:hidden')
);
}
// 5) 底部导航要避开 iPhone 手势条
//
// 注意查的位置2026-09-14 底部导航改成「悬浮玻璃条」后,贴底通栏的写法
// (组件里 padding-bottom换成了 index.css 里 .narrow-nav 的下外边距。
// 断言只看组件文件就恒为假 —— 这是一个**过期断言**,会让整份套件一直红着,
// 而红着的套件挡不住真回归。所以两处都认。
const nav = read('../src/components/NarrowNav.tsx');
check(
'底部导航留了安全区内边距(组件或 .narrow-nav 样式)',
nav.includes('safe-area-inset-bottom') ||
/\.narrow-nav[\s\S]{0,400}safe-area-inset-bottom/.test(read('../src/index.css'))
);
// 5.1) 抽屉式侧栏已删。
// 它装的六项与底部导航完全重复,唯一独有的是退出登录;代价是 z-50 的
// fixed 层铺满视口高度,把底部导航最左那一项盖住点不到
// (实测 elementFromPoint 命中抽屉里的 SVG
const app = read('../src/App.tsx');
check(
'窄屏没有抽屉式侧栏(它曾遮挡底部导航)',
!app.includes('navOpen') && !app.includes('bg-black/40')
);
const ui = read('../src/stores/uiStore.ts');
check('uiStore 不再有抽屉状态', !ui.includes('navOpen') && !ui.includes('toggleNav'));
// 5.2) 退出登录必须还有地方可点 —— 删抽屉时它是唯一的独有入口
const account = read('../src/components/AccountPage.tsx');
check(
'退出登录已移到账号页(窄屏唯一出口)',
account.includes('logout') && account.includes('退出登录')
);
// 5.3) 触摸命中区44x44 是移动端下限,而这些按钮视觉高度只有 15-24px。
// .tap 用居中的透明伪元素扩大命中区,视觉尺寸不变。
const css = read('../src/index.css');
check(
'.tap 提供 44px 触摸命中区且覆盖手机与竖屏平板',
/\.tap::after/.test(css) && css.includes('min-width: 44px') &&
css.includes('min-height: 44px') && /max-width:\s*1023px/.test(css)
);
// 详情页那排工具按钮是实测最小的一组(「抄送」只有 20x15
const viewSrc = read('../src/components/MailView.tsx');
for (const label of ['标记已读', '对话树', '转发']) {
const re = new RegExp('className="tap[^"]*"[^>]*>[\\s\\S]{0,120}' + label);
check(`详情页「${label}」有 .tap 命中区`, re.test(viewSrc));
}
// 5.4) 悬停才显形的次要动作在触摸设备上必须默认可见。
// `opacity-0 group-hover:opacity-100` 在没有 hover 的设备上永远透明,
// 却仍然接收点击 —— 一个看不见却按得动的「归档」比没有按钮更糟。
check(
'.reveal 只在支持悬停的设备上隐藏',
css.includes('.reveal') && /@media\s*\(hover:\s*hover\)\s*and\s*\(pointer:\s*fine\)/.test(css)
);
for (const f of ['ContactPanel', 'WorkCard']) {
const src = read(`../src/components/${f}.tsx`);
check(
`${f} 用 .reveal 而非裸 opacity-0 group-hover`,
src.includes('reveal') && !src.includes('opacity-0 group-hover:opacity-100')
);
}
// 5.5) 对话树:缩进随屏宽变,且窄屏要有返回出口。
// 固定「每级 20px、上限 8 级」在 320px 屏上把卡片压到 110px 可用宽度。
const thread = read('../src/components/ThreadView.tsx');
check('对话树缩进随屏宽自适应', thread.includes('useIsNarrow') && /narrow \? 10 : 20/.test(thread));
check('对话树窄屏有返回出口', thread.includes('<BackButton'));
// 5.6) 每个页面级组件都要有纵向滚动容器。
// 窄屏外壳是 `h-full flex flex-col overflow-hidden`,页面本身是
// `flex-1 min-w-0 flex flex-col` —— 内容超过视口时**没有任何办法滚到**
// 超出那段直接被裁。AccountPage 曾经就缺这个390px 下内容需 860px、
// 容器 795px「退出登录」按钮连同下面 65px 一起消失。
// 判据是「存在 overflow-y-auto」不是「当前正在滚动」——
// 内容暂时不够高时后者为假,但页面是健康的。
for (const f of ['AccountPage', 'AdminUsersPage', 'MailView', 'ComposePage', 'ThreadView', 'ContactPanel', 'MailList', 'PermissionList', 'CalendarView', 'CalendarEventEditor']) {
const src = read(`../src/components/${f}.tsx`);
check(`${f} 有纵向滚动容器`, src.includes('overflow-y-auto'));
}
// 5.7) 居中的单卡片页(登录 / 初始化)在矮屏必须能滚到底。
// `items-center` 在内容超高时让卡片上下同时溢出,而溢出到顶部那段
// 滚不到scrollTop 最小是 0—— 实测 568x280 下「登录」按钮完全在
// 视口外。改用卡片自己的 my-auto空间不足时 auto margin 退化为 0。
for (const f of ['LoginPage', 'SetupPage']) {
const src = read(`../src/components/${f}.tsx`);
check(
`${f} 矮屏可滚且不用 items-center 居中`,
src.includes('overflow-y-auto') && src.includes('my-auto') &&
!/h-full[^"]*items-center/.test(src)
);
}
// 5.8) 写信页不能靠 flex 把正文压成一行。
// 软键盘出现时可见高度骤减:顶部字段和底部附件/按钮都是固定内容,原先唯一
// 可收缩的正文区只有 min-h-0于是会被压到接近 0。窄屏改为整页可滚正文
// 保留明确的最小高度;宽屏仍使用 flex 填满剩余空间。
check(
'写信页窄屏整页可滚,正文有明确最小高度',
compose.includes('overflow-y-auto lg:overflow-hidden') &&
compose.includes('min-h-[12rem]') &&
compose.includes('lg:flex-1')
);
check(
'写信页附件与操作栏不参与正文压缩',
compose.includes('shrink-0 px-4 md:px-6 pb-20 lg:pb-3') &&
compose.includes('sticky bottom-0')
);
check('根视口使用 100dvh 跟随软键盘', css.includes('@supports (height: 100dvh)'));
// 6) 横向内边距在窄屏收窄px-6 在 375px 屏上白吃 48px
const wide = ['MailView', 'ComposePage', 'ThreadView', 'AccountPage', 'AdminUsersPage'];
for (const f of wide) {
const src = read(`../src/components/${f}.tsx`);
const bare = src.match(/className="[^"]*(?<![-:])\bpx-6\b/g) || [];
check(`${f} 没有裸 px-6应为 px-4 md:px-6`, bare.length === 0, `发现 ${bare.length}`);
}
// 7) 窄屏阅读:顶部只留标题、底部收起为悬浮球
//
// 用户2026-09-14「窄屏页面阅读邮件时顶部的邮件信息和底部的输入框等
// 占用了绝大部分页面,用户只能通过中间的一小块看邮件……上面缩为窄栏,只显示
// 邮件标题,点击展开显示完整信息,底部一个悬浮的聊天图标的球,点一下展开输入框」。
//
// 这两处坏掉的形态很隐蔽:组件照样渲染、宽屏一切正常,只是窄屏可读区被挤成一条缝。
// 所以在结构层钉住「窄屏默认收起 + 有展开出口 + 有收回归口」。
const mv = read('../src/components/MailView.tsx');
check(
'头部窄屏默认收起useState(!narrow) + 断点变化时重置)',
/useState\(!narrow\)/.test(mv) && /setOpen\(!narrow\)/.test(mv),
'缺少「窄屏默认收起」或跨断点重置 —— 自动隐藏只在首次生效'
);
check(
'头部收起态保留标题与状态点,展开才有完整信息',
mv.includes('aria-expanded={open}') &&
mv.includes('min-w-0 flex-1 truncate text-sm font-semibold') &&
mv.includes('{open && <div className="mt-1.5">{children}</div>}')
);
check(
'回复框窄屏收起为悬浮球reply-fab点开才展开',
mv.includes('data-testid="reply-fab"') &&
mv.includes('narrow && !open') &&
mv.includes('absolute bottom-4 right-4 z-20') &&
mv.includes('<ChatBubbleIcon')
);
check(
'回复框展开后有收回归口,且点开时焦点落到输入框',
mv.includes('aria-label="收起回复框"') && mv.includes('autoFocus={narrow}')
);
check(
'悬浮球与列表页新建球同一套观感w-14 h-14 rounded-full',
(mv.match(/w-14 h-14 rounded-full/g) || []).length >= 1
);
// 顶部标题行不能把返回键套进 toggle 按钮里button 嵌 button 是无效 HTML
// 且屏幕阅读器会读出两个可点区域)
check(
'返回键在 toggle 按钮之外(避免 button 嵌套)',
/\{lead\}\s*<button/.test(mv)
);
// 共享 helper 打汇总与 marker计数在 check() 内部,见 lib/checks.mjs 的说明)
finish('窄屏布局');