|
| 1 | +// @vitest-environment jsdom |
| 2 | +/** |
| 3 | + * useNotesDeepLink.test.ts — 批 5 T16(裁决 C6)的判据集:`focus*` 的**消费复位**契约。 |
| 4 | + * |
| 5 | + * @ai-context: C6 只做**最小收敛**——给 5 个粘滞字段(`focusSessionId` / `focusNoteId` / |
| 6 | + * `focusNoteSearch` / `focusSystemId` / `focusGroupId`)补 `onFocus*Consumed` |
| 7 | + * 复位,**声明形态与类型一字不动**(`CommandPalette.kb.test.tsx:173-181` 继续绿)。 |
| 8 | + * 本文件判两件事: |
| 9 | + * ① **行为**(hook 级,注入假 `notesApi`):消费 ⇒ 回调恰一次 ⇒ App 置 null ⇒ |
| 10 | + * **同目标再次跳转仍触发**(旧形态下固定值不产生 prop 变化 ⇒ effect 不重跑 |
| 11 | + * ⇒ 深链静默失效——这是最小收敛修掉的真实缺陷); |
| 12 | + * ② **接线**(静态,读源码文本):App 的三组复位 + 三页的消费点。**仪器局限**: |
| 13 | + * 静态判据只能证明「写了」,运行期行为只有真机给证据 —— 与 |
| 14 | + * `CommandPalette.kb.test.tsx` ⑤ 同款声明,口径也照它(读前剥块注释与整行 `//`)。 |
| 15 | + */ |
| 16 | +import { readFileSync } from "node:fs"; |
| 17 | +import { dirname, join } from "node:path"; |
| 18 | +import { fileURLToPath } from "node:url"; |
| 19 | +import { useState } from "react"; |
| 20 | +import { act, cleanup, renderHook } from "@testing-library/react"; |
| 21 | +import { afterEach, describe, expect, it, vi } from "vitest"; |
| 22 | +import type { Note } from "../types"; |
| 23 | +import type { NotesListData } from "./useNotesListData"; |
| 24 | +import { useNotesDeepLink } from "./useNotesDeepLink"; |
| 25 | + |
| 26 | +const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() })); |
| 27 | +vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })); |
| 28 | + |
| 29 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 30 | +/** 读源码的**只留代码**口径(与 `CommandPalette.kb.test.tsx` 的 APP_CODE 逐字同款) */ |
| 31 | +const codeOf = (rel: string) => |
| 32 | + readFileSync(join(HERE, "..", rel), "utf8").replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); |
| 33 | + |
| 34 | +const NOTE_42: Note = { |
| 35 | + id: 42, title: "眼影入门", content: "少量多次", source: "manual", tags: "[]", pin: 0, |
| 36 | + created_at: 1, updated_at: 2, |
| 37 | +}; |
| 38 | + |
| 39 | +/** 深链只碰 `notesApi` 的 5 个成员(setKeyword/setTagFilter/setNotes/setStatus/seqRef)⇒ 注入最小假体 */ |
| 40 | +function makeNotesApi() { |
| 41 | + const seqRef = { current: 0 }; |
| 42 | + const calls = { |
| 43 | + keyword: [] as string[], tagFilter: [] as (number | null)[], notes: [] as Note[][], status: [] as string[], |
| 44 | + }; |
| 45 | + const api = { |
| 46 | + setKeyword: (v: string) => calls.keyword.push(v), |
| 47 | + setTagFilter: (v: number | null) => calls.tagFilter.push(v), |
| 48 | + setNotes: (rows: Note[]) => calls.notes.push(rows), |
| 49 | + setStatus: (s: string) => calls.status.push(s), |
| 50 | + seqRef, |
| 51 | + } as unknown as NotesListData; |
| 52 | + return { api, calls }; |
| 53 | +} |
| 54 | + |
| 55 | +const noop = () => {}; |
| 56 | +/** 冲干 `invoke` 的 await 链(mock 立即 resolve;深链体内是 async IIFE ⇒ 多轮微任务) */ |
| 57 | +const settle = () => act(async () => { await new Promise((r) => setTimeout(r, 0)); }); |
| 58 | + |
| 59 | +afterEach(() => { |
| 60 | + cleanup(); |
| 61 | + invokeMock.mockReset(); |
| 62 | + invokeMock.mockResolvedValue([]); |
| 63 | +}); |
| 64 | + |
| 65 | +describe("① 消费复位的行为契约(K2/K3;注入假 notesApi)", () => { |
| 66 | + it("K2 消费后归零:focusNoteId 有值 ⇒ 深链消费后 onConsumed **恰调用 1 次**(不是 0、不是 2)", async () => { |
| 67 | + const { api, calls } = makeNotesApi(); |
| 68 | + const consumed = vi.fn(); |
| 69 | + invokeMock.mockResolvedValue([NOTE_42]); |
| 70 | + renderHook(() => useNotesDeepLink({ |
| 71 | + focusNoteId: 42, notesApi: api, setSelected: vi.fn(), setEditing: noop, setView: noop, |
| 72 | + setGroupFilter: noop, onConsumed: consumed, |
| 73 | + })); |
| 74 | + await settle(); |
| 75 | + expect(consumed, "消费后没有回调 App 复位(字段粘滞)").toHaveBeenCalledTimes(1); |
| 76 | + // 同一轮里深链该做的事一件不少:清搜索/标签态 + 全量重载 + 注入列表 |
| 77 | + expect(calls.keyword).toEqual([""]); |
| 78 | + expect(calls.tagFilter).toEqual([null]); |
| 79 | + expect(calls.notes).toEqual([[NOTE_42]]); |
| 80 | + }); |
| 81 | + |
| 82 | + it("K3 连续两次跳**同一**笔记都能触发(旧形态:固定值粘滞 ⇒ 第二次静默失效)", async () => { |
| 83 | + const { api } = makeNotesApi(); |
| 84 | + const setSelected = vi.fn(); |
| 85 | + invokeMock.mockResolvedValue([NOTE_42]); |
| 86 | + const { result } = renderHook(() => { |
| 87 | + const [focusNoteId, setFocusNoteId] = useState<number | null>(null); |
| 88 | + useNotesDeepLink({ |
| 89 | + focusNoteId, notesApi: api, setSelected, setEditing: noop, setView: noop, setGroupFilter: noop, |
| 90 | + onConsumed: () => setFocusNoteId(null), // App 侧同款:消费即复位 |
| 91 | + }); |
| 92 | + return { focusNoteId, jump: setFocusNoteId }; |
| 93 | + }); |
| 94 | + await act(async () => { result.current.jump(42); await new Promise((r) => setTimeout(r, 0)); }); |
| 95 | + expect(result.current.focusNoteId, "K2:消费后字段未归零").toBeNull(); |
| 96 | + expect(setSelected).toHaveBeenCalledTimes(1); |
| 97 | + await act(async () => { result.current.jump(42); await new Promise((r) => setTimeout(r, 0)); }); |
| 98 | + expect(setSelected, "K3:同目标第二次跳转没有重新消费").toHaveBeenCalledTimes(2); |
| 99 | + expect(setSelected).toHaveBeenLastCalledWith(NOTE_42); |
| 100 | + }); |
| 101 | + |
| 102 | + it("K2 focusGroupId 走**同一个**复位回调:消费后字段归零,而组过滤态留在本页(复位不撤销过滤)", () => { |
| 103 | + const { api } = makeNotesApi(); |
| 104 | + const consumed = vi.fn(); |
| 105 | + const setGroupFilter = vi.fn(); |
| 106 | + const { rerender } = renderHook( |
| 107 | + ({ gid }: { gid: number | null }) => useNotesDeepLink({ |
| 108 | + focusGroupId: gid, notesApi: api, setSelected: noop, setEditing: noop, setView: noop, |
| 109 | + setGroupFilter, onConsumed: consumed, |
| 110 | + }), |
| 111 | + { initialProps: { gid: 7 as number | null } }, |
| 112 | + ); |
| 113 | + expect(setGroupFilter).toHaveBeenCalledWith(7); |
| 114 | + expect(consumed).toHaveBeenCalledTimes(1); |
| 115 | + rerender({ gid: null }); // App 侧复位 |
| 116 | + expect(setGroupFilter, "复位把本页已应用的组过滤也撤销了").toHaveBeenCalledTimes(1); |
| 117 | + rerender({ gid: 7 }); // 同组再次跳转 |
| 118 | + expect(consumed, "同组第二次跳转没有重新消费").toHaveBeenCalledTimes(2); |
| 119 | + }); |
| 120 | + |
| 121 | + it("加载失败**同样**复位:否则陈旧值卡死同目标重试(同值 setState 无 prop 变化 ⇒ effect 不重跑)", async () => { |
| 122 | + const { api, calls } = makeNotesApi(); |
| 123 | + const consumed = vi.fn(); |
| 124 | + invokeMock.mockRejectedValue(new Error("db locked")); |
| 125 | + renderHook(() => useNotesDeepLink({ |
| 126 | + focusNoteId: 42, notesApi: api, setSelected: vi.fn(), setEditing: noop, setView: noop, |
| 127 | + setGroupFilter: noop, onConsumed: consumed, |
| 128 | + })); |
| 129 | + await settle(); |
| 130 | + expect(calls.status).toEqual(["加载失败: Error: db locked"]); |
| 131 | + expect(consumed).toHaveBeenCalledTimes(1); |
| 132 | + }); |
| 133 | +}); |
| 134 | + |
| 135 | +describe("② 注释与实现一致(K4)+ 行数红线镜像(K5)", () => { |
| 136 | + it("K4 `useNotesDeepLink.ts` 的 @ai-context 出现 onConsumed,且不再说「本页无回调」", () => { |
| 137 | + const src = readFileSync(join(HERE, "useNotesDeepLink.ts"), "utf8"); |
| 138 | + const ctx = src.slice(0, src.indexOf("*/")); // 文件头 @ai-context 块 |
| 139 | + expect(ctx).toContain("onConsumed"); |
| 140 | + expect(ctx, "注释还在说清空责任在 App ⇒ 标签说谎(App 里没有该复位)").not.toContain("清空责任在 App"); |
| 141 | + expect(ctx).not.toContain("本页无"); |
| 142 | + }); |
| 143 | + |
| 144 | + it("K5 `App.tsx` ≤600(**镜像**行数口径;权威读数 = `node scripts/line-limits.mjs --full`)", () => { |
| 145 | + const text = readFileSync(join(HERE, "..", "App.tsx"), "utf8"); |
| 146 | + const lines = text === "" ? 0 : text.split("\n").length - (text.endsWith("\n") ? 1 : 0); |
| 147 | + expect(lines, "App.tsx 越过 600 硬限(C6 逐字:不许登记豁免)").toBeLessThanOrEqual(600); |
| 148 | + expect(lines, "反空真:读到空文件/路径错时,上面那条会假绿").toBeGreaterThan(300); |
| 149 | + }); |
| 150 | +}); |
| 151 | + |
| 152 | +describe("③ 接线(App + 三页;静态判据,运行期证据归真机)", () => { |
| 153 | + const APP = codeOf("App.tsx"); |
| 154 | + const SESSIONS = codeOf("pages/SessionsPage.tsx"); |
| 155 | + const KNOWLEDGE = codeOf("pages/KnowledgePage.tsx"); |
| 156 | + const NOTES = codeOf("pages/NotesPage.tsx"); |
| 157 | + |
| 158 | + it("K2 接线:5 个粘滞字段在 App 侧各有一个复位(三组回调),三页都接上了消费点", () => { |
| 159 | + expect(APP, "focusSessionId 没有复位回调").toMatch(/onFocusSessionConsumed=\{\(\) => setFocusSessionId\(null\)\}/); |
| 160 | + expect(APP, "focusSystemId 没有复位回调").toMatch(/onFocusSystemConsumed=\{\(\) => setFocusSystemId\(null\)\}/); |
| 161 | + for (const f of ["focusNoteId", "focusNoteSearch", "focusGroupId"]) { |
| 162 | + const setter = `set${f[0].toUpperCase()}${f.slice(1)}`; |
| 163 | + expect(APP, `${f} 没有在 onFocusNoteConsumed 里复位`).toMatch( |
| 164 | + new RegExp(`onFocusNoteConsumed=\\{\\(\\) => \\{[^}]*${setter}\\(null\\)`), |
| 165 | + ); |
| 166 | + } |
| 167 | + expect(SESSIONS, "SessionsPage 没有消费 focusSessionId").toMatch(/onFocusSessionConsumed\?\.\(\)/); |
| 168 | + expect(KNOWLEDGE, "KnowledgePage 没有消费 focusSystemId").toMatch(/onFocusSystemConsumed\?\.\(\)/); |
| 169 | + expect(NOTES, "NotesPage 没把三个字段的复位回调接到深链 hook").toMatch(/onConsumed: onFocusNoteConsumed/); |
| 170 | + }); |
| 171 | + |
| 172 | + it("反例自检:同一组正则对**改前**形态必须不命中(防判据空真)", () => { |
| 173 | + const before = [ |
| 174 | + "<SessionsPage focusSessionId={focusSessionId}", |
| 175 | + "<NotesPage focusNoteId={focusNoteId} focusNoteSearch={focusNoteSearch} focusGroupId={focusGroupId}", |
| 176 | + "<KnowledgePage focusSystemId={focusSystemId}", |
| 177 | + "// 清空责任在 App(本页无 onFocus*Consumed 回调)", |
| 178 | + ].join("\n"); |
| 179 | + expect(/onFocusSessionConsumed=/.test(before)).toBe(false); |
| 180 | + expect(/onFocusNoteConsumed=/.test(before)).toBe(false); |
| 181 | + expect(/onFocusSystemConsumed=/.test(before)).toBe(false); |
| 182 | + }); |
| 183 | +}); |
0 commit comments