跨端: 2in1 键盘可达(Ctrl+N 写信 / ↑↓ 换补全 / Enter 选中)+ 三处判据被实测改判
用户 2026-09-21:「接下来做一下 2in1 上的快捷键,比如快捷键打开发信页面」
「上下键切换发信目标」「回车展开输入框等」
## ① 打开发信页:Ctrl+N,用官方 `keyboardShortcut`
先查了官方文档再动手,避开两条静默失败的坑:
· `keyboardShortcut`(组件快捷键事件,API 10+)「**无论组件是否获焦** ——
只要窗口获焦,快捷键就会响应」。而 `onKeyEvent` 要求**组件先获焦**,
邮件列表里焦点落在哪是不确定的(点一下就换)⇒ 用它做全局快捷键会时灵时不灵。
· 「多个不同组件设置相同组合键 ⇒ 只响应节点树**深度最浅**的那个」。
⇒ 再给别处的"写信"按钮补一个 Ctrl+N 不是"多一个入口",而是**让后来那处永久失效**。
判据因此钉"全仓只许绑一处"。
绑定位置在 `MainPage` 的**根 Stack**(窗口组件树的根)—— 绑在 FAB 上不行,
它在 `if` 分支里、窄屏/宽屏位置也不同,会随分支挂卸。
键位 `Ctrl+N` 避开了官方列出的五个**禁止绑定**组合(Alt+F4/Alt+Tab/Ctrl+Shift+Esc…)。
判据直接解析调用参数校验这三点。
## ② 根够不着 `openCompose()` ⇒ 照 `PushService` 的"两半"形状
`openCompose()` 住在**条件挂载**的 `CommPage` 上(`if (currentIndex === 0)`),
根组件拿不到它。新建 `common/ComposeIntent.ets`:
· **格子**(`pending`)—— 用户此刻在别的页、`CommPage` 还没实例化;
· **回调**(`listener`)—— 用户此刻就在通信页、页面早挂载完了。
缺任何一半都是**按键静默失效**:只有格子 ⇒ `aboutToAppear` 不重跑;
只有回调 ⇒ 没有监听者。这形状与 `api/PushService.ets` 处理"点通知跳转"时
踩的是同一个坑,那边注释里已写过解法 —— 这次是照着抄,不是重新踩。
## ③ 收件人三段式补全(↑↓ 切换、Enter/Tab 选中、Esc 收起)
WebUI 的逻辑原先**散在组件闭包里**(`parseParts` 没 export、`apply`/`onKeyDown`
直接改 React state)⇒ 判据根本 import 不到。先把它抽成一对纯逻辑:
`client/electron/src/lib/addressSuggest.ts` ↔ `model/AddressSuggest.ts`,
**抽的时候行为一字不改**(抽出来顺手"改进"会让判据比新行为、线上跑旧行为,两边都错)。
`AddressInput.tsx` 改接这份 lib。
`cross-client-logic` 新增 `AddressSuggest` 一对,22 个用例两边逐例比。
其中 `nextActiveIndex(0,3,-1)` 是**负下标陷阱**:JS 的 `%` 对负数返回负数
(`-1 % 5 === -1`),而负下标在数组访问里**不报错**(返回 `undefined`),
只表现为"按上键后没有任何一项高亮"。直接写 `(i-1) % n` 就会这样静默坏掉。
候选走**内联渲染**而不是 `bindPopup`/`bindMenu`:那两者各有焦点体系,
会先吃掉按键 ⇒ "↑↓ 换候选"落不到 `onToKey` 上。
## ④ 另修一个真 bug:`AddressSuggestion` 字段名整套写错
模型声明 `value`/`kind`,而服务端(`contacts.go:206`)给的是 `alias`/`title`/`source`
—— **从来没返回过** `value`/`kind`。按本仓纪律「声明了服务端从不返回的字段 ⇒ 删掉声明」改正。
顺带守住 `title` 的 `omitempty`:缺键时裸 cast 给 `undefined`(**不是**类里那个 `= ''`),
直接读会 `Cannot read property of undefined` —— 本仓在 `MailDetail.normalize()` 上
踩过同一形状(整页白屏)。判据钉住那句 `typeof … === 'string'` 的守。
## ⑤ 三处判据被**实测**改判(不是我挑一边,是拿数字定的)
### a. 卡片不该有模糊 —— 反转原断言
原判据断言「玻璃卡要走参数化 `backgroundEffect`」。那是**记录旧实现的副作用**
(重言式)。逐字读 WebUI 的 CSS:全仓 `backdrop-filter` **只有两处**
(`.glass-control` 8px/1.1、`.narrow-nav` 18px/1.5),而 `.glass-card`(`index.css:1629`)
**完全没有** —— 它的玻璃感是 `rgb(255 255 255 / 0.92)` 这个 alpha。
留着旧断言更坏:下次谁把卡片改成正确的"白 + alpha"会被判红,然后去**把模糊加回来**。
### b. 我试了"把模糊移到导航条",被实测否掉
推断「真归属是底栏」,于是把底栏改成 `backgroundEffect({radius:18, saturation:1.5})`。
实测(模拟器窄屏 1008×2232,底栏中心列 x=504):
y backgroundEffect backgroundBlurStyle
1960 rgb(191,199,209) rgb(234,235,239) ← 差 -36 亮度
2060 rgb(206,211,219) rgb(234,235,239) ← 差 -24
⇒ `backgroundEffect` **只给模糊、不给底色**,壁纸原样透上来,底栏暗了 24~36 级。
WebUI 的 `.narrow-nav` 是**两条声明**组合的(`background-color` + `backdrop-filter`),
我只搬了后者。系统材质**同时含色调 + 模糊 + 深浅两套** ⇒ 在这里它才是正解
(§7.12 把它判成"有意差异"是对的,我的"改进"是退步)。
判据改成**反向钉住** `backgroundEffect`,并把这段实测数字留在 Theme.ets 里。
### c. 页签条判据记录的是被否掉的"胶囊"
原判据钉 `TAB_BAR_RADIUS`/`TAB_BAR_SIDE`/`TAB_BAR_TOP` —— 那正是用户否掉的形状
(「你又在内部套了一个胶囊」)。CDP 读 WebUI 的实测几何:
.comm-pane x=80 w=320 radius=14px overflow=hidden ← 窗格,裁圆的是它
tabstrip x=80 w=320 radius=0px ← 条自己无圆角
设备实测(`uitest dumpLayout`):页签条 `[28,140][980,267]`、窗格 `[28,140][980,1957]`
⇒ 左右边缘逐像素相同。判据改为钉"与窗格齐平 + 只有左上角圆角"这两条**不变式**,
`TAB_BAR_RADIUS`/`TAB_BAR_SIDE` 一并**删除**(留着就是孤儿,会邀请人把胶囊拼回来)。
## ⑥ 顺带修两条判据自己的正则
`{6}` 看不见 8 位色 ⇒ 把 `glassCard`/`glassCardWall` 报成"清册过期",
**病因报错了**。改成 `{6}(?:[0-9A-Fa-f]{2})?`(不能写 `{6,8}`,那会连 7 位也放进来)。
半透明禁令改为**枚举白名单**(不是放宽:遮罩那个真实约束原样保留,
`overlay` 写成 8 位单色照样红)。
新增判据 `harmony-2in1.test.mjs` 12 条;`files=34 checks=543 pass=540 fail=2`(收尾前)。
This commit is contained in:
27
client/electron/measure.mjs
Normal file
27
client/electron/measure.mjs
Normal file
@ -0,0 +1,27 @@
|
||||
import { WebSocket } from 'ws';
|
||||
const list = await (await fetch('http://127.0.0.1:9222/json/list')).json();
|
||||
const page = list.find(t => t.type === 'page');
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
let id = 0; const pend = new Map();
|
||||
const send = (m, p = {}) => new Promise(r => { const i = ++id; pend.set(i, r); ws.send(JSON.stringify({ id: i, method: m, params: p })); });
|
||||
await new Promise(r => ws.on('open', r));
|
||||
ws.on('message', d => { const m = JSON.parse(d); if (m.id && pend.has(m.id)) { pend.get(m.id)(m.result); pend.delete(m.id); } });
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: 430, height: 932, deviceScaleFactor: 2, mobile: true });
|
||||
await send('Page.navigate', { url: 'http://127.0.0.1:5173/' });
|
||||
await new Promise(r => setTimeout(r, 4500));
|
||||
const { result } = await send('Runtime.evaluate', { expression: `(() => {
|
||||
const out = {};
|
||||
const shell = document.querySelector('.narrow-shell');
|
||||
const nav = document.querySelector('.narrow-nav');
|
||||
const listEl = document.querySelector('.overflow-y-auto');
|
||||
const r = e => { if(!e) return null; const b = e.getBoundingClientRect(); return {top:Math.round(b.top), bottom:Math.round(b.bottom), h:Math.round(b.height)}; };
|
||||
out.viewport = {h: innerHeight};
|
||||
out.shell = r(shell);
|
||||
out.nav = r(nav);
|
||||
out.navStyle = nav ? {position:getComputedStyle(nav).position, margin:getComputedStyle(nav).margin, flexShrink:getComputedStyle(nav).flexShrink} : null;
|
||||
out.scroller = r(listEl);
|
||||
out.shellDisplay = shell ? getComputedStyle(shell).display : null;
|
||||
return JSON.stringify(out, null, 1);
|
||||
})()`, returnByValue: true });
|
||||
console.log(result?.value);
|
||||
ws.close();
|
||||
19
client/electron/shot-cal.mjs
Normal file
19
client/electron/shot-cal.mjs
Normal file
@ -0,0 +1,19 @@
|
||||
import { WebSocket } from 'ws';
|
||||
const list = await (await fetch('http://127.0.0.1:9222/json/list')).json();
|
||||
const page = list.find(t => t.type === 'page');
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
let id = 0; const pend = new Map();
|
||||
const send = (m, p = {}) => new Promise(r => { const i = ++id; pend.set(i, r); ws.send(JSON.stringify({ id: i, method: m, params: p })); });
|
||||
await new Promise(r => ws.on('open', r));
|
||||
ws.on('message', d => { const m = JSON.parse(d); if (m.id && pend.has(m.id)) { pend.get(m.id)(m.result); pend.delete(m.id); } });
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: 430, height: 932, deviceScaleFactor: 2, mobile: true });
|
||||
await send('Page.navigate', { url: 'http://127.0.0.1:5173/' });
|
||||
await new Promise(r => setTimeout(r, 4500));
|
||||
// 点日历(底部导航第 2 项)
|
||||
const { result } = await send('Runtime.evaluate', { expression: `(()=>{const els=[...document.querySelectorAll('button,a,div')];const t=els.find(e=>e.textContent?.trim()==='日历');if(t){t.click();return 'clicked'}return 'not found'})()`, returnByValue: true });
|
||||
console.log('点击结果:', result?.value);
|
||||
await new Promise(r => setTimeout(r, 2500));
|
||||
const { data } = await send('Page.captureScreenshot', { format: 'png' });
|
||||
(await import('node:fs')).writeFileSync('/tmp/webui-cal.png', Buffer.from(data, 'base64'));
|
||||
console.log('已截图 /tmp/webui-cal.png');
|
||||
ws.close();
|
||||
26
client/electron/shot-pane.mjs
Normal file
26
client/electron/shot-pane.mjs
Normal file
@ -0,0 +1,26 @@
|
||||
import { WebSocket } from 'ws';
|
||||
const list = await (await fetch('http://127.0.0.1:9222/json/list')).json();
|
||||
const page = list.find(t => t.type === 'page');
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
let id=0; const pend=new Map();
|
||||
const send=(m,p={})=>new Promise(r=>{const i=++id;pend.set(i,r);ws.send(JSON.stringify({id:i,method:m,params:p}));});
|
||||
await new Promise(r=>ws.on('open',r));
|
||||
ws.on('message',d=>{const m=JSON.parse(d);if(m.id&&pend.has(m.id)){pend.get(m.id)(m.result);pend.delete(m.id);}});
|
||||
await send('Emulation.setDeviceMetricsOverride',{width:1400,height:900,deviceScaleFactor:2,mobile:false});
|
||||
await send('Page.navigate',{url:'http://127.0.0.1:5173/'});
|
||||
await new Promise(r=>setTimeout(r,5000));
|
||||
const {result}=await send('Runtime.evaluate',{expression:`(()=>{
|
||||
const out={};
|
||||
const pick=(sel,label)=>{const e=document.querySelector(sel); if(!e){out[label]='(none)';return;}
|
||||
const b=e.getBoundingClientRect(), cs=getComputedStyle(e);
|
||||
out[label]={x:Math.round(b.x),y:Math.round(b.y),w:Math.round(b.width),h:Math.round(b.height),
|
||||
radius:cs.borderRadius, bg:cs.backgroundColor, blur:cs.backdropFilter, shadow:cs.boxShadow.slice(0,40), border:cs.borderWidth+' '+cs.borderColor};};
|
||||
pick('.app-shell > *','pane');
|
||||
pick('[data-testid="comm-tabs"]','tabstrip');
|
||||
pick('.glass-card','firstCard');
|
||||
return JSON.stringify(out,null,1);
|
||||
})()`,returnByValue:true});
|
||||
console.log(result?.value);
|
||||
const {data}=await send('Page.captureScreenshot',{format:'png',clip:{x:60,y:0,width:420,height:260,scale:2}});
|
||||
(await import('node:fs')).writeFileSync('/tmp/web-pane-tl.png',Buffer.from(data,'base64'));
|
||||
ws.close();
|
||||
27
client/electron/shot-pane2.mjs
Normal file
27
client/electron/shot-pane2.mjs
Normal file
@ -0,0 +1,27 @@
|
||||
import { WebSocket } from 'ws';
|
||||
const list = await (await fetch('http://127.0.0.1:9222/json/list')).json();
|
||||
const page = list.find(t => t.type === 'page');
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
let id=0; const pend=new Map();
|
||||
const send=(m,p={})=>new Promise(r=>{const i=++id;pend.set(i,r);ws.send(JSON.stringify({id:i,method:m,params:p}));});
|
||||
await new Promise(r=>ws.on('open',r));
|
||||
ws.on('message',d=>{const m=JSON.parse(d);if(m.id&&pend.has(m.id)){pend.get(m.id)(m.result);pend.delete(m.id);}});
|
||||
await send('Emulation.setDeviceMetricsOverride',{width:1400,height:900,deviceScaleFactor:2,mobile:false});
|
||||
await send('Page.navigate',{url:'http://127.0.0.1:5173/'});
|
||||
await new Promise(r=>setTimeout(r,5000));
|
||||
const {result}=await send('Runtime.evaluate',{expression:`(()=>{
|
||||
const ts=document.querySelector('[data-testid="comm-tabs"]');
|
||||
const b=ts.getBoundingClientRect();
|
||||
// 往上找所有祖先,列出它们的几何+圆角,看 tabstrip 住在谁里面
|
||||
const out=[]; let e=ts;
|
||||
while(e && e!==document.documentElement){
|
||||
const r=e.getBoundingClientRect(), cs=getComputedStyle(e);
|
||||
out.push({tag:e.tagName.toLowerCase()+'.'+(e.className||'').split(' ').slice(0,3).join('.'),
|
||||
x:Math.round(r.x),y:Math.round(r.y),w:Math.round(r.width),h:Math.round(r.height),
|
||||
radius:cs.borderRadius, overflow:cs.overflow, bg:cs.backgroundColor});
|
||||
e=e.parentElement;
|
||||
}
|
||||
return JSON.stringify({tabstrip:{x:Math.round(b.x),w:Math.round(b.width),y:Math.round(b.y),h:Math.round(b.height)}, ancestors:out},null,1);
|
||||
})()`,returnByValue:true});
|
||||
console.log(result?.value);
|
||||
ws.close();
|
||||
18
client/electron/shot-webui-narrow.mjs
Normal file
18
client/electron/shot-webui-narrow.mjs
Normal file
@ -0,0 +1,18 @@
|
||||
import { WebSocket } from 'ws';
|
||||
const list = await (await fetch('http://127.0.0.1:9222/json/list')).json();
|
||||
const page = list.find(t => t.type === 'page');
|
||||
if (!page) { console.log('no page'); process.exit(1); }
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
let id = 0; const pend = new Map();
|
||||
const send = (method, params = {}) => new Promise(res => { const i = ++id; pend.set(i, res); ws.send(JSON.stringify({ id: i, method, params })); });
|
||||
await new Promise(r => ws.on('open', r));
|
||||
ws.on('message', d => { const m = JSON.parse(d); if (m.id && pend.has(m.id)) { pend.get(m.id)(m.result); pend.delete(m.id); } });
|
||||
// 388x844 ≈ 1008/2.6(同比例窄屏)
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: 430, height: 932, deviceScaleFactor: 2, mobile: true });
|
||||
await send('Page.navigate', { url: 'http://127.0.0.1:5173/' });
|
||||
await new Promise(r => setTimeout(r, 4000));
|
||||
const { data } = await send('Page.captureScreenshot', { format: 'png' });
|
||||
const fs = await import('node:fs');
|
||||
fs.writeFileSync('/tmp/webui-narrow.png', Buffer.from(data, 'base64'));
|
||||
console.log('已截图 /tmp/webui-narrow.png');
|
||||
ws.close();
|
||||
23
client/electron/shot-wide.mjs
Normal file
23
client/electron/shot-wide.mjs
Normal file
@ -0,0 +1,23 @@
|
||||
import { WebSocket } from 'ws';
|
||||
const list = await (await fetch('http://127.0.0.1:9222/json/list')).json();
|
||||
const page = list.find(t => t.type === 'page');
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
let id=0; const pend=new Map();
|
||||
const send=(m,p={})=>new Promise(r=>{const i=++id;pend.set(i,r);ws.send(JSON.stringify({id:i,method:m,params:p}));});
|
||||
await new Promise(r=>ws.on('open',r));
|
||||
ws.on('message',d=>{const m=JSON.parse(d);if(m.id&&pend.has(m.id)){pend.get(m.id)(m.result);pend.delete(m.id);}});
|
||||
await send('Emulation.setDeviceMetricsOverride',{width:1400,height:900,deviceScaleFactor:1,mobile:false});
|
||||
await send('Page.navigate',{url:'http://127.0.0.1:5173/'});
|
||||
await new Promise(r=>setTimeout(r,4500));
|
||||
const {result} = await send('Runtime.evaluate',{expression:`(()=>{
|
||||
const t=document.querySelector('[role="tablist"]');
|
||||
if(!t) return 'no tablist';
|
||||
const b=t.getBoundingClientRect(); const cs=getComputedStyle(t);
|
||||
return JSON.stringify({rect:{x:Math.round(b.x),y:Math.round(b.y),w:Math.round(b.width),h:Math.round(b.height)},
|
||||
radius:cs.borderRadius, borderBottom:cs.borderBottomWidth+' '+cs.borderBottomColor,
|
||||
bg:cs.backgroundColor, blur:cs.backdropFilter, margin:cs.margin, padding:cs.padding}, null, 1);
|
||||
})()`,returnByValue:true});
|
||||
console.log(result?.value);
|
||||
const {data}=await send('Page.captureScreenshot',{format:'png'});
|
||||
(await import('node:fs')).writeFileSync('/tmp/webui-wide.png',Buffer.from(data,'base64'));
|
||||
ws.close();
|
||||
@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import * as api from '../api/client';
|
||||
import { parseParts, mergeCandidate, nextActiveIndex, queryFor, filterIndexes } from '../lib/addressSuggest';
|
||||
import type { SessionCandidate } from '../types';
|
||||
|
||||
/**
|
||||
@ -49,11 +50,8 @@ export default function AddressInput({
|
||||
const run = async () => {
|
||||
try {
|
||||
// 决定问哪一层:还没写 @ -> 问 name;写了 @ 没写 . -> 问 path;写了 . -> 问 session
|
||||
const res = parts.hasDot
|
||||
? await api.suggestAddress(parts.name, parts.path)
|
||||
: parts.hasAt
|
||||
? await api.suggestAddress(parts.name)
|
||||
: await api.suggestAddress();
|
||||
const q = queryFor(parts);
|
||||
const res = await api.suggestAddress(q.name, q.path);
|
||||
if (cancelled) return;
|
||||
|
||||
const frag = parts.hasDot ? parts.session : parts.hasAt ? parts.path : parts.name;
|
||||
@ -61,15 +59,11 @@ export default function AddressInput({
|
||||
const all = res.suggestions || [];
|
||||
const cands = res.candidates || [];
|
||||
|
||||
// 过滤时保持 suggestions 与 candidates 同序:candidates 是按下标对应的,
|
||||
// 分别过滤两个数组会让标题错位到别的别名上。
|
||||
const keep: number[] = [];
|
||||
all.forEach((s, i) => {
|
||||
const c = cands[i];
|
||||
// 标题也参与匹配:想找「缓存选型」那条会话时,人记得的是标题而不是随机短名
|
||||
const hay = c?.title ? `${s} ${c.title}`.toLowerCase() : s.toLowerCase();
|
||||
if (hay.includes(lower)) keep.push(i);
|
||||
});
|
||||
/*
|
||||
* 过滤下标(**保持 suggestions 与 candidates 同序**)——
|
||||
* 抽到 `lib/addressSuggest.ts`,与鸿蒙 `filterIndexes` 同一份规则。
|
||||
*/
|
||||
const keep: number[] = filterIndexes(all, cands.map(c => c?.title ?? ''), lower);
|
||||
|
||||
setKind(res.kind);
|
||||
setItems(keep.map(i => all[i]));
|
||||
@ -127,14 +121,7 @@ export default function AddressInput({
|
||||
|
||||
/** 选中一个候选后拼回完整地址 */
|
||||
const apply = (choice: string) => {
|
||||
let next: string;
|
||||
if (kind === 'name') {
|
||||
next = `${choice}@`;
|
||||
} else if (kind === 'path') {
|
||||
next = `${parts.name}@${choice}.`;
|
||||
} else {
|
||||
next = `${parts.name}@${parts.path}.${choice}`;
|
||||
}
|
||||
const next: string = mergeCandidate(parts, kind, choice);
|
||||
onChange(allowMultiple ? `${head}${head ? ' ' : ''}${next}` : next);
|
||||
// name/path 选完仍停留在补全态,继续下一段
|
||||
setOpen(kind !== 'session');
|
||||
@ -144,10 +131,10 @@ export default function AddressInput({
|
||||
if (!open || items.length === 0) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActive(i => (i + 1) % items.length);
|
||||
setActive(i => nextActiveIndex(i, items.length, 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActive(i => (i - 1 + items.length) % items.length);
|
||||
setActive(i => nextActiveIndex(i, items.length, -1));
|
||||
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
apply(items[active]);
|
||||
@ -244,22 +231,12 @@ export default function AddressInput({
|
||||
}
|
||||
|
||||
/** 把 name@path.session 拆段;path 内允许 . 与 /,按最后一个 . 切 */
|
||||
function parseParts(s: string) {
|
||||
const at = s.indexOf('@');
|
||||
if (at < 0) {
|
||||
return { name: s, path: '', session: '', hasAt: false, hasDot: false };
|
||||
}
|
||||
const name = s.slice(0, at);
|
||||
const rest = s.slice(at + 1);
|
||||
const dot = rest.lastIndexOf('.');
|
||||
if (dot < 0) {
|
||||
return { name, path: rest, session: '', hasAt: true, hasDot: false };
|
||||
}
|
||||
return {
|
||||
name,
|
||||
path: rest.slice(0, dot),
|
||||
session: rest.slice(dot + 1),
|
||||
hasAt: true,
|
||||
hasDot: true
|
||||
};
|
||||
}
|
||||
/*
|
||||
* `parseParts` 已抽到 `lib/addressSuggest.ts`(与鸿蒙 `model/AddressSuggest.ts` 一对)。
|
||||
*
|
||||
* 抽的理由:这段逻辑原先只在本组件里,鸿蒙要写同一套规则时**没有可比的基准**。
|
||||
* 现在它进了 `lib/`,`cross-client-logic.test.mjs` 能把同一张用例表喂给两边。
|
||||
*
|
||||
* ★ 抽的时候行为**一字不改**("取最后一个点"那个选择保留)——
|
||||
* 抽出来顺手"改进"会让判据比的是新行为,而线上跑的仍是旧行为。
|
||||
*/
|
||||
|
||||
125
client/electron/src/lib/addressSuggest.ts
Normal file
125
client/electron/src/lib/addressSuggest.ts
Normal file
@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 三维地址补全的**纯逻辑** —— 与鸿蒙 `model/AddressSuggest.ts` 一对。
|
||||
*
|
||||
* 从 `components/AddressInput.tsx` 里抽出来的。抽的原因有两个:
|
||||
* ① 逻辑散在组件闭包里时**没法单测**(`apply`/`onKeyDown` 都改 React state);
|
||||
* ② 鸿蒙侧要写同一套规则 —— 两份手抄必然漂移(本仓已有判据
|
||||
* `cross-client-logic.test.mjs` 专门抓这个,它的首次运行就抓到过两处真分叉)。
|
||||
*
|
||||
* ⇒ 抽成纯函数后,同一张用例表喂给两边、逐条比结果。
|
||||
*
|
||||
* ★ 抽的时候**行为一字不改**:`parseParts` 是逐字搬过来的(含"取最后一个点")。
|
||||
* 抽出来顺手"改进"一下是最危险的 —— 那会让判据比的是新行为,
|
||||
* 而线上跑的仍是旧行为,两边都"通过"了却都错。
|
||||
*/
|
||||
|
||||
export interface AddressParts {
|
||||
name: string;
|
||||
path: string;
|
||||
session: string;
|
||||
hasAt: boolean;
|
||||
hasDot: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把编辑中的一段拆成三段。
|
||||
*
|
||||
* 没有 `@` ⇒ 整串都是 name;有 `@` 没 `.` ⇒ `@` 之后是 path;
|
||||
* 都有 ⇒ 以**最后一个** `.` 为界。
|
||||
*/
|
||||
export function parseParts(s: string): AddressParts {
|
||||
const at = s.indexOf('@');
|
||||
if (at < 0) {
|
||||
return { name: s, path: '', session: '', hasAt: false, hasDot: false };
|
||||
}
|
||||
const name = s.slice(0, at);
|
||||
const rest = s.slice(at + 1);
|
||||
const dot = rest.lastIndexOf('.');
|
||||
if (dot < 0) {
|
||||
return { name, path: rest, session: '', hasAt: true, hasDot: false };
|
||||
}
|
||||
return {
|
||||
name,
|
||||
path: rest.slice(0, dot),
|
||||
session: rest.slice(dot + 1),
|
||||
hasAt: true,
|
||||
hasDot: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 把选中的候选拼回一段完整地址。
|
||||
*
|
||||
* `name`/`path` 选完要**补上分隔符**(`@` / `.`),这样用户接着打字就自然
|
||||
* 进入下一段,不必自己敲分隔符。
|
||||
*/
|
||||
export function mergeCandidate(parts: AddressParts, kind: string, choice: string): string {
|
||||
if (kind === 'name') {
|
||||
return `${choice}@`;
|
||||
}
|
||||
if (kind === 'path') {
|
||||
return `${parts.name}@${choice}.`;
|
||||
}
|
||||
return `${parts.name}@${parts.path}.${choice}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上下键移动时的下一个下标(**循环**)。
|
||||
*
|
||||
* ★ JS 的 `%` 对负数返回负数(`-1 % 5 === -1`),所以向上移动要写成
|
||||
* `((i + d) % n + n) % n`。直接写 `(i-1) % n` 会得到负下标 ——
|
||||
* 而负下标在数组访问里**不报错**(返回 `undefined`),只会表现为
|
||||
* "按上键之后菜单里没有任何一项高亮"。这类静默失败正是判据要钉的。
|
||||
*/
|
||||
export function nextActiveIndex(current: number, count: number, delta: number): number {
|
||||
if (count <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return (((current + delta) % count) + count) % count;
|
||||
}
|
||||
|
||||
/** 候选菜单要不要向上翻转(下方空间不够且上方更多时翻) */
|
||||
export function shouldFlipUp(spaceAbove: number, spaceBelow: number, need: number): boolean {
|
||||
const want = Math.min(need, spaceAbove);
|
||||
return spaceBelow < want && spaceAbove > spaceBelow;
|
||||
}
|
||||
|
||||
export interface SuggestQuery {
|
||||
name: string;
|
||||
path: string;
|
||||
kind: 'name' | 'path' | 'session';
|
||||
}
|
||||
|
||||
/** 按当前输入决定"问哪一层" */
|
||||
export function queryFor(parts: AddressParts): SuggestQuery {
|
||||
if (parts.hasDot) {
|
||||
return { name: parts.name, path: parts.path, kind: 'session' };
|
||||
}
|
||||
if (parts.hasAt) {
|
||||
return { name: parts.name, path: '', kind: 'path' };
|
||||
}
|
||||
return { name: '', path: '', kind: 'name' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤候选的下标 —— **保持 suggestions 与 candidates 同序**。
|
||||
*
|
||||
* 分别过滤两个数组会让标题错位到别的别名上(`AddressInput.tsx:60` 的原注释)。
|
||||
* 标题也参与匹配:人记得的是会话标题而不是随机短名。
|
||||
*/
|
||||
export function filterIndexes(
|
||||
suggestions: string[],
|
||||
titles: string[],
|
||||
fragment: string
|
||||
): number[] {
|
||||
const keep: number[] = [];
|
||||
const lower = fragment.toLowerCase();
|
||||
suggestions.forEach((s, i) => {
|
||||
const title = titles[i] ?? '';
|
||||
const hay = title ? `${s} ${title}`.toLowerCase() : s.toLowerCase();
|
||||
if (hay.includes(lower)) {
|
||||
keep.push(i);
|
||||
}
|
||||
});
|
||||
return keep;
|
||||
}
|
||||
@ -234,6 +234,66 @@ const PAIRS = [
|
||||
['月历网格 2026-02(4 行的月)', 'monthGrid', [new Date(2026, 1, 1)], [2026, 2, 1], 'grid:2026,2'],
|
||||
['月历网格 2024-02(闰年)', 'monthGrid', [new Date(2024, 1, 1)], [2024, 2, 1], 'grid:2024,2']
|
||||
]
|
||||
},
|
||||
{
|
||||
/*
|
||||
* 三维地址补全 —— 用户 2026-09-21:「上下键切换发信目标」「回车展开输入框」。
|
||||
*
|
||||
* 这一对是**先抽再比**:WebUI 的逻辑原先散在 `AddressInput.tsx` 的组件闭包里
|
||||
* (`parseParts` 是模块级但没 export、`apply`/`onKeyDown` 直接改 React state),
|
||||
* 判据根本 import 不到。所以先把纯逻辑抽到 `lib/addressSuggest.ts` 并导出,
|
||||
* 鸿蒙侧写成一对的 `model/AddressSuggest.ts`。
|
||||
*
|
||||
* ★ 抽的时候行为**一字不改**。抽出来顺手"改进"一下是最危险的:
|
||||
* 判据会去比新行为,而线上跑的仍是旧行为 —— 两边都"通过"了却都错。
|
||||
*
|
||||
* ★ 这一对的价值:**键盘行为可单测**。"上下键循环"这种逻辑写成组件闭包时
|
||||
* 只能手动点着验;抽成纯函数后,`nextActiveIndex(0,3,-1)` 这类边界
|
||||
* (回绕、空列表)才钉得住。JS 的 `%` 对负数返回负数 ——
|
||||
* 直接写 `(i-1) % n` 会得到负下标,而负下标**不报错**(返回 `undefined`),
|
||||
* 只表现为"按上键后没有任何一项高亮"。判据抓的就是这种静默失败。
|
||||
*/
|
||||
name: 'AddressSuggest',
|
||||
electron: join(E_LIB, 'addressSuggest.ts'),
|
||||
harmony: join(H_MODEL, 'AddressSuggest.ts'),
|
||||
project: {},
|
||||
gaps: [],
|
||||
cases: [
|
||||
/* ── parseParts:三段切分(`@` 与最后一个 `.` 为界) ── */
|
||||
['三段全有', 'parseParts', ['pi@root.new']],
|
||||
['只有 name', 'parseParts', ['dsh']],
|
||||
['有 @ 无 .', 'parseParts', ['pi@root']],
|
||||
['@ 在开头', 'parseParts', ['@root.new']],
|
||||
['多个 @(取第一个)', 'parseParts', ['a@b@c.d']],
|
||||
['路径带点(取最后一个)', 'parseParts', ['pi@a.b/c.sess']],
|
||||
['空串', 'parseParts', ['']],
|
||||
['末尾就是 .(session 为空)', 'parseParts', ['pi@root.']],
|
||||
|
||||
/* ── mergeCandidate:选完候选拼回完整地址 ── */
|
||||
['选 name', 'mergeCandidate', [{ name: 'pi', path: 'root', session: 'new', hasAt: true, hasDot: true }, 'name', 'dsh']],
|
||||
['选 path', 'mergeCandidate', [{ name: 'pi', path: 'root', session: 'new', hasAt: true, hasDot: true }, 'path', 'home/x']],
|
||||
['选 session', 'mergeCandidate', [{ name: 'pi', path: 'root', session: 'new', hasAt: true, hasDot: true }, 'session', 'new']],
|
||||
|
||||
/* ── nextActiveIndex:上下键循环 ── */
|
||||
['下移', 'nextActiveIndex', [0, 3, 1]],
|
||||
['下移到底回绕', 'nextActiveIndex', [2, 3, 1]],
|
||||
['上移', 'nextActiveIndex', [1, 3, -1]],
|
||||
['上移到头回绕(负下标陷阱)', 'nextActiveIndex', [0, 3, -1]],
|
||||
['空列表', 'nextActiveIndex', [0, 0, 1]],
|
||||
['单元素上移', 'nextActiveIndex', [0, 1, -1]],
|
||||
|
||||
/* ── queryFor:问哪一层 ── */
|
||||
['没写 @ → 问 name', 'queryFor', [{ name: 'pi', path: '', session: '', hasAt: false, hasDot: false }]],
|
||||
['写了 @ → 问 path', 'queryFor', [{ name: 'pi', path: 'ro', session: '', hasAt: true, hasDot: false }]],
|
||||
['写了 . → 问 session', 'queryFor', [{ name: 'pi', path: 'root', session: 'n', hasAt: true, hasDot: true }]],
|
||||
|
||||
/* ── filterIndexes:同序过滤 + 标题参与匹配 ── */
|
||||
['按别名过滤', 'filterIndexes', [['a@x.n', 'b@y.m'], ['', ''], 'a']],
|
||||
['标题也参与匹配', 'filterIndexes', [['a@x.n', 'b@y.m'], ['缓存选型', '其他'], '缓存']],
|
||||
['无匹配', 'filterIndexes', [['a@x.n'], [''], 'zzz']],
|
||||
['空片段全保留', 'filterIndexes', [['a@x', 'b@y'], ['', ''], '']],
|
||||
['candidates 比 suggestions 短', 'filterIndexes', [['a@x', 'b@y'], ['t1'], 'b']]
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@ -231,6 +231,26 @@ const SELF_OWNED_COLORS = [
|
||||
* 深色变体 `accentEdgeDark` 对齐 WebUI `.dark` 段的 `--c-blue-200`。
|
||||
*/
|
||||
'accentEdge', 'accentEdgeDark',
|
||||
/*
|
||||
* ★★ 2026-09-21 补登记:`glassCard` / `glassCardWall`。
|
||||
*
|
||||
* 这两个是卡片底 —— 与 WebUI `.glass-card` **逐字同源**:
|
||||
* index.css:1629 .glass-card { background-color: rgb(255 255 255 / 0.92) }
|
||||
* index.css html[data-bg='on'] … { background-color: rgb(255 255 255 / 0.78) }
|
||||
* ⇒ glassCard = #EBFFFFFF(0.92×255 ≈ 235 = 0xEB)
|
||||
* ⇒ glassCardWall = #C7FFFFFF(0.78×255 ≈ 199 = 0xC7)
|
||||
*
|
||||
* 为什么必须是**自己写**的色而不是 `$r('sys.*')`:
|
||||
* 系统材质(`bgMaterial*`/`compBackground*`)是**不透明**的整块色板,
|
||||
* 没有"白 92% 叠在壁纸上"这一档。而 WebUI 的玻璃观感**正是**这个 alpha
|
||||
* —— 它不是模糊(`.glass-card` 全仓没有 `backdrop-filter`)。
|
||||
* 换成系统材质 ⇒ 壁纸被整块盖死,就是用户报的「玻璃不透明」。
|
||||
*
|
||||
* 为什么是"白 + alpha"而不是调亮度模拟:
|
||||
* 壁纸是**用户可换的图**,颜色不可预知;只有"半透明白"能同时满足
|
||||
* 浅壁纸与深壁纸(深色档另有 `DARK_DIM_MIN` 兜底)。
|
||||
*/
|
||||
'glassCard', 'glassCardWall',
|
||||
// 业务语义色:系统没有对应物(同意 / 拒绝 / 警示)
|
||||
'approve', 'danger', 'approveBg', 'approveFg', 'dangerBg', 'warnBg', 'warnFg',
|
||||
// 权限档位与预算档位的胶囊配色(档位是产品语义,系统不认识"plan/workspace/full")
|
||||
@ -336,9 +356,33 @@ test('B|旧机制不得回来:手写玻璃 alpha、替系统猜深色、与
|
||||
*/
|
||||
assert.ok(!/navBg(Light|Dark)/.test(codeOnly), 'navBgLight/navBgDark 不该再出现(玻璃交给系统材质)');
|
||||
|
||||
// 半透明色(8 位 #AARRGGBB)= 手写玻璃/手写遮罩那一类,一律不许
|
||||
const translucent = rawColors(codeOnly).filter(h => h.startsWith('#') && h.length === 9);
|
||||
assert.deepEqual(translucent, [], '源码里出现 8 位半透明色 —— 半透明属于系统材质/语义色的职责');
|
||||
/*
|
||||
* 半透明色(8 位 #AARRGGBB)= 手写玻璃/手写遮罩那一类,**原则上**不许。
|
||||
*
|
||||
* ★★ 2026-09-21:只有**两处**例外,且是"例外"而非"放宽" —— 理由是
|
||||
* 它们抄的对象**本身就是白色 + alpha**,而系统材质里没有这一档:
|
||||
*
|
||||
* index.css:1629 .glass-card → rgb(255 255 255 / 0.92)
|
||||
* index.css html[data-bg='on'] … → rgb(255 255 255 / 0.78)
|
||||
*
|
||||
* 系统材质(`bgMaterial*` / `compBackground*`)是**不透明**的整块色板。
|
||||
* 若把它们换成系统材质,壁纸会被整块盖死 —— 那正是用户报的
|
||||
* 「界面完全不透明,不显示背景」「玻璃也不透明」。
|
||||
*
|
||||
* ★ 为什么不允许"随手加一个":例外是**枚举**的(下面这个白名单),
|
||||
* 不是"只要是玻璃就放行"。加第三个半透明色 ⇒ 这条判据照样红,
|
||||
* 而那时得先回答"系统为什么没有对应物"这个问题。
|
||||
*
|
||||
* ★ 白名单项与 `SELF_OWNED_COLORS` 是**两个不同的问题**:
|
||||
* 那个问"色值有没有登记理由",这个问"半透明这件事合不合法"。
|
||||
*/
|
||||
const TRANSLUCENT_OK = ['#EBFFFFFF', '#C7FFFFFF']; // glassCard / glassCardWall
|
||||
const translucent = rawColors(codeOnly)
|
||||
.filter(h => h.startsWith('#') && h.length === 9)
|
||||
.filter(h => !TRANSLUCENT_OK.includes(h.toUpperCase()));
|
||||
assert.deepEqual(translucent, [],
|
||||
'源码里出现 8 位半透明色 —— 半透明属于系统材质/语义色的职责;' +
|
||||
`只有 glassCard/glassCardWall 这两个有登记理由(${TRANSLUCENT_OK.join(' ')})`);
|
||||
|
||||
// 鸿蒙侧不写 CSS 式颜色函数
|
||||
assert.ok(!/\brgba?\s*\(/.test(codeOnly), '鸿蒙源码里不该出现 rgb()/rgba()(那是 WebUI 的写法)');
|
||||
@ -588,17 +632,87 @@ test('C|玻璃:位置用系统材质、不许叠、每一处都要登记(
|
||||
assert.match(main, /\.backgroundBlurStyle\(Theme\.navMaterial\)/, '导航条要用系统材质(不是手写 alpha)');
|
||||
|
||||
/*
|
||||
* 参数化玻璃的**取值也要来自令牌**:`backgroundEffect({radius, saturation})`
|
||||
* 若不引 `Theme.glassCard*`,就等于把"玻璃浓度"写死在调用点 ——
|
||||
* 与 `#B8FFFFFF` 同类的问题(只是换了个旋钮)。
|
||||
* ══════════════════════════════════════════════════════════════════════
|
||||
* ★★ 2026-09-21 **反转**:卡片**不该**有模糊;模糊只属于导航/控件层
|
||||
* ══════════════════════════════════════════════════════════════════════
|
||||
*
|
||||
* 这条原先断言的是反面:
|
||||
* assert.ok(eff.length > 0, '玻璃卡要走参数化 backgroundEffect …')
|
||||
*
|
||||
* 那是**记录旧实现的副作用**,不是不变式 —— 本仓纪律:
|
||||
* 「记录旧实现副作用的判据是重言式」。旧实现确实给卡片挂了
|
||||
* `backgroundEffect({radius:18, saturation:1.5})`,于是判据照着它写。
|
||||
*
|
||||
* 但那个参数是**底部导航条那一档**(WebUI `.narrow-nav` 的
|
||||
* `blur(18px) saturate(1.5)`,`index.css:1204`),被我错安到了卡片上。
|
||||
*
|
||||
* ── WebUI 的事实(逐字读它的 CSS)──
|
||||
*
|
||||
* 全仓 `backdrop-filter` **只有两处**:
|
||||
* html[data-bg='on'] .glass-control → blur(8px) saturate(1.1)
|
||||
* .narrow-nav → blur(18px) saturate(1.5)
|
||||
* 而 `.glass-card`(`index.css:1629`)**完全没有 backdrop-filter**:
|
||||
* .glass-card { background-color: rgb(255 255 255 / 0.92); }
|
||||
* html[data-bg='on'] … { background-color: rgb(255 255 255 / 0.78); }
|
||||
* 它只有 radius + border + bg + transition。
|
||||
*
|
||||
* ⇒ 卡片的"玻璃感"来源是**半透明白**,不是模糊。给卡片加模糊的后果
|
||||
* 已实测:叠加成灰雾 + 掉帧,且用户报「邮件看着没有玻璃效果」。
|
||||
*
|
||||
* ── 所以现在钉的是这个方向 ──
|
||||
*
|
||||
* ① 卡片层(Surface.ets 的 GlassCardModifier)**不得**有 backgroundEffect;
|
||||
* ② 底色的两个 alpha 必须来自 Theme 令牌(不是写死在调用点);
|
||||
* ③ 真正的模糊只许出现在导航/控件层,且参数引 Theme 令牌。
|
||||
*
|
||||
* ★ 为什么不留着"必须有"那条:留着它,下次谁把卡片改成正确的
|
||||
* "白 + alpha"就会**被这条件据判红**,然后去把模糊加回来 ——
|
||||
* 判据会主动把实现推回错误方向。这比没有判据更坏。
|
||||
*/
|
||||
const surfaceSrc = code(join(HARMONY_ETS, 'common', 'Surface.ets'));
|
||||
const eff = [...surfaceSrc.matchAll(/\.backgroundEffect\(/g)];
|
||||
assert.ok(eff.length > 0, '玻璃卡要走参数化 `backgroundEffect`(BlurStyle 没有 saturation,观感偏灰)');
|
||||
assert.match(surfaceSrc, /radius:\s*Theme\.glass\w+/,
|
||||
'`backgroundEffect` 的 radius 要引 Theme 令牌(不许写死数字)');
|
||||
assert.match(surfaceSrc, /saturation:\s*Theme\.glass\w+/,
|
||||
'`backgroundEffect` 的 saturation 要引 Theme 令牌(它是"玻璃感"的主旋钮)');
|
||||
|
||||
/* ① 卡片构造器里不得出现模糊 */
|
||||
const cardAt = surfaceSrc.indexOf('class GlassCardModifier');
|
||||
assert.ok(cardAt > 0, '要能找到 GlassCardModifier');
|
||||
const cardBody = surfaceSrc.slice(cardAt, surfaceSrc.indexOf('\nclass ', cardAt + 10));
|
||||
assert.ok(!/\.backgroundEffect\(/.test(cardBody),
|
||||
'卡片层不得挂 backgroundEffect —— WebUI `.glass-card` 没有 backdrop-filter,' +
|
||||
'卡片的玻璃感来自半透明白。加模糊会叠成灰雾并掉帧(已实测)。');
|
||||
|
||||
/* ② 两个 alpha 令牌必须真的被卡片读(否则是孤儿令牌,见 C 的另一段) */
|
||||
assert.match(cardBody, /Theme\.glassCard\b/,
|
||||
'卡片要读 Theme.glassCard(0.92 那档,照抄 WebUI rgb(255 255 255 / 0.92))');
|
||||
assert.match(cardBody, /Theme\.glassCardWall\b/,
|
||||
'卡片要读 Theme.glassCardWall(0.78 那档,对应 WebUI html[data-bg=\'on\'])');
|
||||
|
||||
/*
|
||||
* ③ **不许**用 `backgroundEffect` —— 2026-09-21 实测否掉了我上一版的推断。
|
||||
*
|
||||
* 我曾把底栏改成 `backgroundEffect({radius:18, saturation:1.5})`,理由是
|
||||
* 「WebUI `.narrow-nav` 有 `saturate(1.5)`,而固定材质档没这个旋钮」。
|
||||
*
|
||||
* 实测(模拟器窄屏 1008×2232,底栏中心列 x=504):
|
||||
*
|
||||
* y backgroundEffect backgroundBlurStyle
|
||||
* 1960 rgb(191,199,209) rgb(234,235,239) ← 差 -36 亮度
|
||||
* 2000 rgb(198,204,212) rgb(234,235,239) ← 差 -31
|
||||
* 2060 rgb(206,211,219) rgb(234,235,239) ← 差 -24
|
||||
*
|
||||
* ⇒ `backgroundEffect` **只给模糊、不给底色**,壁纸原样透上来,
|
||||
* 整条底栏暗了 24~36 个亮度级。而 WebUI 的 `.narrow-nav` 是**两条声明**:
|
||||
* background-color: rgb(var(--nav-bg)); ← 我丢了这条
|
||||
* backdrop-filter: blur(18px) saturate(1.5);
|
||||
* 系统材质(`backgroundBlurStyle`)**同时含色调 + 模糊 + 深浅两套**,
|
||||
* 正好把这两条一起给了;且 WebUI 那个 `--nav-bg` 是手写 alpha、
|
||||
* 跟不了深色主题(§7.12)。⇒ 系统材质是这里**唯一**能兼顾深浅的方案。
|
||||
*
|
||||
* ★ 为什么判据要**反向**钉住它:不留这条,下一个人看到 WebUI 那行
|
||||
* `saturate(1.5)` 会再想一遍同样的事,而他要重跑一遍模拟器才能知道答案。
|
||||
*/
|
||||
const effSites = [...surfaceSrc.matchAll(/\.backgroundEffect\(/g)];
|
||||
assert.equal(effSites.length, 0,
|
||||
'不得使用 `backgroundEffect` —— 它不给底色,实测底栏会暗 24~36 个亮度级;' +
|
||||
'系统材质 `backgroundBlurStyle(Theme.navMaterial)` 同时含色调 + 模糊 + 深浅两套');
|
||||
|
||||
/*
|
||||
* 自检:造一次**链式叠用**(`X.blur().blur()`),确认上面的判定抓得到。
|
||||
@ -820,7 +934,23 @@ test('遮罩:交给系统的遮罩语义色("随主题换向"这件事现在
|
||||
'而只盯声明的判据("声明了且不是 NONE")**照样绿**。');
|
||||
// 用它的必须是**自绘遮罩**的地方(系统自带遮罩的弹窗不需要它)
|
||||
assert.ok(overlayUsers.every(f => f.endsWith('.ets')), `遮罩使用点应该是页面:${overlayUsers.join('、')}`);
|
||||
assert.ok(!/#[0-9A-Fa-f]{8}/.test(stripComments(harmony)), '遮罩不该再写成色与透明度焊死的 #AARRGGBB 单值');
|
||||
/*
|
||||
* 遮罩不该写成 `#AARRGGBB` 单值(色与透明度焊死,跟不了主题换向)。
|
||||
*
|
||||
* ★★ 2026-09-21 与 B 条同因修正:原来是**全仓**扫 `#……{8}`,
|
||||
* 于是 `glassCard` / `glassCardWall` 这两个**不是遮罩**的令牌也把它撞红了 ——
|
||||
* 而它们的理由与遮罩无关(它们是 WebUI `.glass-card` 的
|
||||
* `rgb(255 255 255 / 0.92)` 与 `/ 0.78`,是卡片底、不是遮罩)。
|
||||
*
|
||||
* ⇒ 把例外限制成**同名白名单**(与 B 条的 `TRANSLUCENT_OK` 同一份名单),
|
||||
* 而不是把这条判据整个放宽:遮罩那个真实约束**原样保留**。
|
||||
* `overlay` / `wallpaperScrim` 若被写成 8 位单值,这条照样红。
|
||||
*/
|
||||
const glassAlphaOk = ['#EBFFFFFF', '#C7FFFFFF']; // 与 B 条同一份名单
|
||||
const all8 = (stripComments(harmony).match(/#[0-9A-Fa-f]{8}/g) ?? [])
|
||||
.filter(h => !glassAlphaOk.includes(h.toUpperCase()));
|
||||
assert.deepEqual(all8, [],
|
||||
`遮罩不该再写成色与透明度焊死的 #AARRGGBB 单值;只有 glassCard/glassCardWall 有登记理由(实见 ${all8.join(' ')})`);
|
||||
// WebUI 侧同构:颜色两套(浅/深,同一个变量名换向)+ 透明度独立
|
||||
assert.match(web, /--bg-scrim: 255 255 255/, 'WebUI 浅色遮罩色');
|
||||
assert.match(web, /--bg-scrim: 0 0 0/, 'WebUI 深色遮罩色');
|
||||
@ -911,7 +1041,20 @@ test('★ 手写色清册**跨文件**:全 ets 树里每个 `X: string = \'#RR
|
||||
for (const f of tree) {
|
||||
const rel = f.slice(HARMONY_ETS.length + 1);
|
||||
const src = prose(f).replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
||||
for (const m of src.matchAll(/(\w+)\s*:\s*string\s*=\s*'(#[0-9A-Fa-f]{6})'/g)) {
|
||||
/*
|
||||
* ★★ 2026-09-21 **正则太窄**(本仓反复出现的同一形状:判据自己的正则
|
||||
* 比被判的东西窄 ⇒ 静默漏报,然后以"清册过期"的错误面貌报出来)。
|
||||
*
|
||||
* 原来写 `{6}` —— 只认 6 位。而 `glassCard` / `glassCardWall` 是
|
||||
* **8 位**(`#EBFFFFFF` / `#C7FFFFFF`,半透明白是 `glass-card` 的本质),
|
||||
* 于是它们扫不到 ⇒ 下面那句 `assert.ok(byName.has(n))` 报
|
||||
* 「清册里的 glassCard 已不存在(清册过期)」——
|
||||
* **报的是错的病因**:不是清册过期,是这个正则看不见 8 位色。
|
||||
*
|
||||
* ⇒ 改成 `{6}(?:[0-9A-Fa-f]{2})?`:6 位必有、8 位可选。
|
||||
* 注意**不能**写成 `{6,8}` —— 那会连 7 位这种非法长度也放进来。
|
||||
*/
|
||||
for (const m of src.matchAll(/(\w+)\s*:\s*string\s*=\s*'(#[0-9A-Fa-f]{6}(?:[0-9A-Fa-f]{2})?)'/g)) {
|
||||
declared.push({ file: rel, name: m[1], value: m[2] });
|
||||
}
|
||||
}
|
||||
|
||||
322
client/electron/test/harmony-2in1.test.mjs
Normal file
322
client/electron/test/harmony-2in1.test.mjs
Normal file
@ -0,0 +1,322 @@
|
||||
/*
|
||||
* 2in1(平板/PC 形态)键盘可达性判据 —— 用户 2026-09-21:
|
||||
* · 「接下来做一下 2in1 上的快捷键,比如快捷键打开发信页面」
|
||||
* · 「上下键切换发信目标」
|
||||
* · 「回车展开输入框等」
|
||||
*
|
||||
* # 为什么单独一个文件
|
||||
*
|
||||
* 这三条属于**同一条能力线**(键盘可达),而它们各自跨了两个文件
|
||||
* (根组件 + 条件挂载的子组件 / 纯逻辑 + 界面接线)。混进 `harmony-nav`
|
||||
* (管悬浮玻璃导航条)或 `harmony-logic`(管纯函数)都会让那个文件的
|
||||
* "这一期在钉什么"变得含糊。
|
||||
*
|
||||
* # 判据分寸:钉"接上了"和"边界对",不钉"好不好用"
|
||||
*
|
||||
* 键盘交互在模拟器上**没法端到端验**(没有真实键盘事件的注入通道,
|
||||
* `uitest uiInput` 只有 click/longClick/swipe/fling,没有 key)。
|
||||
* ⇒ 所以分两层:
|
||||
* · **可执行层**:纯逻辑用真跑(`model/AddressSuggest.ts` 已被
|
||||
* `cross-client-logic.test.mjs` 与 electron 逐例比对,这里不重复跑,
|
||||
* 只钉"界面接线确实调了它");
|
||||
* · **接线层**:钉"快捷键绑在根上、意图有接收方、键位不与系统冲突"。
|
||||
*
|
||||
* # 每个断言都要在**改坏时变红**(本仓纪律)
|
||||
*
|
||||
* 下面每条都写了"改什么会红"。写不出这句话的断言就是装饰。
|
||||
*/
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { code, prose } from './lib/read.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..', '..');
|
||||
const ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
|
||||
|
||||
const MAIN = code(join(ETS, 'pages/MainPage.ets'));
|
||||
const COMPOSE = code(join(ETS, 'pages/ComposePage.ets'));
|
||||
const INTENT = code(join(ETS, 'common/ComposeIntent.ets'));
|
||||
const SUGGEST_UI = code(join(ETS, 'model/AddressSuggest.ts'));
|
||||
const PROSE_INTENT = prose(join(ETS, 'common/ComposeIntent.ets'));
|
||||
|
||||
const mainProse = prose(join(ETS, 'pages/MainPage.ets'));
|
||||
const composeProse = prose(join(ETS, 'pages/ComposePage.ets'));
|
||||
|
||||
/** 取 `from` 处第一个 `{` 到配对 `}` 之间的正文(按花括号配对,不用窗口) */
|
||||
function braceBody(src, from) {
|
||||
const at = src.indexOf('{', src.indexOf(from));
|
||||
assert.ok(at > 0, `要能找到 ${from} 后面的 {`);
|
||||
let depth = 0;
|
||||
for (let i = at; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') { depth--; if (depth === 0) return src.slice(at + 1, i); }
|
||||
}
|
||||
assert.fail(`${from} 的花括号没有闭合`);
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
* ① 打开发信页的快捷键
|
||||
* ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
test('2in1 快捷键|Ctrl+N 绑在**根**组件树上(不是某个会卸载的分支)', () => {
|
||||
/*
|
||||
* 官方 `keyboardShortcut` 文档:「即使组件未获焦或是在所在页面未展示,
|
||||
* 只要已经挂载到**获焦窗口**的组件树上就会响应自定义组合键」。
|
||||
* ⇒ 只有绑在窗口组件树的根上,"窗口在就生效"才成立。
|
||||
*
|
||||
* 改坏会红:把 `.keyboardShortcut(...)` 挪到 FAB 上(FAB 在 `if` 分支里、
|
||||
* 窄屏/宽屏位置也不同)—— 换个 tab 就失效,而那时用户按 Ctrl+N 什么都没发生。
|
||||
*/
|
||||
const at = MAIN.indexOf(".keyboardShortcut('n'");
|
||||
assert.ok(at > 0, 'MainPage 里要有 Ctrl+N 绑定');
|
||||
|
||||
const structAt = MAIN.lastIndexOf('struct MainPage', at);
|
||||
const prevStruct = MAIN.lastIndexOf('\nstruct ', at);
|
||||
assert.ok(structAt > 0, 'Ctrl+N 绑定的位置要在 MainPage 之前有 struct 声明');
|
||||
assert.ok(
|
||||
structAt > prevStruct,
|
||||
`Ctrl+N 必须绑在 MainPage(根)里,而不是更早的 ${MAIN.slice(prevStruct + 1, prevStruct + 40).split('\n')[0]}`
|
||||
);
|
||||
});
|
||||
|
||||
test('2in1 快捷键|键位不与官方禁止绑定的系统组合冲突', () => {
|
||||
/*
|
||||
* 官方文档「禁止绑定的系统快捷键」列了五个:Alt+F4、Alt+Shift+F4、
|
||||
* Alt+TAB、Alt+Shift+TAB、Ctrl+Shift+ESC。
|
||||
*
|
||||
* 这五个绑上去**不生效**(且没有报错)—— 表现为"按了没反应",
|
||||
* 极容易被误判成代码接错了。
|
||||
*
|
||||
* 改坏会红:把 Ctrl+N 改成 Alt+Tab 之类。
|
||||
*/
|
||||
const at = MAIN.indexOf('.keyboardShortcut(');
|
||||
assert.ok(at > 0);
|
||||
const call = MAIN.slice(at, MAIN.indexOf(')', MAIN.indexOf('[', at)) + 1);
|
||||
|
||||
const banned = [
|
||||
["Alt", "F4"], ["Alt", "TAB"], ["Ctrl", "Shift", "ESC"]
|
||||
];
|
||||
for (const combo of banned) {
|
||||
const all = combo.every(k => call.includes(`ModifierKey.${k.toUpperCase()}`));
|
||||
assert.ok(!all, `不得绑定被系统占用的 ${combo.join('+')}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('2in1 快捷键|组合键用 ModifierKey 枚举,且热键是单字符', () => {
|
||||
/*
|
||||
* 官方约束(「快捷键使用注意事项」表):
|
||||
* · 「控制键 Ctrl、Shift、Alt 及它们的组合加上热键的单个字符」
|
||||
* · 「value 有多个字符时**不绑定**组合键」(静默失败)
|
||||
* · 「keys 有重复的控制键时**不绑定**」(静默失败)
|
||||
*
|
||||
* 改坏会红:写成 `.keyboardShortcut('open', ...)`(多字符)或
|
||||
* `[ModifierKey.CTRL, ModifierKey.CTRL]`(重复)。
|
||||
*/
|
||||
const m = MAIN.match(/\.keyboardShortcut\(\s*'([^']*)'\s*,\s*\[([^\]]*)\]/);
|
||||
assert.ok(m, '要能解析出 keyboardShortcut 的 value 与 keys');
|
||||
const [, value, keysRaw] = m;
|
||||
|
||||
assert.equal(value.length, 1, `热键必须是单个字符,实际是 '${value}'`);
|
||||
|
||||
const keys = keysRaw.split(',').map(s => s.trim()).filter(Boolean);
|
||||
assert.ok(keys.length > 0, 'keys 不能为空');
|
||||
assert.equal(new Set(keys).size, keys.length, `keys 不得重复:${keysRaw}`);
|
||||
for (const k of keys) {
|
||||
assert.match(k, /^ModifierKey\.(CTRL|SHIFT|ALT)$/, `keys 只允许 ModifierKey.CTRL/SHIFT/ALT,实际 ${k}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('2in1 快捷键|同一组合只允许绑一处(浅的赢,第二处等于失效)', () => {
|
||||
/*
|
||||
* 官方文档:「多个不同组件设置相同组合键 ⇒ 只响应节点树上的
|
||||
* **深度最浅**的组件,其它组件不响应快捷键」。
|
||||
*
|
||||
* ⇒ 再给别处的"写信"按钮补一个 Ctrl+N,不是"多一个入口",
|
||||
* 而是让后来那处**永远收不到**。这类"多写一份反而坏掉"的坑
|
||||
* 必须由判据挡住 —— 它不会报错,只会静默失效。
|
||||
*
|
||||
* 改坏会红:在 ComposePage / 任何别处再加一个 `.keyboardShortcut('n', [CTRL])`。
|
||||
*/
|
||||
const hits = [];
|
||||
for (const [name, src] of [['MainPage', MAIN], ['ComposePage', COMPOSE]]) {
|
||||
const re = /keyboardShortcut\(\s*'n'\s*,\s*\[\s*ModifierKey\.CTRL\s*\]/g;
|
||||
const n = (src.match(re) ?? []).length;
|
||||
if (n > 0) hits.push(`${name}×${n}`);
|
||||
}
|
||||
assert.deepEqual(hits, ['MainPage×1'], `Ctrl+N 只能绑一处,实际:${hits.join(' ')}`);
|
||||
});
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
* ② 意图的"两半"必须都在(缺一半 = 按键静默失效)
|
||||
* ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
test('2in1 快捷键|意图有"存住"与"当场交付"两半,且发起方先切到通信页', () => {
|
||||
/*
|
||||
* 根(MainPage)够不着 `openCompose()` —— 它住在**条件挂载**的 CommPage 上
|
||||
* (`if (this.currentIndex === 0)`)。这正是 `PushService` 处理"点通知跳转"
|
||||
* 时踩过的同一个坑,解法在它的注释里:**两半都要有**。
|
||||
*
|
||||
* · 只有"存住" ⇒ 用户此刻就在通信页、页面早挂载完了,
|
||||
* `aboutToAppear` 不重跑 ⇒ **按了没反应**;
|
||||
* · 只有"当场交付" ⇒ 用户此刻在日历页、CommPage 还没实例化、
|
||||
* 没有监听者 ⇒ **同样没反应**。
|
||||
*
|
||||
* 改坏会红:删掉 `ComposeIntent.setListener(...)`(通信页内按无效);
|
||||
* 删掉 `ComposeIntent.consume()`(跨页按无效);
|
||||
* 删掉发起方的 `this.currentIndex = 0`(在日历页按了,意图存着却没人挂载它)。
|
||||
*/
|
||||
assert.match(INTENT, /static pending: boolean/, '要有"存住"的格子');
|
||||
assert.match(INTENT, /static setListener\(/, '要有登记监听的入口');
|
||||
assert.match(INTENT, /static consume\(\): boolean/, '要有取走待处理的入口');
|
||||
|
||||
/* 发起方:先切通信页,再提意图 */
|
||||
const callAt = MAIN.indexOf('ComposeIntent.request()');
|
||||
assert.ok(callAt > 0, '根上要提意图');
|
||||
const before = MAIN.slice(Math.max(0, callAt - 400), callAt);
|
||||
assert.match(
|
||||
before,
|
||||
/this\.currentIndex\s*=\s*0/,
|
||||
'提意图之前必须先把 currentIndex 拨到 0(否则 CommPage 可能还没挂载)'
|
||||
);
|
||||
|
||||
/* 接手方:两半都接上 */
|
||||
const commAt = MAIN.indexOf('struct CommPage');
|
||||
const commEnd = MAIN.indexOf('\nstruct ', commAt + 1);
|
||||
const commBody = MAIN.slice(commAt, commEnd > 0 ? commEnd : MAIN.length);
|
||||
assert.match(commBody, /ComposeIntent\.setListener\(/, 'CommPage 要登记监听');
|
||||
assert.match(commBody, /ComposeIntent\.consume\(\)/, 'CommPage 挂载后要取走积压的请求');
|
||||
assert.match(commBody, /ComposeIntent\.clearListener\(\)/, 'CommPage 卸载时要摘掉监听');
|
||||
});
|
||||
|
||||
test('2in1 快捷键|摘监听发生在 aboutToDisappear(否则唤醒已销毁组件)', () => {
|
||||
/*
|
||||
* `clearListener` 若不在 `aboutToDisappear` 里,用户切走之后
|
||||
* 键盘事件仍会调到那个已卸载组件的 `openCompose()` ——
|
||||
* 轻则不响应,重则对已释放的 `navPathStack` 推路由。
|
||||
*
|
||||
* 改坏会红:把 `clearListener()` 挪到 `aboutToAppear`,或整个删掉。
|
||||
*/
|
||||
const commAt = MAIN.indexOf('struct CommPage');
|
||||
const commEnd = MAIN.indexOf('\nstruct ', commAt + 1);
|
||||
const commBody = MAIN.slice(commAt, commEnd > 0 ? commEnd : MAIN.length);
|
||||
|
||||
const disAt = commBody.indexOf('aboutToDisappear');
|
||||
assert.ok(disAt > 0, 'CommPage 要有 aboutToDisappear');
|
||||
const disBody = braceBody(commBody, 'aboutToDisappear');
|
||||
assert.match(disBody, /ComposeIntent\.clearListener\(\)/, '摘监听要在 aboutToDisappear 里');
|
||||
});
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
* ③ 收件人补全:上下键 / 回车 —— 接线用的必须是那份**已比对过**的纯逻辑
|
||||
* ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
test('2in1 快捷键|收件人键盘处理走纯逻辑(接循环下标,不自己写 %)', () => {
|
||||
/*
|
||||
* `nextActiveIndex` 里那个 `+ n) % n` 是负下标陷阱的解药
|
||||
* (JS 的 `%` 对负数返回负数,`-1 % 5 === -1`;而负下标在数组访问里
|
||||
* **不报错**,只表现为"按上键后没有任何一项高亮")。
|
||||
* 该函数已被 `cross-client-logic.test.mjs` 与 electron 逐例比对。
|
||||
*
|
||||
* ⇒ 界面必须**调它**,不能就地写 `(i - 1) % n` —— 后者看起来等价、
|
||||
* 实际在边界上会静默坏掉,而这样的坏不会让任何判据变红。
|
||||
*
|
||||
* 改坏会红:把 `nextActiveIndex(...)` 换回 `(this.suggestActive + 1) % len`。
|
||||
*/
|
||||
assert.match(COMPOSE, /nextActiveIndex\(/, '收件人键盘处理要调 nextActiveIndex');
|
||||
assert.ok(
|
||||
!/suggestActive\s*[-+]\s*1\)\s*%/.test(COMPOSE),
|
||||
'不得自己就地写 %(负下标陷阱)'
|
||||
);
|
||||
assert.match(SUGGEST_UI, /\(\(current \+ delta\) % count \+ count\) % count/, '纯逻辑里才是那条公式');
|
||||
});
|
||||
|
||||
test('2in1 快捷键|↑↓ 换候选、Enter/Tab 选中、Esc 收起 —— 四个键都在', () => {
|
||||
/*
|
||||
* 对齐 WebUI `AddressInput.tsx:143-155`:
|
||||
* ArrowDown → active+1 ;ArrowUp → active-1
|
||||
* Enter | Tab → 选中当前项 ;Escape → 收起
|
||||
*
|
||||
* 改坏会红:删掉任一个 `else if` 分支 —— 用户按那个键就毫无反应。
|
||||
*/
|
||||
const at = COMPOSE.indexOf('onToKey');
|
||||
assert.ok(at > 0, '要有 onToKey');
|
||||
const body = braceBody(COMPOSE, 'private onToKey');
|
||||
assert.match(body, /KEYCODE_DPAD_DOWN/, '↓ 要有');
|
||||
assert.match(body, /KEYCODE_DPAD_UP/, '↑ 要有');
|
||||
assert.match(body, /KEYCODE_ENTER/, 'Enter 要有');
|
||||
assert.match(body, /KEYCODE_TAB/, 'Tab 要有');
|
||||
assert.match(body, /KEYCODE_ESCAPE/, 'Esc 要有');
|
||||
});
|
||||
|
||||
test('2in1 快捷键|只响应 KeyType.Down(Down/Up 都处理会让一次按键走两步)', () => {
|
||||
/*
|
||||
* `KeyEvent` 对一次按键会派发 Down 与 Up 两个事件。两个都处理 ⇒
|
||||
* 按一次 ↓ 下标移动 **2** 格,用户看到的是"跳着走"。
|
||||
*
|
||||
* 改坏会红:删掉 `if (e.type !== KeyType.Down) return;`。
|
||||
*/
|
||||
const body = braceBody(COMPOSE, 'private onToKey');
|
||||
assert.match(body, /e\.type\s*!==\s*KeyType\.Down/, '要先挡掉非 Down 的按键事件');
|
||||
});
|
||||
|
||||
test('2in1 快捷键|候选列表内联渲染,不用 bindPopup/bindMenu', () => {
|
||||
/*
|
||||
* 这两者各有自己的焦点体系 —— 用户的按键会先被它们吃掉,
|
||||
* "↑↓ 切换候选"就落不到 `onToKey` 上(菜单收不到、输入框也收不到)。
|
||||
*
|
||||
* 内联渲染(条件挂载)能让焦点一直留在输入框里 —— 这是键盘可达的前提。
|
||||
*
|
||||
* 改坏会红:把候选改成 `bindPopup(...)`。
|
||||
*/
|
||||
assert.ok(!/\.bindPopup\(/.test(COMPOSE), '不得用 bindPopup(会吃掉按键)');
|
||||
assert.ok(!/\.bindMenu\(/.test(COMPOSE), '不得用 bindMenu(会吃掉按键)');
|
||||
assert.match(COMPOSE, /if \(this\.suggestOpen && this\.suggestItems\.length > 0\)/, '候选要在布局里内联挂载');
|
||||
});
|
||||
|
||||
test('2in1 快捷键|候选标题读 title 时守住 omitempty 缺键', () => {
|
||||
/*
|
||||
* `SessionCandidate.Title` 带 `json:"title,omitempty"` ⇒ **整个键可能不存在**。
|
||||
* ArkTS 的裸 cast(`JSON.parse(raw) as T`)在缺键时给 `undefined`,
|
||||
* **不会**应用类里那个 `= ''` 默认值。
|
||||
*
|
||||
* 本仓已踩过同一个坑的另一个实例:`MailDetail.normalize()` 之前直接读
|
||||
* `m.to.trim()`,服务端 omit 时就 `Cannot read property trim of undefined`
|
||||
* ⇒ **整页白屏**。
|
||||
*
|
||||
* 改坏会红:把 `typeof raw === 'string' ? raw : ''` 换回 `cands[i].title`。
|
||||
*/
|
||||
assert.match(
|
||||
COMPOSE,
|
||||
/typeof raw === 'string' \? raw : ''/,
|
||||
'读 title 前必须守一道(omitempty 缺键时是 undefined,不是空串)'
|
||||
);
|
||||
/* 而且模型里的字段名要跟服务端一致(服务端给的是 alias/title/source) */
|
||||
const M = code(join(ETS, 'model/Models.ets'));
|
||||
const cls = M.slice(M.indexOf('export class AddressSuggestion'));
|
||||
assert.match(cls, /alias: string/, 'AddressSuggestion 要有 alias(服务端字段名)');
|
||||
assert.ok(!/value: string/.test(cls.slice(0, 600)), '不得保留服务端从不返回的 value 字段');
|
||||
});
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────
|
||||
* ④ 文档化:为什么用 keyboardShortcut 而不是 onKeyEvent
|
||||
* ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
test('2in1 快捷键|选 keyboardShortcut 的理由写在源码里(否则后人会"顺手改成 onKeyEvent")', () => {
|
||||
/*
|
||||
* 这条判据钉的不是代码,是**理由**。
|
||||
*
|
||||
* `onKeyEvent` 看着更"底层可控",但官方文档写明:它要求**组件获焦**才触发
|
||||
* (「按键事件是指组件与键盘、遥控器等按键设备交互时触发的事件,
|
||||
* 适用于所有可获焦组件」)。而邮件列表里焦点落在哪是不确定的(点一下就换),
|
||||
* 用 onKeyEvent 做全局快捷键会时灵时不灵。
|
||||
*
|
||||
* 而 `keyboardShortcut` 是「无论组件是否获焦 —— 只要窗口获焦,快捷键就会响应」。
|
||||
*
|
||||
* 改坏会红:把这段理由删了(后人看不到就会"顺手改成 onKeyEvent")。
|
||||
*/
|
||||
assert.match(mainProse, /keyboardShortcut/, '注释里要写明用的是 keyboardShortcut');
|
||||
assert.match(mainProse, /未获焦|获焦窗口/, '要写明"不依赖焦点"这条理由');
|
||||
assert.match(PROSE_INTENT, /两半|当初|坑/, 'ComposeIntent 要留下"为什么需要中间层"的由来');
|
||||
});
|
||||
@ -656,26 +656,53 @@ test('★ 顶部页签条与底部导航条同一族(都是悬浮玻璃)—
|
||||
'页签条不许铺实体面(Theme.surface)—— 那是壁纸模式下唯一一块实心白,"割裂"就是这么来的');
|
||||
assert.match(body, /\.backgroundBlurStyle\(this\.bgActive \? Theme\.navMaterial : BlurStyle\.NONE\)/,
|
||||
'页签条要吃系统材质(与底部导航条同档),否则它和玻璃卡片拼在一起不是一套东西');
|
||||
assert.match(body, /\.borderRadius\(TAB_BAR_RADIUS\)/,
|
||||
'页签条要用 TAB_BAR_RADIUS(= NAV_BAR_RADIUS)—— 自己拍一个数会让上下两条不齐');
|
||||
/*
|
||||
* ★★ 2026-09-20:留白必须**算进宽度**,不能只写 `width('100%')` + `margin`。
|
||||
* ══════════════════════════════════════════════════════════════════════
|
||||
* ★★ 2026-09-21 **改判**:页签条不再自己成条,而是与窗格**齐平**
|
||||
* ══════════════════════════════════════════════════════════════════════
|
||||
*
|
||||
* ArkUI 的 `width('100%')` 是按父容器算的,再叠 `margin` 会**向右溢出**
|
||||
* 而不是把条挤窄 —— 实测(模拟器 3184px、密度 2.875):页签条占 x=231..1151,
|
||||
* 与面板**同宽**,`TAB_BAR_SIDE` 完全没生效;看起来仍是"通栏 + 圆角",
|
||||
* 与底部悬浮条不齐。
|
||||
* 用户当天连指三次,最后一句是裁定:
|
||||
* 「你看看顶栏右边那个圆角,你不觉得奇怪吗?」
|
||||
* 「你右边改成没圆角不就行了,还 better,你好好看看,**你又在内部套了一个胶囊**」
|
||||
* 「鸿蒙布局还略有不同的,你直接改成右边没圆角就行了」
|
||||
*
|
||||
* 这条断言是**从这次实测反推出来的**:单看代码"有 margin"会以为留白生效了,
|
||||
* 而只有形如 `calc(100% - N vp)` 的宽度才真的让出那一段。
|
||||
* ── 我原来错在哪(用 CDP 读 WebUI 的实测几何才发现)──
|
||||
*
|
||||
* .comm-pane x=80 w=320 radius=14px overflow=hidden ← 窗格,**裁圆的是它**
|
||||
* tabstrip x=80 w=320 radius=0px ← 页签条,自己无圆角
|
||||
*
|
||||
* 两者**横向完全齐平**。而我们原来是"自己缩进 16vp + 自己带满圆角"⇒
|
||||
* 页签条成了一个**嵌在窗格里的胶囊**,上下两条弧各画一遍,中间留出一弯月牙。
|
||||
* 判据当时钉的 `TAB_BAR_RADIUS` / `TAB_BAR_SIDE` / `TAB_BAR_TOP`
|
||||
* **正是那个被否掉的形状**——它记录的是旧实现,不是不变式。
|
||||
*
|
||||
* ── 设备实测(模拟器窄屏 1008px,`uitest dumpLayout`)──
|
||||
*
|
||||
* 页签条 Row bounds=[28,140][980,267]
|
||||
* 所在窗格 bounds=[28,140][980,1957]
|
||||
* ⇒ 左右边缘**逐像素相同**(28 / 980),与 WebUI 一致。
|
||||
*
|
||||
* ── 现在钉的三条(换成不变式,而不是"等于某个常量")──
|
||||
*
|
||||
* ① 与窗格齐平:`width('100%')`,**不许**再有左右内缩
|
||||
* (`margin`/`calc(100% - N)` 那两种写法都不行 —— 那会把胶囊加回来);
|
||||
* ② **右上角无圆角**(用户明确要求):只给左上角,与窗格左上角那道弧**重合**成一道;
|
||||
* ③ 依旧要有玻璃(与底部导航条同档),不许退回实体面。
|
||||
*
|
||||
* ★ `TAB_BAR_RADIUS` / `TAB_BAR_SIDE` / `TAB_BAR_TOP` 已随之**删除**
|
||||
* (`model/NavItems.ts`)—— 留着它们就是**孤儿常量**:
|
||||
* 下一个人会照着名字把胶囊拼回来。`TAB_BAR_HEIGHT` 保留(仍在用:
|
||||
* 它决定条高,而条高是真实几何)。
|
||||
*/
|
||||
assert.ok(!/\.width\('100%'\)[\s\S]{0,200}?\.margin\(\{[^}]*left: TAB_BAR_SIDE/.test(body),
|
||||
"页签条不能写 `width('100%')` + `margin` —— ArkUI 里那是向右溢出、留白不生效。\n" +
|
||||
' 要用 `width(`calc(100% - ${TAB_BAR_SIDE * 2}vp)`)` 把留白算进宽度(设备实测的结论)');
|
||||
assert.match(body, /\.width\(`calc\(100% - \$\{TAB_BAR_SIDE \* 2\}vp\)`\)/,
|
||||
'页签条宽度要 `calc(100% - 2×TAB_BAR_SIDE)` —— 悬浮条的左右留白必须真的让出来');
|
||||
assert.match(body, /\.margin\(\{[^}]*top: TAB_BAR_TOP/,
|
||||
'页签条要留顶部间距(TAB_BAR_TOP)—— "浮着"而不是"贴着"内容区顶');
|
||||
assert.match(body, /\.width\('100%'\)/,
|
||||
'页签条要与窗格齐平(`width(\'100%\')`)—— WebUI 实测 .comm-pane 与 tabstrip 横向逐像素相同');
|
||||
assert.ok(!/TAB_BAR_SIDE/.test(body),
|
||||
'页签条不得再有左右内缩(TAB_BAR_SIDE 那个胶囊形状已被用户否掉)');
|
||||
assert.match(body, /\.borderRadius\(\{\s*topLeft:\s*Theme\.glassRadius,\s*topRight:\s*0\s*\}\)/,
|
||||
'页签条只许有**左上**圆角(右上 0)—— 用户:「你直接改成右边没圆角就行了」;' +
|
||||
'右上那道弧会与窗格右上角的弧叠成两道');
|
||||
assert.ok(!/\.borderRadius\(TAB_BAR_RADIUS\)/.test(body),
|
||||
'页签条不得用 TAB_BAR_RADIUS(那是"胶囊"形状,已被否)');
|
||||
|
||||
/*
|
||||
* 自检:把页签条改回实心面,上面第一条必须判红。
|
||||
|
||||
@ -284,8 +284,36 @@ test('⑤ 宽屏 app-shell 几何:padding/gap/radius 与 WebUI 同值(一比
|
||||
assert.match(theme, /static readonly paneGap: number = 10/, 'paneGap = 10(与 WebUI --pane-gap 同值)');
|
||||
// Row 用 space=paneGap(gap)
|
||||
assert.match(code_, /Row\(\{ space: Theme\.paneGap \}\)/, '宽屏 Row 用 space=paneGap(与 WebUI gap 同值)');
|
||||
// 内容面板圆角只在宽屏启用(窄屏贴合全屏)
|
||||
assert.match(code_, /borderRadius\(this\.isWide \? Theme\.glassRadius : 0\)/, '内容面板圆角宽屏 14 / 窄屏 0');
|
||||
/*
|
||||
* ★★ 2026-09-21 反转:原断言是
|
||||
* `borderRadius(this.isWide ? Theme.glassRadius : 0)` —— 「宽屏 14 / 窄屏 0」。
|
||||
*
|
||||
* 那是**误读 WebUI**:它的两条 shell 规则**都给圆角**,差别只在留白 ——
|
||||
*
|
||||
* .app-shell > * { border-radius: var(--radius-card); } ← 宽屏
|
||||
* .narrow-shell > * { border-radius: var(--radius-card); } ← 窄屏
|
||||
* .app-shell { padding: var(--pane-gap); }
|
||||
* .narrow-shell { padding: var(--pane-gap) var(--pane-gap) 0; } ← 只差底边
|
||||
*
|
||||
* 我当初把"窄屏底边留白为 0"顺手推广成了"窄屏什么都不留(含圆角)",
|
||||
* 于是窄屏四个窗格全是直角 —— 用户看出来的原话:「你的邮件的圆角呢?日历的圆角呢?」
|
||||
*
|
||||
* 新不变式(这才是 WebUI 的真正契约):
|
||||
* · 面板**必须有**圆角(两端都有);
|
||||
* · 且**不得**用 `isWide ? … : 0` 这种把圆角绑到宽窄上的写法。
|
||||
*/
|
||||
assert.match(code_, /borderRadius\(Theme\.glassRadius\)/,
|
||||
'内容面板两端都要圆角(WebUI 的 `.app-shell > *` 与 `.narrow-shell > *` 都给 `--radius-card`)');
|
||||
assert.ok(!/borderRadius\(this\.isWide \? Theme\.glassRadius : 0\)/.test(code_),
|
||||
'★ 圆角不得绑在宽窄上(`isWide ? glassRadius : 0`)—— ' +
|
||||
'WebUI 的两条 shell 规则**都给圆角**,差的只是留白;' +
|
||||
'写成窄屏 0 会让四个窗格变直角,正是用户 2026-09-21 报的那个问题。');
|
||||
/*
|
||||
* 留白才是两端有别的那一处:宽屏四边都是 paneGap,窄屏底边为 0
|
||||
* (底栏自己带 margin,内容要从它下面穿过 —— 见 `navReserve` 那段)。
|
||||
*/
|
||||
assert.match(code_, /left: Theme\.paneGap,[\s\S]{0,80}?right: Theme\.paneGap,/,
|
||||
'左右留白两端都要(WebUI 两条 shell 的 padding 左右都是 --pane-gap)');
|
||||
/*
|
||||
* ★★ 2026-09-20 改:原来这条断言的是
|
||||
* assert.match(code_, /clip\(this\.isWide\)/, '面板内容要被圆角裁剪(clip 宽屏才开)');
|
||||
@ -307,15 +335,41 @@ test('⑤ 宽屏 app-shell 几何:padding/gap/radius 与 WebUI 同值(一比
|
||||
* 原来那条测的是"某句实现写着没写着"(且那实现是错的),
|
||||
* 现在测的是"与 WebUI 同一条几何纪律":圆角在、`overflow:hidden` 不在。
|
||||
*/
|
||||
assert.match(code_, /borderRadius\(this\.isWide \? Theme\.glassRadius : 0\)/,
|
||||
'宽屏圆角开(14)/ 窄屏 0 —— 与 WebUI `.app-shell > *` 同值');
|
||||
/*
|
||||
* 圆角:两端都开。
|
||||
*
|
||||
* ★★ 2026-09-21 反转(原句是 `borderRadius(this.isWide ? Theme.glassRadius : 0)`)。
|
||||
* 见上面那条断言的说明:WebUI 的两条 shell 规则**都给圆角**,
|
||||
* 差的只是留白。写成"窄屏 0"是我把"底边留白为 0"错推广成了"什么都不留"。
|
||||
* 用户原话:「你的邮件的圆角呢?日历的圆角呢?」。
|
||||
*/
|
||||
assert.match(code_, /borderRadius\(Theme\.glassRadius\)/,
|
||||
'内容面板两端都要圆角 —— 与 WebUI `.app-shell > *` / `.narrow-shell > *` 同值(都是 `--radius-card`)');
|
||||
assert.ok(!/borderRadius\(this\.isWide \? Theme\.glassRadius : 0\)/.test(code_),
|
||||
'★ 圆角不得绑宽窄(`isWide ? glassRadius : 0`)—— 窄屏会变直角');
|
||||
assert.match(code_, /attributeModifier\(GlassCardModifier\.of\(this\.bgActive\)\)/,
|
||||
'宽屏内容列的面来自设计令牌(不是就地写死一个实心 surface)');
|
||||
assert.ok(!/\.clip\(this\.isWide\)/.test(code_),
|
||||
'宽屏内容列**不能**写 `.clip(this.isWide)` —— 会把 `Navigation` 里 List 的滚动整条裁掉'
|
||||
+ '(WebUI `index.css:968` 在同一个规则块里写明了这条,2026-09-14 用户实测"无法上下滑动")');
|
||||
// 容器 padding 宽屏 paneGap / 窄屏 0(壁纸从缝隙露出)
|
||||
assert.match(code_, /left: this\.isWide \? Theme\.paneGap : 0/, '宽屏容器左右 padding paneGap(窄屏 0 贴合全屏)');
|
||||
/*
|
||||
* 容器左右留白 —— 两端都要 `paneGap`。
|
||||
*
|
||||
* ★★ 2026-09-21 反转:原句是 `left: this.isWide ? Theme.paneGap : 0`
|
||||
* (「窄屏 0 贴合全屏」)。那是我把 WebUI 的
|
||||
* .narrow-shell { padding: var(--pane-gap) var(--pane-gap) 0; }
|
||||
* 读成了"窄屏不留白" —— 它**左右上三边都留 gap**,只有**底边**为 0
|
||||
* (底栏自带 margin,内容要从它下面穿过)。
|
||||
* 后果:窄屏面板贴死屏幕两侧,既没圆角的余地也没投影的余地,
|
||||
* 看起来是一块被屏幕裁掉的白板 —— 用户:「邮箱页面丑死了,跟 WebUI 没法比」。
|
||||
*/
|
||||
assert.match(code_, /left: Theme\.paneGap,/,
|
||||
'容器左右留白两端都要(WebUI `.narrow-shell` 的 padding 左右也是 `--pane-gap`)');
|
||||
assert.ok(!/left: this\.isWide \? Theme\.paneGap : 0/.test(code_),
|
||||
'★ 左右留白不得绑宽窄(窄屏会贴死屏幕两侧)');
|
||||
/* 真正两端有别的只有**底边**:宽屏补 paneGap,窄屏 0(底栏自己带 margin) */
|
||||
assert.match(code_, /bottom: this\.isWide \? Theme\.paneGap : 0/,
|
||||
'底边留白才是两端有别的那一处:宽屏 paneGap / 窄屏 0(底栏自带 margin)');
|
||||
});
|
||||
test('⑦ 导航项徽标:取值/色调与 WebUI Sidebar 同口径(纯逻辑,不需要设备)', async () => {
|
||||
/*
|
||||
|
||||
@ -130,6 +130,11 @@ const SUITE = [
|
||||
// ArkTS **编译期**硬规则(纯文本可判、不需要设备)。这一条是构建撞出来的:
|
||||
// 我把常量表插在了既有 import 之前 ⇒ arkts-no-misplaced-imports,而当时没有任何判据会跑它。
|
||||
['test/harmony-arkts.test.mjs', [], 8],
|
||||
/*
|
||||
* 2in1 键盘可达(用户 2026-09-21「快捷键打开发信页面 / 上下键切换发信目标 /
|
||||
* 回车展开输入框」)。见该文件头部说明:为什么单开一个文件、为什么不端到端验。
|
||||
*/
|
||||
['test/harmony-2in1.test.mjs', [], 12],
|
||||
['test/harmony-contacts.test.mjs', [], 5],
|
||||
// ★★ 下面三条是**补接线**,不是新写的判据(2026-09-17)。
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user