/** * 窄屏悬浮玻璃导航 + 视图切换动画的真实验收。 * * 用户两条原话: * 「窄屏布局的导航栏还是固定底部延伸,没有玻璃效果,也没有悬浮」 * 「页面极度缺少动画,所有页面都是直接出现」 * * 判据只量**可观测的东西**:几何(留缝/圆角)、玻璃(半透明 + 模糊)、动画(真的在跑)。 * 每项都配反向对照,否则"没有动画"和"有动画但没触发"分不开。 * * 用法:AGENTMAIL_DIST= ADMIN_USER=.. ADMIN_PW=.. node test/manual/narrow-glass-verify.mjs */ import { openApp } from './narrow-probe-helper.mjs'; const PHONE = { width: 390, height: 844 }; const results = []; const record = (name, ok, note) => { results.push({ name, ok }); console.log(` ${ok ? '通过' : '失败'} ${name}${note ? ' — ' + note : ''}`); }; const { page, ctx } = await openApp(PHONE); try { await page.waitForSelector('.narrow-nav', { timeout: 25000 }); await page.waitForTimeout(600); // ── ① 悬浮:留缝 + 圆角 ── const geo = await page.evaluate(() => { const nav = document.querySelector('.narrow-nav'); const r = nav.getBoundingClientRect(); const cs = getComputedStyle(nav); const pr = getComputedStyle(document.querySelector('.narrow-shell') || nav.parentElement); return { left: r.left, right: r.right, bottom: r.bottom, vw: window.innerWidth, vh: window.innerHeight, radius: cs.borderRadius, marginBottom: cs.marginBottom, parentPadBottom: pr.paddingBottom, pointerAtGap: document.elementFromPoint(r.left - 4, r.top + 4)?.className?.toString().slice(0, 40) || '' }; }); record( '① 导航不再贴底通栏:左右留缝、离底留缝、有圆角', geo.left >= 6 && geo.vw - geo.right >= 6 && geo.vh - geo.bottom >= 6 && parseFloat(geo.radius) >= 8, `left=${Math.round(geo.left)} right缝=${Math.round(geo.vw - geo.right)} bottom缝=${Math.round(geo.vh - geo.bottom)} radius=${geo.radius}` ); record( '① 左边缝露出来的是页面底(说明它真的浮在上面)', !/narrow-nav/.test(geo.pointerAtGap), `缝隙处命中=${geo.pointerAtGap || '(页面底)'}` ); // ── ② 玻璃:半透明 + 模糊 ── const glass = await page.evaluate(() => { const cs = getComputedStyle(document.querySelector('.narrow-nav')); const bg = cs.backgroundColor.match(/rgba?\(([^)]+)\)/); const a = bg ? (bg[1].split(',').length === 4 ? parseFloat(bg[1].split(',')[3]) : 1) : null; const bf = cs.backdropFilter || cs.webkitBackdropFilter || 'none'; return { alpha: a, blur: bf }; }); record( '② 导航是玻璃:半透明 + 背景模糊', glass.alpha !== null && glass.alpha > 0.4 && glass.alpha < 0.98 && /blur\((\d+)/.test(glass.blur), `α=${glass.alpha} backdrop=${glass.blur}` ); // ── ③ 触摸目标仍然达标(悬浮不能把可点面积改小)── const taps = await page.$$eval('.narrow-nav button', els => els.map(e => { const r = e.getBoundingClientRect(); return { w: Math.round(r.width), h: Math.round(r.height), label: e.textContent.trim().slice(0, 4) }; }) ); const small = taps.filter(t => t.h < 44); record('③ 每项触摸高度 ≥44px', taps.length >= 4 && small.length === 0, `${taps.length} 项,最小 ${Math.min(...taps.map(t => t.h))}px`); // ── ④ 动画:切换页签时面板真的在跑 pane-in ── const animBefore = await page.evaluate( () => getComputedStyle(document.querySelector('.narrow-shell > *')).animationName ); await page.click('.narrow-nav button:has-text("日历")'); await page.waitForTimeout(60); const during = await page.evaluate(() => { const root = document.documentElement; const panes = [...document.querySelectorAll('.narrow-shell > *')]; return { hasClass: root.classList.contains('view-switch'), names: panes.map(p => getComputedStyle(p).animationName).filter(n => n !== 'none') }; }); record( '④ 切视图时面板在跑入场动画(反向对照:静止时不跑)', animBefore === 'none' && during.names.length > 0 && during.names.every(n => n === 'pane-in'), `静止 animationName=${animBefore};切换后 class=${during.hasClass} 动画=[${during.names.join(',')}]` ); // ── ⑤ 动画必须尊重 reduced-motion ── await page.emulateMedia({ reducedMotion: 'reduce' }); await page.click('.narrow-nav button:has-text("联系人")'); await page.waitForTimeout(60); const reduced = await page.evaluate(() => [...document.querySelectorAll('.narrow-shell > *')] .map(p => getComputedStyle(p).animationName) .filter(n => n !== 'none') ); record('⑤ 系统关了动画时一点都不动', reduced.length === 0, `动画=[${reduced.join(',')}]`); await page.emulateMedia({ reducedMotion: null }); } finally { await page.close(); await ctx.close(); } const failed = results.filter(r => !r.ok); console.log(`\n 窄屏悬浮玻璃 + 动画:${results.length - failed.length}/${results.length} 通过`); if (failed.length) console.log(' 失败:' + failed.map(f => f.name).join('; ')); process.exitCode = failed.length ? 1 : 0;