Files
MailUI4Agents/client/electron/test/manual/nav-restructure-verify.mjs
JianFeeeee e6048e9914 fix(webui): 候选菜单改用不透明弹层表面 —— 半透明+背景模糊把背后的表单糊进了列表
用户(2026-09-15,看着抄送/转发两处的候选):「你在选择框的模糊逻辑上用力过猛,
导致只能显示一条信息」。

实测(生产构建 4c2bf26,浏览器里读计算样式,壁纸开):
  菜单 background: rgba(255,255,255,0.5) + backdrop-filter: blur(8px) saturate(1.1)
  而它浮在**表单自己**之上(主题输入框 y=294 正落在菜单 284-510 下面)
⇒ 背后的输入框/标签被糊一遍再从半透明列表底下透上来,只有顶行还读得清。

根因与 2026-09-14 那次「模糊叠模糊」同族:把**控件档**(0.5 透 + 8px 模糊)用在
**弹层**上。控件背后是面板自身,透一点好看;弹层背后是别的内容,透就是叠两份内容。

- 新增 .popup-surface(rgb(var(--c-white)),不取 backdrop-filter;壁纸开时单独压回不透明,
  否则会撞上 html[data-bg='on'] .bg-white 那条接管规则)
- .popup-surface 并入「滚动区不淡」豁免(原来只认 .glass-control,换类会漏掉 → 弹层又被洗白)
- AddressInput 菜单 glass-control → popup-surface;旧断言 nav-merge.test.mjs:109 钉的正是
  那个错的意图(『地址建议菜单』必须用控件档),已改钉新意图

判据:narrow-layout 新增 4 条(表面不透明/不取模糊/壁纸开时也压回/菜单穿的类)+
nav-merge 反向对照。两侧都验:变异①换回 glass-control → 红;变异②改回 0.5 → 红;
复原 → 71 通过 0 失败。生产实测:rgba(...,0.5)+blur → rgb(255,255,255)+无模糊。
2026-09-15 07:46:54 +08:00

207 lines
9.5 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-14 用户三条要求)。
*
* 文件级判据(test/nav-merge.test.mjs)只能证明"代码里写了",证明不了"点得到、
* 看得见、量出来对"。这里在真浏览器里量:
*
* ① 导航项真的只剩 3 项(宽屏侧栏)/ 3+我的(窄屏底部)
* ② 内部页签能切换、切换后列表真的换内容
* ③ 悬浮加号命中区 >= 44px,且 elementFromPoint 命中的就是它(不是被别的层压住)
* ④ 管理员在「我的」页最下面真的看得到管理入口
* ⑤ 控件档透度**真的**比正文面低(反向对照:两类面的 alpha 必须分得开)
*
* 用法:node client/electron/test/manual/nav-restructure-verify.mjs
*/
import { openApp, tapTargets } from './narrow-probe-helper.mjs';
const WIDE = { width: 1280, height: 900 };
const PHONE = { width: 390, height: 844 };
const results = [];
function record(name, ok, note) {
results.push({ name, ok });
console.log(` ${ok ? '通过' : '失败'} ${name}${note ? ' — ' + note : ''}`);
}
// ── 宽屏 ─────────────────────────────────────────────────────────────
{
const { page, ctx } = await openApp(WIDE);
try {
await page.waitForSelector('.w-\\[60px\\] , .narrow-nav', { timeout: 25000 });
// 侧栏根是 div.w-[60px](不是 nav 元素)——之前用 'nav' 选择器什么也没等到。
// 标签取"按钮文字去掉徽标数字":带徽标的项最后一个 span 是徽标,
// 按 span:last-child 取会把「通信」「联系人」整条丢掉(判据自己的 bug,实测踩到)。
const navLabels = await page.$$eval('.w-\\[60px\\] button', els =>
els.map(e => e.textContent.replace(/[0-9]+\+?/g, '').trim()).filter(Boolean)
);
const commCount = navLabels.filter(t => t === '通信').length;
const stale = navLabels.filter(t => ['收件', '发件', '授权', '新建', '用户'].includes(t));
record(
'① 宽屏侧栏只剩 通信/日历/联系(+我的)',
commCount === 1 && stale.length === 0,
`标签=[${navLabels.join(',')}] 残留=[${stale.join(',')}]`
);
// ② 内部页签:切换后列表标题/内容变化
if (await page.locator('[data-testid="comm-tabs"]').count()) {
const tabs = await page.$$eval('[data-testid="comm-tabs"] button', els =>
els.map(e => e.textContent.trim())
);
record(
'① 通信内部有三个页签(收件箱/发件箱/授权)',
tabs.length === 3 && tabs[0].includes('收件箱') && tabs[2].includes('授权'),
`页签=[${tabs.join(' | ')}]`
);
const listText = async () =>
(await page.locator('[data-testid="comm-tabs"]').locator('xpath=..').innerText()).slice(0, 400);
const before = await listText();
await page.click('[data-testid="comm-tabs"] button:has-text("发件箱")');
await page.waitForTimeout(700);
const after = await listText();
record('② 切换页签后内容真的变了', before !== after, `${before.length} 字符 → ${after.length} 字符`);
await page.click('[data-testid="comm-tabs"] button:has-text("收件箱")');
await page.waitForTimeout(500);
} else {
record('① 通信内部有三个页签', false, '没找到 comm-tabs');
}
// ③ 悬浮加号
const fab = page.locator('[data-testid="compose-fab"]');
if (await fab.count()) {
const box = await fab.boundingBox();
const hit = await page.evaluate(() => {
const b = document.querySelector('[data-testid="compose-fab"]');
if (!b) return null;
const r = b.getBoundingClientRect();
const el = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
return { inside: b.contains(el) || b === el, r: { w: r.width, h: r.height } };
});
const round = box && Math.abs(box.width - box.height) < 2;
record(
'③ 悬浮加号是圆的、命中区 ≥44px、且没被压住',
!!box && box.width >= 44 && box.height >= 44 && round && hit?.inside === true,
`尺寸=${Math.round(box?.width)}×${Math.round(box?.height)} 命中自己=${hit?.inside} 圆=${round}`
);
} else {
record('③ 悬浮加号存在', false, '没找到 compose-fab');
}
// ⑤ 透度分档(把壁纸状态直接打开,量的是 CSS 本身,不动用户保存的设置)
await page.evaluate(() => {
document.documentElement.dataset.bg = 'on';
});
await page.waitForTimeout(300);
const alpha = sel =>
page.evaluate(s => {
const el = document.querySelector(s);
if (!el) return null;
const bg = getComputedStyle(el).backgroundColor;
const m = bg.match(/rgba?\(([^)]+)\)/);
if (!m) return null;
const parts = m[1].split(',').map(x => parseFloat(x));
return parts.length === 4 ? parts[3] : 1;
}, sel);
const card = await alpha('.bg-white');
// 地址建议菜单:先在写信页的收件人框里打字触发
await page.click('[data-testid="compose-fab"]');
await page.waitForTimeout(600);
const toInput = page.locator('input[placeholder*="收件人"], input[autocomplete="off"]').first();
if (await toInput.count()) {
await toInput.fill('gui');
await page.waitForTimeout(1200);
}
const menu = await alpha('.popup-surface');
record(
'⑤ 弹层(地址建议菜单)不透明、也不取背景模糊',
// 2026-09-15 用户:「你在选择框的模糊逻辑上用力过猛,导致只能显示一条信息」——
// 菜单浮在表单之上,半透明 + 背景模糊会把背后的输入框/标签糊进候选行里。
menu !== null && menu >= 0.99 && card !== null && menu > card,
`正文面 α=${card} 弹层 α=${menu}(弹层必须 ≥0.99)`
);
} finally {
await page.close();
await ctx.close();
}
}
// ── 窄屏 ─────────────────────────────────────────────────────────────
{
const { page, ctx } = await openApp(PHONE);
try {
await page.waitForSelector('nav', { timeout: 20000 });
const labels = await page.$$eval('.narrow-nav button', els =>
els.map(e => e.textContent.replace(/[0-9]+\+?/g, '').trim()).filter(Boolean)
);
const stale = labels.filter(t => ['收件', '发件', '授权', '新建', '管理'].includes(t));
record(
'① 窄屏底部导航 = 通信/日历/联系人/我的',
labels.length === 4 && labels.includes('通信') && labels.includes('我的') && stale.length === 0,
`标签=[${labels.join(',')}] 残留=[${stale.join(',')}]`
);
const fab = page.locator('[data-testid="compose-fab"]');
const fabInfo = (await fab.count())
? await page.evaluate(() => {
const b = document.querySelector('[data-testid="compose-fab"]');
const r = b.getBoundingClientRect();
const nav = document.querySelector('.narrow-nav')?.getBoundingClientRect();
const el = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
return {
w: r.width,
h: r.height,
bottom: r.bottom,
navTop: nav ? nav.top : null,
inside: b.contains(el) || b === el
};
})
: null;
record(
'③ 窄屏加号在底部导航之上、命中区达标、没被压住',
!!fabInfo &&
fabInfo.w >= 44 &&
fabInfo.h >= 44 &&
fabInfo.inside === true &&
(fabInfo.navTop === null || fabInfo.bottom <= fabInfo.navTop + 1),
fabInfo ? `${Math.round(fabInfo.w)}×${Math.round(fabInfo.h)} bottom=${Math.round(fabInfo.bottom)} navTop=${Math.round(fabInfo.navTop)}` : '没找到'
);
if (await fab.count()) {
await fab.click();
await page.waitForTimeout(900);
const composing = await page.evaluate(() =>
!!document.querySelector('textarea') && !document.querySelector('[data-testid="compose-fab"]')
);
record('② 点加号能进入写信(且加号自己让位)', composing, '');
}
// ④ 我的页最下面的管理入口(当前登录账号是否管理员决定可见性,两种都算通过)
await page.click('.narrow-nav button:has-text("我的")');
await page.waitForTimeout(900);
const adminInfo = await page.evaluate(() => {
const btns = [...document.querySelectorAll('button')];
const admin = btns.find(b => b.textContent.includes('用户管理'));
const logout = btns.find(b => b.textContent.includes('退出登录'));
if (!admin) return { found: false };
return {
found: true,
afterLogout: !!(logout && admin.getBoundingClientRect().top >= logout.getBoundingClientRect().top),
visible: admin.getBoundingClientRect().height > 0
};
});
record(
'④ 「我的」页底部有管理入口(且排在退出登录之后)',
adminInfo.found && adminInfo.afterLogout && adminInfo.visible,
adminInfo.found ? `在退出登录之后=${adminInfo.afterLogout}` : '当前账号非管理员(此判据不适用)'
);
} 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;