Skip to content

Commit 2b42623

Browse files
committed
fix(shell): 修 T12 评审 I-1/I-2 与 M-1/M-2/M-4/M-5
1 parent 9bcb472 commit 2b42623

6 files changed

Lines changed: 255 additions & 44 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* @ai-context ⌘K 面板的 **T12 独立评审 follow-up 判据**(I-1 合并语义 + I-2 如实计数)。
4+
*
5+
* Why 单独立文件:`CommandPalette.kb.test.tsx`(T12 交付的接线守卫)**182 行 / 计划上限 200**,
6+
* 这几条判据塞不下;且它们判的是**评审之后才裁决的语义**(控制方 2026-09-12 裁决 B),与 T12
7+
* 交付时的 ①–⑤ 分开记更好归因。⇒ T12 与 T11 的两个既有测试文件**零改动**(既有断言不许改)。
8+
*
9+
* 判据(每条各自有变异体,实测见 `task-t12-followups-report.md` §变异体):
10+
* ⑥ I-1:**有命中时页面命令恒在**(输入只命中学习库、不命中页面名的词 ⇒ 9 条页面命令一条不少);
11+
* ⑦ I-2:命中里**无跳转目标**的条数如实说出来,**不谎报**「没有匹配的命令」;
12+
* ⑧ 回归护栏:**零命中且不匹配页面名**时**空态仍出现**(T11 `CommandPalette.test.tsx:103` 的语义,
13+
* 裁决 B 不许把它顺手破坏)。
14+
*/
15+
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
16+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
17+
import type { KbHit } from "../types";
18+
19+
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() }));
20+
vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock }));
21+
22+
import { CommandPalette } from "./CommandPalette";
23+
import { KB_SEARCH_DEBOUNCE_MS } from "./kbCommands";
24+
import { ALL_ENTRIES } from "./navRegistry";
25+
26+
const noteHit: KbHit = {
27+
chunkId: 7, sourceKind: "note", noteId: 42, fragmentId: null, noteTitle: "眼影入门",
28+
groupName: "化妆课", heading: "晕染手法", snippet: "先取粉再==少量多次==地上色", scoreKind: "fts",
29+
};
30+
31+
/** 推进防抖窗 + 冲洗 IPC 的 promise 链(两个 `act`:先跑定时器,再等微任务) */
32+
async function afterDebounce() {
33+
await act(async () => { vi.advanceTimersByTime(KB_SEARCH_DEBOUNCE_MS); });
34+
await act(async () => { await Promise.resolve(); });
35+
}
36+
37+
beforeEach(() => {
38+
vi.useFakeTimers();
39+
invokeMock.mockReset();
40+
invokeMock.mockResolvedValue([]); // 默认:零命中(阴性基线)
41+
});
42+
afterEach(() => {
43+
cleanup();
44+
vi.useRealTimers();
45+
});
46+
47+
describe("⑥ I-1:有命中时页面命令恒在(导航入口不得因检索而消失)", () => {
48+
it("查询「眼影」命中一条笔记 ⇒ 9 条页面命令全部仍在列表里", async () => {
49+
invokeMock.mockResolvedValueOnce([noteHit]);
50+
render(<CommandPalette open onClose={vi.fn()} onPick={vi.fn()} />);
51+
fireEvent.change(screen.getByTestId("command-palette-input"), { target: { value: "眼影" } });
52+
await afterDebounce();
53+
expect(screen.getByTestId("command-hit:7"), "前提不成立:这次查询没有命中").toBeTruthy();
54+
const survived = ALL_ENTRIES.filter((e) => screen.queryByTestId(`command-page:${e.key}`) !== null).map((e) => e.key);
55+
expect(survived, "有命中时页面命令被查询词过滤掉了(9 条导航入口集体消失)").toEqual(ALL_ENTRIES.map((e) => e.key));
56+
});
57+
});
58+
59+
describe("⑦ I-2:无跳转目标的命中如实说明(不许谎报「没有匹配的命令」)", () => {
60+
it("只有 fragment 命中 ⇒ 给出被跳过的条数、且不出现空态文案", async () => {
61+
// fragment = 无跳转页(`utils/kbHits.isNoteHit` 的既有口径,与 `CitationChips` 同款:不显示、不回调)
62+
invokeMock.mockResolvedValueOnce([{ ...noteHit, chunkId: 9, sourceKind: "fragment", fragmentId: 3, noteId: null }]);
63+
render(<CommandPalette open onClose={vi.fn()} onPick={vi.fn()} />);
64+
fireEvent.change(screen.getByTestId("command-palette-input"), { target: { value: "素材词" } });
65+
await afterDebounce();
66+
expect(screen.queryByTestId("command-palette-empty"), "命中被静默丢弃后仍谎报「没有匹配的命令」").toBeNull();
67+
expect(screen.getByTestId("command-palette-skipped").textContent).toContain("另有 1 条无定位信息的命中");
68+
});
69+
70+
it("有可跳命令时也如实报出条数,且不谎称「没有可打开的命令」", async () => {
71+
invokeMock.mockResolvedValueOnce([noteHit, { ...noteHit, chunkId: 9, sourceKind: "fragment", fragmentId: 3, noteId: null }]);
72+
render(<CommandPalette open onClose={vi.fn()} onPick={vi.fn()} />);
73+
fireEvent.change(screen.getByTestId("command-palette-input"), { target: { value: "素材词" } });
74+
await afterDebounce();
75+
const note = screen.getByTestId("command-palette-skipped").textContent ?? "";
76+
expect(screen.getByTestId("command-hit:7"), "前提不成立:可跳的那条没上屏").toBeTruthy();
77+
expect(note, "列表里明明有可打开的命令,却报了「没有可打开的命令」").not.toContain("没有可打开的命令");
78+
expect(note).toContain("另有 1 条无定位信息的命中");
79+
});
80+
});
81+
82+
describe("⑧ 回归护栏:零命中且不匹配页面名 ⇒ 空态仍出现(T11 :103 的语义)", () => {
83+
it("查询「zzz-不存在的命令」零命中 ⇒ 空态在(检索已返回后判,不是「还没查」的假绿)", async () => {
84+
render(<CommandPalette open onClose={vi.fn()} onPick={vi.fn()} />);
85+
fireEvent.change(screen.getByTestId("command-palette-input"), { target: { value: "zzz-不存在的命令" } });
86+
await afterDebounce();
87+
expect(invokeMock, "检索还没返回 ⇒ 本判据测不到「零命中」那一支").toHaveBeenCalledTimes(1);
88+
expect(screen.getByTestId("command-palette-empty")).toBeTruthy();
89+
});
90+
});

‎app/src/shell/CommandPalette.tsx‎

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@
2020
* 副作用:`open` 为真时在 `window` 上挂一个 keydown(只处理 Esc),关闭或卸载即解绑;
2121
* 输入框 `autoFocus` 会搬走焦点、关闭时**不还原**(与今日 28 个手写弹层一致 —— 焦点归还是批 4 的
2222
* `Modal` 焦点管理范围)。
23-
* 边界:① **不做模糊搜索排序**(只对本地面板命令做大小写不敏感的子串过滤;T12 的检索结果由后端给序);
23+
* 边界:① **合并语义**(控制方 I-1 裁决 B,逐字口径):「**有命中时页面命令恒在;零命中时按查询词
24+
* 过滤并显示空态**」—— 页面命令只在**零命中**那一支才把查询词交给 `filterCommands`(有命中时它们
25+
* 不参与过滤,导航入口不会因检索而消失;此前那句「页面命令恒在最前」不含过滤语义,属不实承诺);
26+
* 检索结果由后端给序、恒追在页面命令之后;
2427
* ② **不做动效**(批 6);③ 不认识任何 `focus*` 深链参数(T12 才收敛入口);
2528
* ④ 层级不写裸数字 —— 走 `zIndex("modal")` 六档标尺;
2629
* ⑤ 组合态的 Esc(「只取消候选词」)归批 4 的 IME 原语,本文件只保证 **Enter 不误提交**。
@@ -68,14 +71,14 @@ function step(index: number, delta: number, size: number): number {
6871
return (Math.min(index, size - 1) + delta + size) % size;
6972
}
7073

71-
/** 本地子串过滤(大小写不敏感)。空查询 = 全量 —— 「没输入时也看得到全部页面命令」是本面板的默认形态 */
74+
/** 本地子串过滤(大小写不敏感)。空查询 = 全量 —— I-1 裁决 B 下「有命中」那一支正是走空查询(不过滤) */
7275
function filterCommands(commands: readonly Command[], query: string): readonly Command[] {
7376
const q = query.trim().toLowerCase();
7477
if (!q) return commands;
7578
return commands.filter((c) => c.label.toLowerCase().includes(q));
7679
}
7780

78-
/** 装配命令列表:注册表的 9 页 + 面板自带的对话面板入口(页面命令恒在最前,T12 的结果追在其后) */
81+
/** 装配命令列表:注册表的 9 页 + 面板自带的对话面板入口(T12 的检索结果恒追在这一组之后) */
7982
function buildCommands(onPick: (pick: PalettePick) => void): Command[] {
8083
const pages: Command[] = ALL_ENTRIES.map((e) => ({
8184
id: `page:${e.key}`,
@@ -121,9 +124,10 @@ export function CommandPalette({ open, onClose, onPick }: CommandPaletteProps) {
121124

122125
// 命令表随渲染重建(11 条,成本可忽略):`run` 闭包因此恒为最新,不需要为依赖稳定性再包一层 memo
123126
// T12:**检索结果**(`kb_search` 数据源)——取样(180ms 防抖 + seq 只认最后一次 + 失败降级)
124-
// 整段在 `useKbPaletteSearch` 里,本组件只把结果与页面命令合并展示(页面命令恒在最前)。
125-
const { commands: hits, degraded } = useKbPaletteSearch(open, query, (jump) => onPick({ kind: "hit", jump }));
126-
const list = [...filterCommands(buildCommands(onPick), query), ...hits];
127+
// 整段在 `useKbPaletteSearch` 里。**合并语义**(I-1 裁决 B):「有命中时页面命令恒在;零命中时按
128+
// 查询词过滤并显示空态」⇒ 只有零命中那一支才把查询词传进 `filterCommands`(空串 = 全量不过滤)。
129+
const { commands: hits, degraded, skipped } = useKbPaletteSearch(open, query, (jump) => onPick({ kind: "hit", jump }));
130+
const list = [...filterCommands(buildCommands(onPick), hits.length ? "" : query), ...hits];
127131
const activeAt = list.length === 0 ? -1 : Math.min(active, list.length - 1);
128132

129133
if (!open) return null;
@@ -179,6 +183,15 @@ export function CommandPalette({ open, onClose, onPick }: CommandPaletteProps) {
179183
学习库检索不可用——只显示页面命令
180184
</div>
181185
) : null}
186+
{/* I-2:命中里**没有跳转目标**的条数要如实说出来 —— 旧版把它们静默丢掉后显示
187+
「没有匹配的命令」是**假陈述**(学习库确实有匹配,只是没有可打开的入口) */}
188+
{skipped > 0 ? (
189+
<div className="ed-cmdk__note" data-testid="command-palette-skipped">
190+
{list.length === 0
191+
? `没有可打开的命令(另有 ${skipped} 条无定位信息的命中)`
192+
: `另有 ${skipped} 条无定位信息的命中(无跳转目标)`}
193+
</div>
194+
) : null}
182195
<ul className="ed-cmdk__list" role="listbox" aria-label="命令列表" data-testid="command-palette-list">
183196
{list.map((cmd, i) => (
184197
<li
@@ -194,7 +207,7 @@ export function CommandPalette({ open, onClose, onPick }: CommandPaletteProps) {
194207
{cmd.hint ? <span className="ed-cmdk__hint">{cmd.hint}</span> : null}
195208
</li>
196209
))}
197-
{list.length === 0 ? (
210+
{list.length === 0 && skipped === 0 ? (
198211
<li className="ed-cmdk__empty" data-testid="command-palette-empty">
199212
没有匹配的命令
200213
</li>

‎app/src/shell/kbCommands.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* ① 形状:`KbHit` → `Command`(id 用 chunkId 定身份、label 复用全站 `hitLabel`、hint 标「命中」);
99
* ② 降级:空白串**不发 IPC** · IPC 抛错 ⇒ 空列表**且不抛**(`degraded` 标记上抛,不吞错——有 warn);
1010
* ③ 阴性样本:脏命中逐条跳过(null / 缺 chunkId / 类型错 / 碎片命中 / 笔记缺 noteId);
11-
* ④ 入参:limit 夹到 [1, 50](Rust 侧同口径),`searchCommands` 的计划签名可用。
11+
* ④ 入参:limit 夹到 [1, 50](上界与 Rust 同值 50;下界 1 是**前端自夹** —— M-4 的事实口径),`searchCommands` 的计划签名可用。
1212
*/
1313
import { readFileSync } from "node:fs";
1414
import { dirname, join } from "node:path";
@@ -124,7 +124,7 @@ describe("② 防御性降级(AGENTS.md §3.4)", () => {
124124
});
125125

126126
describe("④ 入参与计划签名", () => {
127-
it("limit 夹到 [1, 50](Rust 侧 clamp 同口径;NaN/负数/超大都不越界)", async () => {
127+
it("limit 夹到 [1, 50](上界与 Rust 同值;下界 1 由前端自夹;NaN/负数/超大都不越界)", async () => {
128128
invokeMock.mockResolvedValue([]);
129129
await kbSearchCommands("a", 999);
130130
await kbSearchCommands("a", 0);
@@ -136,6 +136,9 @@ describe("④ 入参与计划签名", () => {
136136
1,
137137
KB_SEARCH_DEFAULT_LIMIT,
138138
]);
139+
// M-1 锚死:上面两行右侧是**本模块自己的常量**(自引用 ⇒ 改常量照绿)——真值取自 Rust `kb_search.rs:23-24`
140+
expect(KB_SEARCH_MAX_LIMIT).toBe(50);
141+
expect(KB_SEARCH_DEFAULT_LIMIT).toBe(10);
139142
});
140143

141144
it("searchCommands(计划 Interfaces 的逐字签名)返回命令数组,空白串短路", async () => {

‎app/src/shell/kbCommands.ts‎

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,15 @@
33
* 「**⌘K 的数据源**(ADR-029 RAG 层)」;批 3 Task 12)。
44
*
55
* Why 单独立文件:把「IPC 结果 → 命令列表」做成**纯函数**,就能在没有 Tauri 运行时的 vitest 环境里测
6-
* (本仓 vitest 全局 `environment: "node"`,`invoke` 不可用)。组件只负责调用与渲染,不负责数据形状
7-
* —— 这样 T12 的测试面是 100%,而不是 0%。
6+
* (本仓 vitest 全局 `environment: "node"`,`invoke` 不可用)⇒ T12 的测试面是 100%,而不是 0%。
87
*
98
* 契约**逐字取自 Rust**(`app/src-tauri/src/commands_kb.rs:36-60`,不凭记忆写):
109
* `pub fn kb_search(state: State<'_, AppState>, query: String, limit: Option<usize>) -> Result<Vec<KbHit>, String>`
1110
* · 入参 `query: String` / `limit: Option<usize>`(JS 侧键名同名:两个参数都无下划线,不受 camelCase 重命名影响);
1211
* · 返回 `Vec<KbHit>`,`KbHit` 带 `#[serde(rename_all = "camelCase")]` ⇒ 前端 `KbHit`(`app/src/types/chat.ts:40`)逐字段同形;
1312
* · 错误是 `String`(`Result<_, String>`)⇒ IPC 拒绝值即可读中文串,不是结构化错误体;
1413
* · Rust 侧行为:空白 query ⇒ `Ok(vec![])`(不报错)· `query.chars().count() > 500` ⇒ `Err("查询过长(≤500 字符)")`
15-
* · `limit` 缺省 `KB_SEARCH_DEFAULT_LIMIT = 10` 且 `.min(KB_SEARCH_MAX_LIMIT = 50)`。
14+
* · `limit` 缺省 10 且 `.min(50)`(命令层**只夹上界**;下界 1 在引擎层 `kb_search.rs:107` 的 `.clamp(1, 50)`)。
1615
* ⚠️ 本任务**不增删命令**(`kb_search` 早已注册,见 `check-command-registry.mjs` 的 312/312)。
1716
*
1817
* 防御性(AGENTS.md §3.4:系统调用必须有超时/重试/降级):
@@ -21,9 +20,8 @@
2120
* `degraded` 标记上抛给面板显示灰字提示(**不是空 catch**:`console.warn` 带命令名与原因);
2221
* · 命中缺字段 / 形状不对 / 无跳转目标 ⇒ **跳过该条**(不让一条脏数据毁掉整个列表)。
2322
*
24-
* 边界:本模块**不 import 任何 React**(纯逻辑,V3 判据;也因此可脱离 Tauri 运行时单测);
25-
* 不做排序(顺序由后端给)· 不做中文分词(Rust 侧 `kb_fts.rs` 已把中文规划成 trigram)·
26-
* 不碰任何 `focus*` 状态(只产出「跳转意图」,落到状态机是 `App.tsx` 的事)。
23+
* 边界:本模块**不 import 任何 React**(纯逻辑;也因此可脱离 Tauri 运行时单测)· 不做排序(顺序由后端给)
24+
* · 不做中文分词(Rust 侧 `kb_fts.rs` 已把中文规划成 trigram)· 不碰 `focus*` 状态(落状态机是 `App.tsx` 的事)。
2725
*/
2826
import { invoke } from "@tauri-apps/api/core";
2927
import type { KbHit } from "../types";
@@ -32,7 +30,12 @@ import type { Command } from "./CommandPalette";
3230

3331
/** Rust `KB_SEARCH_DEFAULT_LIMIT` 的前端镜像(两端同值:默认取 10 条命中) */
3432
export const KB_SEARCH_DEFAULT_LIMIT = 10;
35-
/** Rust `KB_SEARCH_MAX_LIMIT` 的前端镜像(命令层已 clamp,这里再夹一次防越界入参) */
33+
/**
34+
* Rust `KB_SEARCH_MAX_LIMIT` 的前端镜像(**上界**同值 50)。⚠️ 口径**不是**「两端同一处 clamp」(M-4):
35+
* Rust **命令层**只夹上界(`commands_kb.rs:57` = `limit.unwrap_or(10).min(50)`,`Some(0)` 原样透传)——
36+
* 下界 1 要到**引擎层** `kb_search.rs:107` 的 `.clamp(1, KB_SEARCH_MAX_LIMIT)` 才兜住 ⇒ 前端在命令层
37+
* 就自夹 `[1, 50]`(`kbSearchHits`);**别照这条注释去 Rust 命令层"对齐"下界**(那边本来就没有)。
38+
*/
3639
export const KB_SEARCH_MAX_LIMIT = 50;
3740

3841
/**
@@ -85,11 +88,11 @@ export function commandsFromHits(hits: readonly KbHit[], onJump?: HitJumpHandler
8588
return out;
8689
}
8790

91+
/** 一次 `kb_search` 的**原始命中** + 降级标记(⌘K 的 hook 要数「命中里被跳过的条数」⇒ 命中层单独开口) */
92+
export interface KbHitsOutcome { hits: KbHit[]; degraded: boolean }
93+
8894
/** 一次 `kb_search` 的结果 + 是否走了降级路径(面板据此显示灰字提示;「没命中」**不算**降级) */
89-
export interface KbSearchOutcome {
90-
commands: Command[];
91-
degraded: boolean;
92-
}
95+
export interface KbSearchOutcome { commands: Command[]; degraded: boolean }
9396

9497
/** IPC 拒绝值的可读化(错误是 Rust 的 `String`,但也可能被运行时包成 Error——两种都给出信息量) */
9598
function describeError(e: unknown): string {
@@ -99,28 +102,38 @@ function describeError(e: unknown): string {
99102
}
100103

101104
/**
102-
* 检索 → 结果命令(带降级标记;面板用的是这个版本 —— 「空结果」与「检索失败」必须可区分)。
103-
* 计划 `Interfaces` 的 `searchCommands` 见下方薄封装。
105+
* 检索 → **原始命中**(带降级标记)。⌘K 的 hook 走这条:除了「命中 → 命令」,它还要算
106+
* **被跳过的条数**(I-2:无跳转目标的命中必须在 UI 上如实说出来,不许静默丢弃后谎报「没有匹配」)。
107+
* 「空结果」与「检索失败」必须可区分 ⇒ 失败时 `degraded = true` 且**不抛**。
104108
*/
105-
export async function kbSearchCommands(
106-
q: string,
107-
limit: number = KB_SEARCH_DEFAULT_LIMIT,
108-
onJump?: HitJumpHandler,
109-
): Promise<KbSearchOutcome> {
109+
export async function kbSearchHits(q: string, limit: number = KB_SEARCH_DEFAULT_LIMIT): Promise<KbHitsOutcome> {
110110
const query = q.trim();
111111
// 空白串不发 IPC(省一次往返;Rust 侧同样返回空)——也顺带让「只输空格」不进降级态
112-
if (!query) return { commands: [], degraded: false };
112+
if (!query) return { hits: [], degraded: false };
113113
const n = Number.isFinite(limit) ? Math.min(Math.max(Math.trunc(limit), 1), KB_SEARCH_MAX_LIMIT) : KB_SEARCH_DEFAULT_LIMIT;
114114
try {
115115
const hits = await invoke<KbHit[]>("kb_search", { query, limit: n });
116116
// 后端契约是数组;真收到别的形状(版本漂移/代理层包装)按空结果处理,不猜结构
117-
return { commands: commandsFromHits(Array.isArray(hits) ? hits : [], onJump), degraded: false };
117+
return { hits: Array.isArray(hits) ? hits : [], degraded: false };
118118
} catch (e) {
119119
console.warn("[kbCommands] kb_search 失败——⌘K 降级为仅页面命令:", describeError(e));
120-
return { commands: [], degraded: true };
120+
return { hits: [], degraded: true };
121121
}
122122
}
123123

124+
/**
125+
* 检索 → 结果命令(带降级标记;「空结果」与「检索失败」必须可区分)。
126+
* 计划 `Interfaces` 的 `searchCommands` 见下方薄封装。
127+
*/
128+
export async function kbSearchCommands(
129+
q: string,
130+
limit: number = KB_SEARCH_DEFAULT_LIMIT,
131+
onJump?: HitJumpHandler,
132+
): Promise<KbSearchOutcome> {
133+
const { hits, degraded } = await kbSearchHits(q, limit);
134+
return { commands: commandsFromHits(hits, onJump), degraded };
135+
}
136+
124137
/** 计划 `Interfaces` 的逐字签名 `searchCommands(q, limit?)`:只要结果命令、不要降级标记时用它 */
125138
export async function searchCommands(q: string, limit?: number, onJump?: HitJumpHandler): Promise<Command[]> {
126139
return (await kbSearchCommands(q, limit ?? KB_SEARCH_DEFAULT_LIMIT, onJump)).commands;

0 commit comments

Comments
 (0)