给 Transfer 补边界用例,我先把每个选择态手动点了一遍。点到第二次就出事了:先勾一项(表头进入半选),再点表头想「全选」——已勾的那一项被取消了,整张列表回到 0。
顺着这条线查下去,同一个组件里一共挖出三个选择态 bug,外加一个 ARIA 语义问题。全部修完、用例补到 14 个,全量 868 个测试通过,随
ruanno-ui@0.2.11发布。
三态复选框,只有两个动作
表头那个复选框看着简单,实际有三个状态、两个动作。任何一个分支写错,用户看到的现象都是「点了没反应」或者「选了又没了」。
| 当前状态 | 点击后应该发生什么 |
|---|---|
| 一个都没选 | 选中全部可选项目 |
| 选了一部分(半选) | 补齐剩余可选项目 |
| 已全选 | 取消全部选择 |
关键在于中间那行。很多人(包括我)会下意识把「有没有选中」当成判断依据,而正确的判断是「是不是全选」。
bug 1:只要选了任意一项,全选就变成全清
修复前的实现只有五行:
function toggleAll(side: "left" | "right") {
const items = side === "left" ? filteredLeftItems.value : filteredRightItems.value;
const list = side === "left" ? leftChecked : rightChecked;
if (list.value.length > 0) list.value = [];
else list.value = items.filter((i) => !i.disabled).map((i) => i.key);
}
list.value.length > 0 判断的是「有没有选中」。于是部分选中时走进了清空分支——用户以为在补全,实际在清空。
修复是把判断条件从「有没有」换成「是不是全部」:
const keys = items.filter((i) => !i.disabled).map((i) => i.key);
const allChecked = keys.length > 0 && keys.every((k) => list.value.includes(k));
list.value = allChecked
? list.value.filter((k) => !keys.includes(k))
: Array.from(new Set([...list.value, ...keys]));
改完之后三个状态各走各的分支。实测第一个示例(7 项,其中「系统设置」禁用):
初始 0/7,表头是空框:
勾「查看用户」,计数 1/7,表头变成横线(半选):
点表头,补到 6/7,表头变成勾:
为什么不是 7/7?因为第 7 项是禁用的。把左侧列表滚到底能看到它:
这张图顺便验证了「全选跳过禁用项」不是嘴上说说——计数 6/7、禁用项没勾、表头仍是全选态。
bug 2:分母把禁用项算进去了
修完 bug 1,我发现另一个数字不对:全选之后表头还是半选。
const leftIndeterminate = computed(
() => leftChecked.value.length > 0 && leftChecked.value.length < leftItems.value.length,
);
leftItems.length 是 7(含 1 个禁用),而全选只能选到 6。于是 6 < 7 永远成立,半选态永远不消失。
分母必须是「可参与选择的项目数」:
function isIndeterminate(items: TransferItem[], checked: TransferKey[]) {
const visible = items.filter((i) => !i.disabled);
const n = visible.filter((i) => checked.includes(i.key)).length;
return n > 0 && n < visible.length;
}
这个 bug 不报错、不崩溃,只是让表头永远少一档状态。靠读代码很难发现,只有把「全选」真的点下去、再看一眼图标才会暴露。
顺手量出来的一件事:count 不是「已移入数量」
写断言时我第一版写错了。把 6 项移到右侧后,我以为右侧应该显示 6/6,实际是 0/6:
0/6 里的 0 是「右侧勾选了几项」,不是「移入了几项」。移动完成后勾选被清空,所以是 0。计数器的语义是 勾选数/可见数,跟数据在哪一侧无关。
想看到 6/6,得再点一次右侧表头:
再点左箭头移回去,两侧勾选都复位:
这三个数字后来全部进了测试。如果我按「以为是 6/6」的假设写断言,测试第一次跑就会红——这算是断言帮我纠正了理解,而不是代码有问题。
bug 3:搜索 + 全选,看不见的已选项被丢掉
前两个修完,我以为结束了。直到给「搜索 + 全选」补用例:
先勾「查看用户」,再在搜索框输入「订单」(过滤后只剩 2 项),点表头全选。修复前的实现是:
list.value = selectable.map((i) => i.key); // 整体替换
它把「当前可见项」当成新的全部选中集合——之前勾的「查看用户」不在可见项里,被一起丢掉了。用户看到的是:搜索一下、点个全选,之前选的东西没了。
改成增量更新:
list.value = allChecked
? list.value.filter((k) => !keys.includes(k)) // 只移除可见项
: Array.from(new Set([...list.value, ...keys])); // 取并集
实测:已勾 1 项,搜索「订单」后可见 2 项,点表头,计数变成 3/7(1 + 2,而不是只剩 2):
清空搜索,可见 6 项里有 3 项已选,表头回到半选:
再搜索「订单」,这两项都已选中,表头是全选;此时点一下,只取消这 2 项:
这一步还暴露了「半选」的判定范围问题:它必须按当前可见项算。否则隐藏项会把表头顶成「全选」或「半选」,而用户眼前的列表明明是另一种状态。
顺带修的:aria-checked 不是布尔值
改到这里我去核对表头的 ARIA 属性,发现两处问题。
第一,aria-checked 写的是 leftChecked.length > 0:
<span role="checkbox" :aria-checked="leftChecked.length > 0">
半选时它输出 true。读屏用户听到「已选中」,但列表里明明只选了一部分。ARIA 的 checkbox 支持三个值:true / false / mixed,半选就该用 mixed。
第二,这个 span 有 role="checkbox" 却没有 tabindex,也没有键盘处理——读屏说它是复选框,键盘用户却永远碰不到它。这和之前 Cell / Grid 的问题一模一样:给了语义,没给入口。
两处一起修:
<span
role="checkbox"
tabindex="0"
:aria-checked="leftIndeterminate ? 'mixed' : leftAllSelected"
@click="toggleAll('left')"
@keydown.enter.prevent="toggleAll('left')"
@keydown.space.prevent="toggleAll('left')"
>
实测键盘路径:聚焦表头按 Enter,计数从 1/7 变成 6/7,aria-checked 从 mixed 变成 true:
注意 aria-checked 和可见状态现在都由同一组计算属性派生(leftIndeterminate / leftAllSelected),不会再出现「视觉是半选、读屏说全选」的分叉。
测试里三个容易写错的断言
这次补的 8 个用例,几乎每个都踩过一次坑,挑三个说。
一、mount 的 props 是静态的,不要断言 DOM 回写。
moveTo 只发 update:modelValue,父级没有真的更新 props。所以「移到右侧后右侧有几项」在单测里不会变。断言应该打在事件载荷上:
expect(wrapper.emitted("update:modelValue")?.[0]).toEqual([[1]]);
二、trigger("keydown") 不触发浏览器默认行为。
它能证明「按键处理器被调用了」,不能证明「真实浏览器里 Enter 真的能激活」。后者只能靠真机(这次就是靠 ego 真浏览器按 Enter 验的)。
三、计数的分母是「可见项总数」,包含禁用项。
全选后是 3/4,不是 3/3——第 4 项禁用,不参与勾选,但仍算在「共有几项」里。第一次写 3/3 的断言直接红了。
结果
Transfer 的用例从 6 个补到 14 个:
| 用例 | 钉住的行为 |
|---|---|
| 部分选中时表头显示半选 | 三态的视觉前提 |
| 半选点表头补齐 | bug 1 |
| 全选跳过 disabled | bug 2 的前提 |
| 移动后勾选复位 | count 语义 |
| aria-checked 输出 mixed | ARIA 三态 |
| 表头 tabindex + Enter | 键盘入口 |
| 过滤后全选取并集 | bug 3 |
| 过滤后取消只移除可见项 | bug 3 的另一半 |
全量 868 个用例、104 个文件通过,ruanno-ui@0.2.11 已发布,文档站同步更新。Playground 里可以自己拖着参数试:
同一个逻辑在第二个示例(数字 key + 禁用成员)里表现一致,说明修复没有依赖 key 的类型:
结论
三个 bug 有一个共同点:都不是「算错了」,而是「判断依据选错了」。
- bug 1 用「有没有选中」代替「是不是全选」;
- bug 2 用「全部项目数」代替「可选项目数」;
- bug 3 用「可见项」代替「已选项」。
三处代码都能跑、都不报错,只在特定操作顺序下表现成「选了又没了」。这类问题读过一遍源码也未必看得出,必须真的按用户的操作顺序点一遍——先勾一项,再全选;先勾一项,再搜索,再全选。
真机验证的顺序也一样:先断言 DOM(计数、aria-checked、禁用项是否被勾),再截图。截图好看但数字不对的例子,前面已经吃过一次亏了。