// 窄屏布局的结构性回归测试。 // // 不做视觉快照:那需要 headless 浏览器,且像素级比对在字体差异下极脆。 // 这里守住几条真正会坏掉的不变量。 import { readFileSync } from 'node:fs'; const read = p => readFileSync(new URL(p, import.meta.url), 'utf8'); let failed = 0; const check = (name, cond, detail = '') => { if (cond) { console.log(` 通过 ${name}`); } else { console.error(` 失败 ${name}${detail ? ' — ' + detail : ''}`); failed++; } }; 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) 固定宽度的中间栏在窄屏必须让位。 // 断言的是「w-full + md: 前缀的固定宽度」这个形态,不是某个具体像素值 —— // ContactPanel 的卡片视图用 400px,列表视图用 320px。 for (const f of ['MailList', 'ContactPanel']) { const src = read(`../src/components/${f}.tsx`); const narrowFullWidth = src.includes('w-full'); // 固定宽度只能出现在 md: 断点后面;裸 w-[NNNpx] 会在 375px 屏上挤掉详情。 // 只看 >=200px 的:min-w-[16px] 之类的徽标尺寸与布局无关 // (前置 (? { const px = Number(m.match(/\d+/)[0]); return px >= 200 && !src.includes('md:' + m); }); check( `${f} 中间栏窄屏全宽,固定宽度仅在 md: 之后`, narrowFullWidth && bareFixed.length === 0, bareFixed.length ? `裸固定宽度:${bareFixed.join(', ')}` : '缺少 w-full' ); } // 3) 详情页必须有返回出口,否则窄屏进去就出不来 const view = read('../src/components/MailView.tsx'); check('邮件详情有返回按钮', view.includes(' src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, ''); for (const f of ['BackButton', 'NavToggle', '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 手势条 const nav = read('../src/components/NarrowNav.tsx'); check('底部导航留了安全区内边距', nav.includes('safe-area-inset-bottom')); // 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="[^"]*(?