跨端: 修我自己引入的 2 条红判据 + 3 条陈旧判据 + 补全地址补全的剩余 4 个挂点
用户两句话点破了我这轮的两个过程问题:
① 「你就不能把预先存在的问题修复一下?」—— 那 2 条 vitest 红其实是**我自己的回归**,
我两次把它们归成"预先存在"。查了 `git log -L` 才确认:是我 `125aec1` 改的。
② 「为什么不加载鸿蒙开发相关skill?」—— `arkts-grammar-standards` 的 frontmatter
第一句就是「**REQUIRED** before writing the first .ets file of a session」,
而我整个 session 写了十几个 `.ets`,一次都没读。
## ① 我自己的回归:`AddressInput` 问错层(`125aec1` 引入)
原始实现是**按层传 0/1/2 个参数**:
parts.hasDot ? api.suggestAddress(parts.name, parts.path)
: parts.hasAt ? api.suggestAddress(parts.name)
: api.suggestAddress();
我在 2in1 键盘那轮"简化"成恒定 `api.suggestAddress(q.name, q.path)`,
理由是"服务端把空串当没给"。**那个理由对,但它改了组件文档化的契约**:
AddressInput.test.tsx:73 「问 name 层时不带任何参数」
AddressInput.test.tsx:86 「写了 @ 没写 . 时带 name 去问 path 层」
两条从此常红。修法**不是改测试** —— 参数个数在这里就是**层语义**:
问 name 层就不该传任何筛选参数。改回按层分岔,但判据用 `q.kind`
(`queryFor` 的产物,"问哪一层"的唯一来源),而不是再自己从 `parts.hasDot` 推一遍。
**`AddressInput.test.tsx`: 14/14 通过**(原 12 passed / 2 failed)。
## ② 实证推翻一条**错的注释**(正是 #① 的病根)
`MailApi.ets` 写着:「服务端的判据是"参数有没有给"…**省略会让它退回到上一层**」
—— 前后两句都错。服务端(`contacts.go:152`)只有 `Query().Get()`、**没有 `Has()`**。
实测(网关 8180):
?name=pi → kind=path, 62 条
?name=pi&path= → kind=path, 62 条 ← 与上一行逐字节相同
?path= → kind=name, 6 条
⇒ 缺参与空串完全等价。**注释里的错误事实会变成代码里的错误决定** ——
我正是因为信了它才去"简化"的。改动注释,并说明代价。
## ③ 三条陈旧判据(都是"钉字面文本"而非"钉行为")
- `harmony-2in1`:钉 `/typeof raw === 'string' ? raw : ''/`,而守卫已搬进
`normalizeCandidates`(为让转发条/日历共用)。改成钉**两件真的事**:
守卫在(三字段各一道)+ 调用点真的走它。**双向变异验证**:
去掉守卫 ⇒ 红;写信页绕开自己读 `.title` ⇒ 红。
- `harmony-nav`:`src.slice(at, at + 4000)` —— 那个 4000 是拍脑袋的,
我在转发弹层加了候选列表后 `.transition()` 被推到 6107 字符处 ⇒ 假红
「转发弹层没有挂过渡」。改用已有的 `braceBody()`(按花括号配对找块尾)。
★ 这类假红的副作用是诱人去**加大那个数字**,而正确修法是换掉它。
- `build-stamp`/`packaging`:重跑 `npm run build` + `electron-builder`。
## ④ 补全剩余 4 个挂点(不再等用户一个一个指)
`codegraph_callers AddressInput` 给出 7 个调用点。写信页那处已修,本轮补:
- **多地址切分** `splitEditing` 抽到 `lib/` + `model/`(跨端共享),
9 条边界进 `cross-client-logic` 用例表,**变异验证**(只认逗号 ⇒ 红)。
- **转发条**收件人 + 抄送 → 都挂补全(复用同一套,`fwdSuggestField` 区分字段)。
- **日历事件编辑器**收件人 → 挂补全;为此把 `AddressSuggestionResponse`
从 `MailApi.ets` 移到 `model/Models.ets`(它在 `CalendarApi` 也要用,
让两个 API 类互相 import 是错的依赖方向)。
- **`normalizeCandidates` / `filterSets`** 收掉"守卫 + 同序过滤"的样板,
三个调用点共用一份;`pickBy` 补到 electron 侧 —— 它原来**只在鸿蒙有**,
是 `cross-client-logic` 当场抓出来的真分叉(`THROW:pickBy is not defined`)。
★ 同时把 electron 的 `AddressInput` **真的改成调用这些共享函数**
(原来抽了 lib 却仍用内联的 `useMemo` —— 等于把第二份实现搬了个地方)。
`items`/`meta` 改用归一化后的三元组,渲染层不再各自守 `omitempty`。
## ⑤ 终于去读了 skill(用户质问后)
读了 `arkts-grammar-standards`(含 `arkui-structure-rules.md`、`recipes-core.md`)、
`arkts-error-fixes`、`arkts-runtime-fix`。**发现我撞过的坑 skill 里全写着**:
`arkts-no-implicit-return-types`(我当成"地图函数的怪毛病",实际是全局推断限制)、
`arkts-no-misplaced-imports`、`Cannot find name`(`export { X } from` 不建立局部绑定)、
§6 `@Builder` 不可链式、§7 Button label XOR children / 嵌套 ForEach 必须异名。
**多花至少三轮编译往返。**
顺手按 skill 的清单审计自有源码,**37 条 ArkTS 告警**(此前两次都拿到 0 条 ——
因为增量构建 `UP-TO-DATE` 跳过了编译,**必须 touch 文件才出告警**):
- 33× `Function may throw exceptions`(全在 `showToast`/`http.request`,已有 catch)
- 2× `'fill' API is supported since SDK 26.0.0,当前 23` ⇒ **真隐患**
(`Circle().fill()`,设备实测黄点确实渲染,但 SDK 变动时会出问题)
- 1× `This API is unavailable to 2in1`、1× `'packing' deprecated`
## 验证
✓ `vitest run` **266/266**(15 文件全绿;此前 2 红是我引入的)
✓ `cross-client-logic` 7/7,且 `splitEditing`/`normalizeCandidates` 变异会红
✓ `harmony-2in1` 12/12、`harmony-nav` 21/21、`build-stamp` 7/7、`packaging` 5/5
✓ `tsc --noEmit` 通过
✓ hvigor 完整重编译 SUCCESSFUL
✗ 未做:`fill` 那 2 处换回 SDK23 可用的写法(当前设备实测无害,留给下一轮)
This commit is contained in:
@ -1,7 +1,14 @@
|
||||
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';
|
||||
import {
|
||||
parseParts,
|
||||
mergeCandidate,
|
||||
nextActiveIndex,
|
||||
queryFor,
|
||||
splitEditing,
|
||||
normalizeCandidates,
|
||||
filterSets
|
||||
} from '../lib/addressSuggest';
|
||||
|
||||
/**
|
||||
* 三段式地址输入:name -> @path -> .session
|
||||
@ -27,20 +34,40 @@ export default function AddressInput({
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [items, setItems] = useState<string[]>([]);
|
||||
// session 段的富候选,与 items 同序。其他段为空数组。
|
||||
const [meta, setMeta] = useState<SessionCandidate[]>([]);
|
||||
/*
|
||||
* session 段的富候选,与 items 同序。其他段为空数组。
|
||||
*
|
||||
* ★★ 2026-09-21 改:从 `SessionCandidate[]` 改成**三元组**。
|
||||
*
|
||||
* 原因:原来这里存服务端对象、渲染层自己 `c?.title` / `c?.source` /
|
||||
* `c?.unread` 地去 `omitempty` 守卫 —— 而这道守卫现在收进了
|
||||
* `normalizeCandidates`(两边共用)。渲染层只需要三个值,
|
||||
* 不需要那个对象的其它字段(`alias` 从来没被读过 —— 字符串在 `items` 里)。
|
||||
* ⇒ 存归一化后的形状,守卫只有一处。
|
||||
*/
|
||||
const [meta, setMeta] = useState<{ title: string; source: string; unread: number }[]>([]);
|
||||
const [kind, setKind] = useState<'name' | 'path' | 'session'>('name');
|
||||
const [active, setActive] = useState(0);
|
||||
const [menuLayout, setMenuLayout] = useState({ flip: false, maxHeight: 288 });
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 当前正在编辑的那一段(多地址时取最后一段)
|
||||
const { head, editing } = useMemo(() => {
|
||||
if (!allowMultiple) return { head: '', editing: value };
|
||||
const idx = Math.max(value.lastIndexOf(','), value.lastIndexOf(';'));
|
||||
if (idx < 0) return { head: '', editing: value };
|
||||
return { head: value.slice(0, idx + 1), editing: value.slice(idx + 1).trimStart() };
|
||||
}, [value, allowMultiple]);
|
||||
/*
|
||||
* ★★ 2026-09-21 改:改调 `splitEditing`(同一份规则抽到了 `lib/`)。
|
||||
*
|
||||
* 这段原来是**内联在这里的 `useMemo`**,与 `lib/addressSuggest.ts`
|
||||
* 里那份是**两份实现**;而鸿蒙侧第三份是我这轮新写的。
|
||||
* 我加了 `splitEditing` 到 lib、加了跨端判据,却**没把本组件改成用它** ——
|
||||
* 等于只是把第二份实现搬了个地方,判据比的是 lib 那份,
|
||||
* 线上跑的仍是这份内联的(这正是本仓`抽的时候行为一字不改`那段注释要防的事,
|
||||
* 只不过方向反过来:这次是**抽了却没接上**)。
|
||||
*
|
||||
* `useMemo` 保留(切分是纯计算,不必每渲染重算)。
|
||||
*/
|
||||
const { head, editing } = useMemo(
|
||||
() => splitEditing(value, allowMultiple),
|
||||
[value, allowMultiple]
|
||||
);
|
||||
|
||||
// 把编辑段拆成 name / path / session 三部分
|
||||
const parts = useMemo(() => parseParts(editing), [editing]);
|
||||
@ -51,23 +78,81 @@ export default function AddressInput({
|
||||
try {
|
||||
// 决定问哪一层:还没写 @ -> 问 name;写了 @ 没写 . -> 问 path;写了 . -> 问 session
|
||||
const q = queryFor(parts);
|
||||
const res = await api.suggestAddress(q.name, q.path);
|
||||
/*
|
||||
* ★★ 2026-09-21 修:**按层决定传几个参数**(这是我 `90631ca` 时弄坏的)。
|
||||
*
|
||||
* 原来的写法是三个三目:
|
||||
* const res = parts.hasDot
|
||||
* ? await api.suggestAddress(parts.name, parts.path)
|
||||
* : parts.hasAt ? await api.suggestAddress(parts.name)
|
||||
* : await api.suggestAddress();
|
||||
* 我在 2in1 键盘那轮把它简化成了恒定的
|
||||
* await api.suggestAddress(q.name, q.path)
|
||||
* —— 当时觉得"反正服务端把空串当没给"。那个说法**是对的**
|
||||
* (`contacts.go:151` 的 `name := r.URL.Query().Get("name")`
|
||||
* 对缺参与空串都得到 `""`,后续判据与线上行为完全一致),
|
||||
* 但它把组件**自己文档化的契约**悄悄改掉了:
|
||||
* `AddressInput.test.tsx:73` 「问 name 层时不带任何参数」,
|
||||
* `:86` 「写了 @ 没写 . 时带 name 去问 path 层」。
|
||||
* 那两条从此一直红,而我先后两次把它们当成"预先存在的问题"绕过去。
|
||||
* **它是我自己引入的回归。**
|
||||
*
|
||||
* ── 为什么是"改回调用形状"而不是"改测试" ──
|
||||
* 参数个数在这里是**层语义**的表达:问 name 层就不该传任何筛选参数。
|
||||
* 写成恒定两个参数虽然在服务端等价,但读代码的人得先确认
|
||||
* "空串会不会退到上一层"才能放心 —— 而那个确认正是我当初做错的地方。
|
||||
* 调用形状把意图摆在签名上,不需要读者去查服务端。
|
||||
*
|
||||
* ── 与旧代码的差别 ──
|
||||
* 判据用 `q.kind`(`queryFor` 的产物,**"问哪一层"的唯一来源**)
|
||||
* 而不是再自己从 `parts.hasDot`/`hasAt` 推一遍。旧写法推了两遍,
|
||||
* 两处将来会分叉(改了 `queryFor` 忘了改这里 ⇒ 问 A 层却按 B 层传参)。
|
||||
*/
|
||||
const res =
|
||||
q.kind === 'session'
|
||||
? await api.suggestAddress(q.name, q.path)
|
||||
: q.kind === 'path'
|
||||
? await api.suggestAddress(q.name)
|
||||
: await api.suggestAddress();
|
||||
if (cancelled) return;
|
||||
|
||||
const frag = parts.hasDot ? parts.session : parts.hasAt ? parts.path : parts.name;
|
||||
const lower = frag.toLowerCase();
|
||||
const all = res.suggestions || [];
|
||||
const cands = res.candidates || [];
|
||||
|
||||
/*
|
||||
* 过滤下标(**保持 suggestions 与 candidates 同序**)——
|
||||
* 抽到 `lib/addressSuggest.ts`,与鸿蒙 `filterIndexes` 同一份规则。
|
||||
* ★★ 2026-09-21 改:归一化 + 过滤改调共享函数。
|
||||
*
|
||||
* 原来这里是:
|
||||
* const keep = filterIndexes(all, cands.map(c => c?.title ?? ''), lower);
|
||||
* setItems(keep.map(i => all[i]));
|
||||
* setMeta(cands.length ? keep.map(i => cands[i]).filter(Boolean) : []);
|
||||
* —— 即"服务端对象存着、渲染时再各自守 `omitempty`"。
|
||||
*
|
||||
* 现在两个方向都只剩下**一份**实现:
|
||||
* `normalizeCandidates` 守一次 `omitempty`,`filterSets` 保同序。
|
||||
*
|
||||
* ★ 行为等价:`cands` 为空时旧代码给 `meta = []`;
|
||||
* 新代码会给每项 `{title:'',source:'',unread:0}` ——
|
||||
* 渲染层对这两者都不输出任何东西(三个值都不满足显示条件)。
|
||||
*/
|
||||
const keep: number[] = filterIndexes(all, cands.map(c => c?.title ?? ''), lower);
|
||||
const sets = normalizeCandidates(
|
||||
all,
|
||||
cands.map(c => c?.title),
|
||||
cands.map(c => c?.source),
|
||||
cands.map(c => c?.unread)
|
||||
);
|
||||
const kept = filterSets(sets, frag);
|
||||
|
||||
setKind(res.kind);
|
||||
setItems(keep.map(i => all[i]));
|
||||
setMeta(cands.length ? keep.map(i => cands[i]).filter(Boolean) : []);
|
||||
setItems(kept.items);
|
||||
setMeta(
|
||||
kept.items.map((_, i) => ({
|
||||
title: kept.titles[i],
|
||||
source: kept.sources[i],
|
||||
unread: kept.unreads[i]
|
||||
}))
|
||||
);
|
||||
setActive(0);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
@ -122,6 +207,11 @@ export default function AddressInput({
|
||||
/** 选中一个候选后拼回完整地址 */
|
||||
const apply = (choice: string) => {
|
||||
const next: string = mergeCandidate(parts, kind, choice);
|
||||
/*
|
||||
* 拼法逐字保留(`head` 尾上已带分隔符,非空时再补一个空格)——
|
||||
* 与 `lib/addressSuggest.ts` 里 `splitEditing` 的 `trimStart` 是一对:
|
||||
* 写的时候多一个空格、读的时候去掉,往返稳定。
|
||||
*/
|
||||
onChange(allowMultiple ? `${head}${head ? ' ' : ''}${next}` : next);
|
||||
// name/path 选完仍停留在补全态,继续下一段
|
||||
setOpen(kind !== 'session');
|
||||
@ -214,7 +304,7 @@ export default function AddressInput({
|
||||
)}
|
||||
{(c?.unread ?? 0) > 0 && (
|
||||
<span className="shrink-0 px-1 py-0.5 rounded bg-red-600 text-white text-3xs">
|
||||
{c!.unread}
|
||||
{c.unread}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -21,6 +21,43 @@ export interface AddressParts {
|
||||
hasDot: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端候选的三个可选字段(都已归一成"一定有值"的形状)。
|
||||
*
|
||||
* ★★ 2026-09-21 新增:抽出来的理由是**同一个坑要防多次**。
|
||||
*
|
||||
* `SessionCandidate` 的三个字段(`title` / `source` / `unread`)
|
||||
* 都带 `omitempty` ⇒ **缺键时裸 cast 拿到的是 `undefined`**。
|
||||
* 本仓已因此抛过 `TypeError`(`undefined.length`)。
|
||||
*
|
||||
* 鸿蒙侧原先在 `ComposePage.fetchSuggestions` 里内联这套 `typeof` 守卫;
|
||||
* 现在转发条、日历事件都要用 ⇒ 内联三份必然漂移。
|
||||
*/
|
||||
export interface CandidateSets {
|
||||
items: string[];
|
||||
titles: string[];
|
||||
sources: string[];
|
||||
unreads: number[];
|
||||
}
|
||||
|
||||
export function normalizeCandidates(
|
||||
suggestions: string[],
|
||||
titles: (string | undefined)[],
|
||||
sources: (string | undefined)[],
|
||||
unreads: (number | undefined)[]
|
||||
): CandidateSets {
|
||||
const out: CandidateSets = { items: suggestions.slice(), titles: [], sources: [], unreads: [] };
|
||||
for (let i = 0; i < suggestions.length; i++) {
|
||||
const t = i < titles.length ? titles[i] : undefined;
|
||||
out.titles.push(typeof t === 'string' ? t : '');
|
||||
const s = i < sources.length ? sources[i] : undefined;
|
||||
out.sources.push(typeof s === 'string' ? s : '');
|
||||
const u = i < unreads.length ? unreads[i] : undefined;
|
||||
out.unreads.push(typeof u === 'number' ? u : 0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把可能含多个地址的输入切成「已完成的前缀」+「正在编辑的最后一段」。
|
||||
*
|
||||
@ -152,3 +189,39 @@ export function filterIndexes(
|
||||
});
|
||||
return keep;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把"保留下来的下标"作用到任意同长数组上。
|
||||
*
|
||||
* ★★ 2026-09-21 补:这个函数**只存在于鸿蒙侧**(`model/AddressSuggest.ts`),
|
||||
* electron 从来没有 —— 而 electron 的 `AddressInput` 是在 `useMemo` 里
|
||||
* 直接 `.map()` 取的。两边的"取子集"因此没有任何可比基准。
|
||||
*
|
||||
* 我是写 `filterSets`(想要两边共用)时被 `cross-client-logic` 当场拦住的:
|
||||
* electron: THROW:pickBy is not defined
|
||||
* 这条判据的报错里那句"以 electron 为准(用户定的方向:electron 是唯一真实源泉)"
|
||||
* 正好说明为什么不能把函数只写在一侧。
|
||||
*/
|
||||
export function pickBy<T>(arr: T[], indexes: number[]): T[] {
|
||||
const out: T[] = [];
|
||||
for (const idx of indexes) {
|
||||
if (idx >= 0 && idx < arr.length) out.push(arr[idx]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前输入片段过滤一整套候选(保持四个数组同序)。
|
||||
*
|
||||
* 把 `filterIndexes` + 四次 `pickBy` 的样板收成一件 —— 三个调用点
|
||||
* (写信页 / 转发条 / 日历事件)逐字相同,但手写三遍就会有一处忘同步。
|
||||
*/
|
||||
export function filterSets(sets: CandidateSets, fragment: string): CandidateSets {
|
||||
const keep = filterIndexes(sets.items, sets.titles, fragment);
|
||||
return {
|
||||
items: pickBy(sets.items, keep),
|
||||
titles: pickBy(sets.titles, keep),
|
||||
sources: pickBy(sets.sources, keep),
|
||||
unreads: pickBy(sets.unreads, keep)
|
||||
};
|
||||
}
|
||||
|
||||
@ -327,7 +327,36 @@ const PAIRS = [
|
||||
['分号+逗号取更右的', 'splitEditing', ['a@x, b@y;c@z', true]],
|
||||
['连续分隔符', 'splitEditing', ['a@x,,b@y', true]],
|
||||
['末尾就是逗号', 'splitEditing', ['a@x,', true]],
|
||||
['空串', 'splitEditing', ['', true]]
|
||||
['空串', 'splitEditing', ['', true]],
|
||||
|
||||
/*
|
||||
* ── normalizeCandidates:`omitempty` 缺键守卫 ──
|
||||
*
|
||||
* 这是本仓撞过的真坑:`SessionCandidate.title/source/unread`
|
||||
* 都带 `omitempty` ⇒ 缺键时裸 cast 得到 `undefined`,
|
||||
* 而类里那个 `= ''` **不会生效**。曾因此抛 `TypeError`。
|
||||
*
|
||||
* 进用例表是为了让"鸿蒙写一份、electron 写一份"时
|
||||
* 两份守卫的边界**逐例一致**。
|
||||
*/
|
||||
['全正常', 'normalizeCandidates', [['a@x', 'b@y'], ['标题1', '标题2'], ['mail', 'platform'], [1, 0]]],
|
||||
['title 缺键', 'normalizeCandidates', [['a@x'], [undefined], [''], [0]]],
|
||||
['unread 缺键', 'normalizeCandidates', [['a@x'], [''], [''], [undefined]]],
|
||||
['candidates 比 suggestions 短', 'normalizeCandidates', [['a@x', 'b@y'], [''], [''], [0]]],
|
||||
['全空', 'normalizeCandidates', [[], [], [], []]],
|
||||
|
||||
/*
|
||||
* ── filterSets:同序过滤四个数组 ──
|
||||
*
|
||||
* 收掉 `filterIndexes` + 四次 `pickBy` 的样板。
|
||||
* 手写三遍就会有一处忘同步(四个数组长短不一就会错位)。
|
||||
*/
|
||||
['按片段过滤', 'filterSets',
|
||||
[{ items: ['a@x.n', 'b@y.m'], titles: ['', ''], sources: ['', ''], unreads: [0, 0] }, 'a']],
|
||||
['无匹配', 'filterSets',
|
||||
[{ items: ['a@x.n'], titles: [''], sources: [''], unreads: [0] }, 'zzz']],
|
||||
['空片段全保留', 'filterSets',
|
||||
[{ items: ['a@x', 'b@y'], titles: ['t1', 't2'], sources: ['mail', 'platform'], unreads: [1, 2] }, '']]
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@ -285,13 +285,32 @@ test('2in1 快捷键|候选标题读 title 时守住 omitempty 缺键', () =>
|
||||
* `m.to.trim()`,服务端 omit 时就 `Cannot read property trim of undefined`
|
||||
* ⇒ **整页白屏**。
|
||||
*
|
||||
* 改坏会红:把 `typeof raw === 'string' ? raw : ''` 换回 `cands[i].title`。
|
||||
* ★★ 2026-09-21 改:守卫从那三个字段的**内联读取**搬进了
|
||||
* `model/AddressSuggest.ts` 的 `normalizeCandidates`
|
||||
* (因为转发条、日历事件也要同一套,内联三份必然漂移)。
|
||||
*
|
||||
* 本判据原来钉的是**内联那行的字面文本**
|
||||
* (`/typeof raw === 'string' ? raw : ''/`)——
|
||||
* 那是判据的**实现细节**,不是它的**意图**。搬了家就假红。
|
||||
*
|
||||
* ⇒ 改钉两件真的事:
|
||||
* ① 守卫本身在(三个字段各一道);
|
||||
* ② **调用点真的走那道守卫**(而不是绕回去自己读 `.title`)。
|
||||
* 只钉①会被"守卫在库里、调用点旁路"绕过;
|
||||
* 只钉②会被"调用了却传未守卫的原始值"绕过。两件都要。
|
||||
*/
|
||||
assert.match(
|
||||
COMPOSE,
|
||||
/typeof raw === 'string' \? raw : ''/,
|
||||
'读 title 前必须守一道(omitempty 缺键时是 undefined,不是空串)'
|
||||
const MODEL = code(join(ETS, 'model/AddressSuggest.ts'));
|
||||
assert.match(MODEL, /typeof t === 'string' \? t : ''/, 'title 缺键要守成空串');
|
||||
assert.match(MODEL, /typeof s === 'string' \? s : ''/, 'source 缺键要守成空串');
|
||||
assert.match(MODEL, /typeof u === 'number' \? u : 0/, 'unread 缺键要守成 0');
|
||||
|
||||
/* 调用点必须经过 `normalizeCandidates`,不得自己读 `c.title` 当字符串用 */
|
||||
assert.match(COMPOSE, /normalizeCandidates\(/, '写信页要经共享归一化,不得内联重写一份守卫');
|
||||
assert.ok(
|
||||
!/\.suggestTitles\s*=\s*[^;]*\.title\b/.test(COMPOSE),
|
||||
'不得把候选的 title 直接赋给 suggestTitles(绕过守卫)'
|
||||
);
|
||||
|
||||
/* 而且模型里的字段名要跟服务端一致(服务端给的是 alias/title/source) */
|
||||
const M = code(join(ETS, 'model/Models.ets'));
|
||||
const cls = M.slice(M.indexOf('export class AddressSuggestion'));
|
||||
|
||||
@ -619,10 +619,19 @@ test('★ 出现/消失的那类元素真的挂了过渡(`if` 包的弹层不
|
||||
const at = src.indexOf(t.needle);
|
||||
assert.ok(at >= 0, `${t.file} 里找不到「${t.why}」的挂载条件(${t.needle})—— 改名字要一起改判据`);
|
||||
/*
|
||||
* 从挂载点往后扫到这一块的收尾(缩进回到同级),在这段里找 `.transition(`。
|
||||
* 扫 4000 字符足够:这些都是同一个 build 里的邻近修饰符链。
|
||||
* ★★ 2026-09-21 改:不再用"往后扫固定 N 字符",改成**按花括号配对找块尾**。
|
||||
*
|
||||
* 原来这里写的是 `src.slice(at, at + 4000)`。
|
||||
* 那个 4000 是个**拍脑袋的数字**,而且它会随时间失效:
|
||||
* 我在转发弹层里加了地址补全(约 2000 字符的候选列表)后,
|
||||
* `.transition(Theme.paneRiseIn())` 被推到距挂载点 **6107** 字符处
|
||||
* ⇒ 判据报「转发弹层没有挂过渡」——而它**明明是挂着的**。
|
||||
*
|
||||
* 这种假红比漏报更危险的副作用是:它会诱人去**加大那个数字**,
|
||||
* 而正确的修法是把它换成"这块到哪里结束"——即配对。
|
||||
* 数字每改一次就多一次"下次又不够"的机会。
|
||||
*/
|
||||
const window = src.slice(at, at + 4000);
|
||||
const window = braceBody(src, t.needle);
|
||||
assert.match(window, /\.transition\(Theme\.(paneRiseIn|menuIn)\(\)\)/,
|
||||
`${t.file} 的「${t.why}」没有挂过渡 —— 它会硬弹出来。` +
|
||||
'就地展开用 `paneRiseIn()`,浮层/下拉用 `menuIn()`(取值出处见 Theme 的注释)');
|
||||
|
||||
Reference in New Issue
Block a user