1313 * (J1-3 死局形态:只翻 setManualFolded 则自动折叠下点了不展开 ⇒ 红);
1414 * ④ 手动折叠记忆 + 页面不注入任何 prop 时仍然自足(缺省不回落 240)。
1515 *
16+ * ⑤⑥⑦(2026-09-12 · T9 评审 I-1 修正 / 控制方裁决 e):**拖拽手柄**。此前页面只
17+ * 散传宽度/折叠态/展开回调 ⇒ `resizeBy` 到不了 UI,注册表承诺的 200..320 是
18+ * 规格 §6.2 逐字点名的「假可调」;现改为列状态**整体**注入 + 手柄在本组件内渲染:
19+ * ⑤ 手柄在(`data-testid="column-resizer"`,见 `ColumnResizer.tsx:60`);
20+ * ⑥ **拖拽真的改宽**(pointer 增量 → `resizeBy` → DOM `style.width`,含 max 夹取);
21+ * ⑦ 折叠态下手柄仍在(与 `notes/NotesGroupsColumn` 同款:手柄在折叠三元之外);
22+ * ⑧ 宽度与 `resizeBy` 都取自**注入的那个对象**(防「注入了却被忽略」的假接线)。
23+ *
1624 * ⚠️ 仪器边界:jsdom **不排版** —— 只判 `style.width` 与元素有无,**不判像素**(像素归 T14 探针);
1725 * 且组件测试底座全局 `environment: "node"` ⇒ 首行必须是指令行(本行之上不得有注释)。
1826 * 本仓未装 jest-dom / user-event ⇒ 断言用原生 API、交互用 `fireEvent`;无 `globals`
1927 * ⇒ 必须显式 `afterEach(cleanup)`(先例 `NoteColorPicker.test.tsx:11`)。
2028 */
2129import { afterEach , beforeEach , describe , expect , it , vi } from "vitest" ;
2230import { cleanup , fireEvent , render , screen } from "@testing-library/react" ;
23- import { useColumnLayout } from "../hooks/useColumnLayout" ;
31+ import { useColumnLayout , type ColumnLayout } from "../hooks/useColumnLayout" ;
2432import { columnSpec } from "../shell/columnRegistry" ;
2533import ChatSidebar from "./ChatSidebar" ;
2634import type { ChatSidebarProps } from "./ChatSidebar" ;
2735
2836const KEY = "chat-sidebar" ;
29- const base : Omit < ChatSidebarProps , "width" | "folded" | "onExpand "> = {
37+ const base : Omit < ChatSidebarProps , "col " > = {
3038 sessions : [ ] , tasks : [ ] , activeChatId : null , activeTaskId : null ,
3139 onSelectChat : vi . fn ( ) , onSelectTask : vi . fn ( ) , onNewChat : vi . fn ( ) ,
3240 onRenameChat : vi . fn ( ) , onDeleteChat : vi . fn ( ) ,
@@ -36,7 +44,7 @@ const base: Omit<ChatSidebarProps, "width" | "folded" | "onExpand"> = {
3644/** 页面形态的宿主:真 hook + 真注册表规格(= `ChatPage.tsx` 的那一行 + props 注入) */
3745function Harness ( ) {
3846 const chatCol = useColumnLayout ( KEY , columnSpec ( KEY ) ) ;
39- return < ChatSidebar { ...base } width = { chatCol . width } folded = { chatCol . folded } onExpand = { chatCol . expand } /> ;
47+ return < ChatSidebar { ...base } col = { chatCol } /> ;
4048}
4149
4250/** 展开态的根元素宽度(折叠态根元素是 ColumnBar 窄条,无 width 样式) */
@@ -84,9 +92,61 @@ describe("ChatSidebar 列接线(规格 §6.2)", () => {
8492 const folded = render ( < Harness /> ) ;
8593 expect ( screen . getByTestId ( "column-bar" ) ) . toBeTruthy ( ) ;
8694 folded . unmount ( ) ;
95+ // 2026-09-12(I-1 修正):组件现在**自持**同 key 的列状态 ⇒ 上面写进去的手动折叠
96+ // 记忆会被兜底实例读回(渲染成窄条)。本条的判据是「宽度缺省来自注册表」,
97+ // 折记忆另由 ③⑦ 覆盖,故先清掉折叠记忆再验缺省宽度。
98+ window . localStorage . removeItem ( `layout:col-fold:${ KEY } ` ) ;
8799 const bare = render ( < ChatSidebar { ...base } /> ) ;
88100 expect ( rootWidth ( bare . container . firstElementChild as HTMLElement ) ) . toBe (
89101 `${ columnSpec ( KEY ) . default } px` ,
90102 ) ;
91103 } ) ;
104+
105+ it ( "⑤ 拖拽手柄接在侧栏上(I-1「假可调」修正:注册表承诺的 200..320 可达)" , ( ) => {
106+ window . localStorage . setItem ( `layout:col-width:${ KEY } ` , "300" ) ;
107+ render ( < Harness /> ) ;
108+ // 有且仅有一个手柄(页面不再自己渲染第二个——列随组件走)
109+ const handle = screen . getByTestId ( "column-resizer" ) ;
110+ expect ( handle . getAttribute ( "role" ) ) . toBe ( "separator" ) ;
111+ } ) ;
112+
113+ it ( "⑥ 拖手柄真的改宽(pointer 增量 → resizeBy → DOM 宽度;max 由 hook 夹取)" , ( ) => {
114+ const { container } = render ( < Harness /> ) ;
115+ const handle = screen . getByTestId ( "column-resizer" ) ;
116+ expect ( rootWidth ( container . firstElementChild as HTMLElement ) ) . toBe ( "260px" ) ;
117+ fireEvent . pointerDown ( handle , { clientX : 100 } ) ;
118+ fireEvent . pointerMove ( handle , { clientX : 140 } ) ; // +40 ⇒ 300(增量语义:不是累计位移)
119+ fireEvent . pointerUp ( handle ) ;
120+ expect ( rootWidth ( container . firstElementChild as HTMLElement ) ) . toBe ( "300px" ) ;
121+ // 注册表 max=320:再拖 +100 必须被 useColumnLayout 夹回 320(手柄旁路不了规格)
122+ fireEvent . pointerDown ( handle , { clientX : 100 } ) ;
123+ fireEvent . pointerMove ( handle , { clientX : 200 } ) ;
124+ fireEvent . pointerUp ( handle ) ;
125+ expect ( rootWidth ( container . firstElementChild as HTMLElement ) ) . toBe ( "320px" ) ;
126+ } ) ;
127+
128+ it ( "⑦ 折叠态下手柄仍在(与 notes/NotesGroupsColumn 同款:手柄在折叠三元之外)" , ( ) => {
129+ window . localStorage . setItem ( `layout:col-fold:${ KEY } ` , "1" ) ;
130+ render ( < Harness /> ) ;
131+ expect ( screen . getByTestId ( "column-bar" ) ) . toBeTruthy ( ) ;
132+ expect ( screen . getByTestId ( "column-resizer" ) ) . toBeTruthy ( ) ;
133+ } ) ;
134+
135+ it ( "⑧ **注入的那个对象**就是真源(宽度与 resizeBy 都取自页面传的 col)" , ( ) => {
136+ // Why 造一个假对象(与 ①–⑦ 的「真 hook」口径互补):Harness 与页面的 hook 同 key、
137+ // 同规格 ⇒ 它分不清「用了注入对象」与「自己另开了一份状态」。若要防
138+ // `const col = ownCol`(页面注入了却被忽略)这类假接线,必须让注入值**可区分**。
139+ const resizeBy = vi . fn ( ) ;
140+ const injected : ColumnLayout = {
141+ width : 205 , folded : false , manuallyFolded : false ,
142+ resizeBy, resetWidth : vi . fn ( ) , setManualFolded : vi . fn ( ) , expand : vi . fn ( ) ,
143+ } ;
144+ const { container } = render ( < ChatSidebar { ...base } col = { injected } /> ) ;
145+ expect ( rootWidth ( container . firstElementChild as HTMLElement ) ) . toBe ( "205px" ) ;
146+ const handle = screen . getByTestId ( "column-resizer" ) ;
147+ fireEvent . pointerDown ( handle , { clientX : 10 } ) ;
148+ fireEvent . pointerMove ( handle , { clientX : 30 } ) ;
149+ fireEvent . pointerUp ( handle ) ;
150+ expect ( resizeBy . mock . calls . map ( ( c ) => c [ 0 ] ) ) . toEqual ( [ 20 ] ) ;
151+ } ) ;
92152} ) ;
0 commit comments