dsh-workspace-mover 2.1.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,667 @@
1
+ // dsh-workspace-mover — client-half tests against a hand-rolled DOM stub.
2
+ //
3
+ // 为什么手写而不是引入 jsdom/happy-dom:本仓库刻意保持零 devDependencies,
4
+ // CI 直接跑 `node --test`(见 package.json)。客户端半只需要 document /
5
+ // MutationObserver / navigator / window.confirm 的一小部分能力,这里按需实现,
6
+ // 顺带让"注册了哪些监听器"变成可断言的事实——这正是 teardown 缺陷的回归网。
7
+ //
8
+ // 测试策略:只经**公开出口**驱动(注册进 document 的事件 + RPC 调用记录),
9
+ // 不触碰闭包内部的私有函数。因此断言的是"用户操作后发往宿主的 payload",
10
+ // 而不是实现细节。
11
+ import { test, before, after } from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+
14
+ //#region 迷你 DOM
15
+
16
+ /** 解析 injectOverlay 使用的那一小撮 HTML:标签 + 类名/属性 + 文本。 */
17
+ function parseHTML(html) {
18
+ const root = { childNodes: [], nodeType: 11 }; // fragment
19
+ const stack = [root];
20
+ const re = /<\/?([a-zA-Z][\w-]*)((?:\s+[\w-]+(?:="[^"]*")?)*)\s*\/?>|([^<]+)/g;
21
+ let m;
22
+ while ((m = re.exec(html)) !== null) {
23
+ const [full, tag, attrs, text] = m;
24
+ if (text !== undefined) {
25
+ const parent = stack[stack.length - 1];
26
+ if (parent === root || text.trim()) parent.childNodes.push({ nodeType: 3, _text: text, parentNode: parent });
27
+ continue;
28
+ }
29
+ if (full.startsWith('</')) {
30
+ if (stack.length > 1) stack.pop();
31
+ continue;
32
+ }
33
+ const el = createElement(tag);
34
+ for (const a of attrs ? attrs.matchAll(/([\w-]+)(?:="([^"]*)")?/g) : []) {
35
+ if (a[1] === 'class') el.className = a[2] ?? '';
36
+ else el.setAttribute(a[1], a[2] ?? '');
37
+ }
38
+ const parent = stack[stack.length - 1];
39
+ el.parentNode = parent === root ? null : parent;
40
+ parent.childNodes.push(el);
41
+ if (!full.endsWith('/>')) stack.push(el);
42
+ }
43
+ // flatten fragment
44
+ const frag = { childNodes: root.childNodes, nodeType: 11 };
45
+ return frag;
46
+ }
47
+
48
+ function matches(el, selector) {
49
+ if (!el || el.nodeType !== 1) return false;
50
+ const parts = selector.trim().split(/\s+/);
51
+ if (parts.length > 1) {
52
+ // descendant combinator
53
+ const [head, ...rest] = parts;
54
+ if (!matches(el, rest.join(' '))) return false;
55
+ for (let p = el.parentNode; p; p = p.parentNode) if (matches(p, head)) return true;
56
+ return false;
57
+ }
58
+ const s = parts[0];
59
+ if (s.startsWith('.')) return String(el.className ?? '').trim().split(/\s+/).includes(s.slice(1));
60
+ if (s.startsWith('#')) return el.id === s.slice(1);
61
+ const attr = /^([a-zA-Z][\w-]*)?((?:\[[^\]]+\])+)$/.exec(s);
62
+ if (attr) {
63
+ const tag = attr[1];
64
+ if (tag && el.tagName?.toLowerCase() !== tag.toLowerCase()) return false;
65
+ for (const a of attr[2].matchAll(/\[([\w-]+)(?:=["']?([^"'\]]*)["']?)?\]/g)) {
66
+ const v = el.getAttribute(a[1]);
67
+ if (v === null) return false;
68
+ if (a[2] !== undefined && v !== a[2]) return false;
69
+ }
70
+ return true;
71
+ }
72
+ return el.tagName?.toLowerCase() === s.toLowerCase();
73
+ }
74
+
75
+ function matchesSelectorList(el, selector) {
76
+ // 支持 `A, B` 与 `A > B`(本仓库只用到这些形态)
77
+ return selector.split(',').some((one) => {
78
+ const sel = one.trim();
79
+ if (sel.includes('>')) {
80
+ const [parentSel, childSel] = sel.split('>').map((x) => x.trim());
81
+ return matches(el, childSel) && matches(el.parentNode, parentSel);
82
+ }
83
+ return matches(el, sel);
84
+ });
85
+ }
86
+
87
+ function descendants(node, out = []) {
88
+ for (const c of node.childNodes ?? []) {
89
+ if (c.nodeType === 1) {
90
+ out.push(c);
91
+ descendants(c, out);
92
+ }
93
+ }
94
+ return out;
95
+ }
96
+
97
+ function createElement(tag) {
98
+ const el = {
99
+ nodeType: 1,
100
+ tagName: String(tag).toUpperCase(),
101
+ childNodes: [],
102
+ parentNode: null,
103
+ className: '',
104
+ id: '',
105
+ style: {},
106
+ dataset: {},
107
+ _attrs: new Map(),
108
+ _listeners: [],
109
+ _removed: false,
110
+ // 挂到已连接树上才有布局;stub 用 _connected 简化
111
+ get offsetParent() {
112
+ let p = this.parentNode;
113
+ while (p) { if (p._connected) return p; p = p.parentNode; }
114
+ return null;
115
+ },
116
+ get isConnected() {
117
+ let p = this;
118
+ while (p) { if (p._connected) return true; p = p.parentNode; }
119
+ return false;
120
+ },
121
+ get textContent() {
122
+ let s = '';
123
+ for (const c of this.childNodes) {
124
+ if (c.nodeType === 3) s += c._text;
125
+ else if (c.nodeType === 1) s += c.textContent;
126
+ }
127
+ return s;
128
+ },
129
+ set textContent(v) {
130
+ this.childNodes = [];
131
+ if (v !== '' && v != null) this.childNodes.push({ nodeType: 3, _text: String(v), parentNode: this });
132
+ },
133
+ set innerHTML(html) {
134
+ this.childNodes = [];
135
+ for (const c of parseHTML(html).childNodes) {
136
+ c.parentNode = this;
137
+ this.childNodes.push(c);
138
+ }
139
+ },
140
+ get innerHTML() {
141
+ const render = (node) => {
142
+ if (node.nodeType === 3) return node._text;
143
+ const cls = String(node.className ?? '').trim();
144
+ const attrs = [...node._attrs.entries()].map(([k, v]) => ` ${k}="${v}"`).join('');
145
+ const open = `<${node.tagName.toLowerCase()}${cls ? ` class="${cls}"` : ''}${attrs}>`;
146
+ return `${open}${node.childNodes.map(render).join('')}</${node.tagName.toLowerCase()}>`;
147
+ };
148
+ return this.childNodes.map(render).join('');
149
+ },
150
+ setAttribute(name, value) { this._attrs.set(String(name), String(value)); },
151
+ getAttribute(name) { return this._attrs.has(String(name)) ? this._attrs.get(String(name)) : null; },
152
+ removeAttribute(name) { this._attrs.delete(String(name)); },
153
+ appendChild(child) {
154
+ child.parentNode = this;
155
+ if (child._connected === undefined) child._connected = this._connected;
156
+ this.childNodes.push(child);
157
+ return child;
158
+ },
159
+ append(...nodes) { for (const n of nodes) this.appendChild(n); },
160
+ remove() {
161
+ this._removed = true;
162
+ if (this.parentNode) this.parentNode.childNodes = this.parentNode.childNodes.filter((c) => c !== this);
163
+ },
164
+ before(ref) {
165
+ const p = ref.parentNode;
166
+ if (!p) return;
167
+ p.childNodes.splice(p.childNodes.indexOf(ref), 0, this);
168
+ this.parentNode = p;
169
+ },
170
+ after(ref) {
171
+ const p = ref.parentNode;
172
+ if (!p) return;
173
+ p.childNodes.splice(p.childNodes.indexOf(ref) + 1, 0, this);
174
+ this.parentNode = p;
175
+ },
176
+ replaceWith(next) {
177
+ const p = this.parentNode;
178
+ if (!p) return;
179
+ p.childNodes.splice(p.childNodes.indexOf(this), 1, next);
180
+ next.parentNode = p;
181
+ },
182
+ contains(other) {
183
+ for (let p = other; p; p = p.parentNode) if (p === this) return true;
184
+ return false;
185
+ },
186
+ closest(selector) {
187
+ for (let p = this; p; p = p.parentNode) if (matchesSelectorList(p, selector)) return p;
188
+ return null;
189
+ },
190
+ querySelector(selector) { return this.querySelectorAll(selector)[0] ?? null; },
191
+ querySelectorAll(selector) { return descendants(this).filter((el) => matchesSelectorList(el, selector)); },
192
+ addEventListener(type, fn) { this._listeners.push({ type, fn }); },
193
+ removeEventListener(type, fn) {
194
+ this._listeners = this._listeners.filter((l) => !(l.type === type && l.fn === fn));
195
+ },
196
+ get classList() {
197
+ const self = this;
198
+ const tokens = () => String(self.className ?? '').split(/\s+/).filter(Boolean);
199
+ const write = (next) => { self.className = [...new Set(next)].join(' '); };
200
+ return {
201
+ add(...names) { write([...tokens(), ...names]); },
202
+ remove(...names) { write(tokens().filter((t) => !names.includes(t))); },
203
+ contains(name) { return tokens().includes(name); },
204
+ toggled(name, on) { if (on) this.add(name); else this.remove(name); },
205
+ };
206
+ },
207
+ focus() { doc.activeElement = this; },
208
+ };
209
+ return el;
210
+ }
211
+
212
+ function createTextNode(text) { return { nodeType: 3, _text: String(text), parentNode: null }; }
213
+
214
+ const doc = {
215
+ nodeType: 9,
216
+ _connected: true,
217
+ activeElement: null,
218
+ documentElement: null,
219
+ childNodes: [],
220
+ _listeners: [],
221
+ createElement,
222
+ createTextNode,
223
+ get head() { return this._head; },
224
+ get body() { return this._body; },
225
+ addEventListener(type, fn, capture) { this._listeners.push({ type, fn, capture: Boolean(capture) }); },
226
+ removeEventListener(type, fn, capture) {
227
+ this._listeners = this._listeners.filter((l) => !(l.type === type && l.fn === fn && l.capture === Boolean(capture)));
228
+ },
229
+ querySelector(selector) { return this.querySelectorAll(selector)[0] ?? null; },
230
+ querySelectorAll(selector) { return descendants(this).filter((el) => matchesSelectorList(el, selector)); },
231
+ getElementById(id) {
232
+ return descendants(this).find((el) => el.id === id || el.getAttribute('id') === id) ?? null;
233
+ },
234
+ dispatchEvent(event) { dispatch(event); return true; },
235
+ };
236
+ doc._head = createElement('head');
237
+ doc._body = createElement('body');
238
+ doc._head._connected = true;
239
+ doc._body._connected = true;
240
+ doc.childNodes.push(doc._head, doc._body);
241
+
242
+ /** 事件派发:捕获阶段(祖先 → 目标)→ 目标 → 冒泡(目标 → 祖先)。 */
243
+ function dispatch(event) {
244
+ const path = [];
245
+ for (let p = event.target; p; p = p.parentNode) path.push(p);
246
+ if (path[path.length - 1] !== doc) path.push(doc);
247
+ event.preventDefault = event.preventDefault ?? (() => { event.defaultPrevented = true; });
248
+ event.stopImmediatePropagation = event.stopImmediatePropagation ?? (() => { event._stopped = true; });
249
+ event.stopPropagation = event.stopPropagation ?? (() => { event._stopped = true; });
250
+ // capture: root..target
251
+ for (const node of [...path].reverse()) {
252
+ for (const l of [...(node._listeners ?? [])]) {
253
+ if (l.type !== event.type) continue;
254
+ if (!l.capture) continue;
255
+ event.currentTarget = node;
256
+ l.fn.call(node, event);
257
+ if (event._stopped) return;
258
+ }
259
+ }
260
+ // bubble: target..root
261
+ for (const node of path) {
262
+ for (const l of [...(node._listeners ?? [])]) {
263
+ if (l.type !== event.type) continue;
264
+ if (l.capture) continue;
265
+ event.currentTarget = node;
266
+ l.fn.call(node, event);
267
+ if (event._stopped) return;
268
+ }
269
+ }
270
+ }
271
+
272
+ class MutationObserverStub {
273
+ constructor(cb) { this.cb = cb; this._target = null; }
274
+ observe(target) { this._target = target; observers.push(this); }
275
+ disconnect() { this._target = null; const i = observers.indexOf(this); if (i >= 0) observers.splice(i, 1); }
276
+ /** 测试驱动:模拟一批 addedNodes。 */
277
+ trigger(added) { if (this._target) this.cb([{ addedNodes: added }], this); }
278
+ }
279
+ const observers = [];
280
+
281
+ //#endregion
282
+
283
+ //#region React shim(client.js 只用 createElement / Fragment / useState / useEffect)
284
+
285
+ const reactShim = {
286
+ Fragment: Symbol('Fragment'),
287
+ createElement(tag, props, ...children) {
288
+ return { $$typeof: 'el', type: tag, props: { ...(props ?? {}), children: children.length > 1 ? children : children[0] } };
289
+ },
290
+ useState(init) { return [typeof init === 'function' ? init() : init, () => {}]; },
291
+ useEffect() {},
292
+ };
293
+
294
+ //#endregion
295
+
296
+ //#region 模块装载
297
+
298
+ let mod = null;
299
+ const rpcCalls = [];
300
+ let rpcResponder = () => ({ ok: true, value: {} });
301
+
302
+ function setResponder(fn) { rpcResponder = fn; }
303
+
304
+ before(async () => {
305
+ globalThis.document = doc;
306
+ globalThis.MutationObserver = MutationObserverStub;
307
+ globalThis.Node = { ELEMENT_NODE: 1 };
308
+ globalThis.Element = function Element() {};
309
+ globalThis.KeyboardEvent = class { constructor(type, init = {}) { Object.assign(this, { type, ...init }); } };
310
+ globalThis.PointerEvent = class { constructor(type, init = {}) { Object.assign(this, { type, ...init }); } };
311
+ // Node 22 的 globalThis.navigator 是只读 getter,必须用 defineProperty 覆盖。
312
+ Object.defineProperty(globalThis, 'navigator', {
313
+ value: { language: 'zh-CN' },
314
+ writable: true,
315
+ configurable: true,
316
+ });
317
+ globalThis.window = {
318
+ confirm: () => true,
319
+ addEventListener() {},
320
+ removeEventListener() {},
321
+ };
322
+ const loader = {
323
+ // client.js 的 factory 既写 module.exports 也把它 return 出来;
324
+ // 取返回值最稳(与官方 __ModuleLoader__ 的约定一致)。
325
+ load(entry) {
326
+ mod = entry.factory((name) => {
327
+ if (name === 'react') return reactShim;
328
+ throw new Error(`unexpected require(${name})`);
329
+ });
330
+ },
331
+ };
332
+ globalThis.window.__ModuleLoader__ = loader;
333
+
334
+ await import('../client/client.js');
335
+ assert.ok(mod && typeof mod.apply === 'function', 'client module must export apply()');
336
+ });
337
+
338
+ after(() => {
339
+ for (const o of observers.slice()) o.disconnect();
340
+ });
341
+
342
+ /** 构造一个可用的 ctx(connection.rpc.call 记录到 rpcCalls)。 */
343
+ function makeCtx() {
344
+ return {
345
+ connection: {
346
+ rpc: {
347
+ async call(_channel, endpoint, payload) {
348
+ rpcCalls.push({ endpoint, payload });
349
+ return rpcResponder(endpoint, payload);
350
+ },
351
+ },
352
+ },
353
+ get: () => undefined,
354
+ slots: undefined,
355
+ effect(fn) {
356
+ const dispose = typeof fn === 'function' ? fn() : undefined;
357
+ effects.push(dispose);
358
+ return dispose;
359
+ },
360
+ };
361
+ }
362
+ const effects = [];
363
+ let ctx = null;
364
+
365
+ /** 模拟宿主废弃本插件 fiber:执行 teardown,之后再装载应当重新生效。 */
366
+ function unmount() {
367
+ for (const dispose of effects.splice(0)) {
368
+ if (typeof dispose === 'function') dispose();
369
+ }
370
+ }
371
+
372
+ function resetDom() {
373
+ unmount();
374
+ doc._body.childNodes = [];
375
+ doc._listeners = [];
376
+ rpcCalls.length = 0;
377
+ for (const o of observers.slice()) o.disconnect();
378
+ if (globalThis.window.__wsmDebug !== undefined) delete globalThis.window.__wsmDebug;
379
+ }
380
+
381
+ function mount() {
382
+ ctx = makeCtx();
383
+ mod.apply(ctx);
384
+ return ctx;
385
+ }
386
+
387
+ /** 造一个工作区标题行。 */
388
+ function header(title, expanded = true) {
389
+ const el = createElement('div');
390
+ el.setAttribute('role', 'treeitem');
391
+ el.setAttribute('aria-expanded', String(expanded));
392
+ el.textContent = title;
393
+ return el;
394
+ }
395
+
396
+ /** 造一个会话行;id 通过 fiber 上的 node 暴露(官方权威通道)。 */
397
+ function sessionRow(title, id, { hidden = false, fiberNode = true } = {}) {
398
+ const el = createElement('div');
399
+ el.setAttribute('role', 'treeitem');
400
+ el.setAttribute('aria-selected', 'false');
401
+ el.textContent = title;
402
+ if (hidden) el._hidden = true; // 用 offsetParent 表达不可见
403
+ if (fiberNode && id) {
404
+ Object.defineProperty(el, '__reactFiber$t', { value: { memoizedProps: { node: { id, blank: false, updatedAt: 1 } }, return: null } });
405
+ }
406
+ return el;
407
+ }
408
+
409
+ /** 把行挂进 body(维护可见性:_hidden 的行 offsetParent 为 null)。 */
410
+ function attach(...els) {
411
+ for (const el of els) doc._body.appendChild(el);
412
+ // 让 _hidden 行的 offsetParent 返回 null
413
+ for (const el of descendants(doc._body)) {
414
+ if (el._hidden) {
415
+ Object.defineProperty(el, 'offsetParent', { get: () => null, configurable: true });
416
+ // 递归隐藏子行
417
+ }
418
+ }
419
+ }
420
+
421
+ //#endregion
422
+
423
+ //#region 夹具:一个工作区 + N 个会话行
424
+
425
+ function fixture({ rows, workspaces, scanItems }) {
426
+ attach(header('proj-alpha'), ...rows);
427
+ const ws = workspaces ?? [
428
+ { workspaceId: 'wid-a', path: 'C:/proj-alpha', title: 'proj-alpha', sessionIds: rows.map((r) => r._sid).filter(Boolean) },
429
+ { workspaceId: 'wid-b', path: 'C:/proj-beta', title: 'proj-beta', sessionIds: [] },
430
+ ];
431
+ setResponder((endpoint) => {
432
+ if (endpoint === 'mover.workspaces') return { ok: true, value: { items: ws, archivedSessionIds: [] } };
433
+ if (endpoint === 'mover.scan') return { ok: true, value: { items: scanItems ?? [], counts: {}, scanned: 0, truncated: false } };
434
+ if (endpoint === 'mover.move' || endpoint === 'mover.moveMany') return { ok: true, value: { movedCount: 1, attachedCount: 0, failedCount: 0, results: [] } };
435
+ return { ok: true, value: {} };
436
+ });
437
+ return { ws };
438
+ }
439
+
440
+ /** 走一遍真实拖拽序列:dragstart → dragover(target) → drop(target) → 点确认。 */
441
+ async function dragDrop(sourceRow, targetHeader, dataTransferId, options = {}) {
442
+ const dt = { getData: (type) => (type === 'text/plain' ? dataTransferId : '') };
443
+ dispatch({ type: 'dragstart', target: sourceRow, dataTransfer: dt });
444
+ await Promise.resolve();
445
+ dispatch({ type: 'dragover', target: targetHeader, dataTransfer: dt, preventDefault() {}, stopImmediatePropagation() {} });
446
+ await new Promise((r) => setTimeout(r, 0)); // 等 dragover 里的 fetchWorkspaces
447
+ dispatch({ type: 'drop', target: targetHeader, dataTransfer: dt, preventDefault() {}, stopImmediatePropagation() {} });
448
+ await new Promise((r) => setTimeout(r, 0));
449
+ // drop 处理是 async:确认框会 await,必须在这里驱动。
450
+ if (options.confirm !== false) {
451
+ const overlay = doc._body.querySelector('.wsm-overlay');
452
+ const ok = overlay?.querySelector('.wsm-ok');
453
+ if (ok) dispatch({ type: 'click', target: ok });
454
+ await new Promise((r) => setTimeout(r, 0));
455
+ }
456
+ }
457
+
458
+ /** 上次 drop 留下的确认框文本(用于断言"跳过了几行"这类提示)。 */
459
+ function lastDialogText() {
460
+ const overlay = doc._body.querySelector('.wsm-overlay');
461
+ return overlay ? overlay.textContent : null;
462
+ }
463
+
464
+ function moveCalls() {
465
+ return rpcCalls.filter((c) => c.endpoint === 'mover.move' || c.endpoint === 'mover.moveMany');
466
+ }
467
+
468
+ //#endregion
469
+
470
+ // ---------------------------------------------------------------------------
471
+ // 用例
472
+ // ---------------------------------------------------------------------------
473
+
474
+ test('模块导出 apply / inject,且 apply 不抛错', () => {
475
+ resetDom();
476
+ mount();
477
+ assert.deepEqual(mod.inject, ['connection', 'slots']);
478
+ });
479
+
480
+ test('热重载:卸载再装载不得让 document 监听器净增长(teardown 回归)', () => {
481
+ resetDom();
482
+ mount();
483
+ const afterFirst = doc._listeners.length;
484
+ assert.ok(afterFirst > 0, '首次 apply 必须注册监听器');
485
+ unmount();
486
+ assert.equal(doc._listeners.length, 0, 'teardown 必须释放全部 document 监听器');
487
+ mount();
488
+ assert.equal(doc._listeners.length, afterFirst,
489
+ `重新装载后监听器数量应与首次相同:${afterFirst} -> ${doc._listeners.length}`);
490
+ });
491
+
492
+ test('未卸载就重复装载时不得叠加第二份监听器(防重复实例)', () => {
493
+ resetDom();
494
+ mount();
495
+ const afterFirst = doc._listeners.length;
496
+ mount(); // 宿主若未废弃旧 fiber 就再次调用 apply
497
+ assert.equal(doc._listeners.length, afterFirst,
498
+ `重复 apply 不得叠加:${afterFirst} -> ${doc._listeners.length}`);
499
+ });
500
+
501
+ test('拖拽跨组移动:经权威 fiber 通道解析出正确 sessionId', async () => {
502
+ resetDom();
503
+ mount();
504
+ const row = sessionRow('Alpha discussion', 'session-aaa');
505
+ const beta = header('proj-beta');
506
+ fixture({ rows: [row] });
507
+ attach(beta);
508
+ await dragDrop(row, beta, 'session-aaa');
509
+ const calls = moveCalls();
510
+ assert.equal(calls.length, 1, '应当恰好发起一次移动');
511
+ assert.equal(calls[0].payload.sessionId, 'session-aaa');
512
+ assert.equal(calls[0].payload.targetWorkspaceId, 'wid-b');
513
+ });
514
+
515
+ test('兜底映射错配时不得把错配 id 当成真实会话移动(P2 回归)', async () => {
516
+ resetDom();
517
+ mount();
518
+ const row1 = sessionRow('行一', null, { fiberNode: false });
519
+ const row2 = sessionRow('行二', 'session-bbb');
520
+ const beta = header('proj-beta');
521
+ fixture({
522
+ rows: [row1, row2],
523
+ workspaces: [
524
+ { workspaceId: 'wid-a', path: 'C:/proj-alpha', title: 'proj-alpha', sessionIds: ['session-aaa', 'session-bbb'] },
525
+ { workspaceId: 'wid-b', path: 'C:/proj-beta', title: 'proj-beta', sessionIds: [] },
526
+ ],
527
+ scanItems: [
528
+ { sessionId: 'session-aaa', title: '完全不同的标题甲' },
529
+ { sessionId: 'session-bbb', title: '完全不同的标题乙' },
530
+ ],
531
+ });
532
+ attach(beta);
533
+ window.confirm = () => true;
534
+ await dragDrop(row1, beta, 'session-bbb');
535
+ const ids = moveCalls().flatMap((c) => (c.payload.sessions ?? [c.payload]).map((s) => s?.sessionId)).filter(Boolean);
536
+ // 兜底映射会把行二错配到 remaining 的第一个 id(session-aaa)。它绝不能出现在 payload 里。
537
+ assert.ok(!ids.includes('session-aaa'),
538
+ `错配出的 id 绝不能进入移动清单(实际 ids: ${JSON.stringify(ids)})`);
539
+ assert.ok(ids.includes('session-bbb'), `拖起行的权威 id 必须被移动(实际 ids: ${JSON.stringify(ids)})`);
540
+ });
541
+
542
+ test('工作区解析必须自证:标题与路径都不匹配时不得按 DOM 序号回退(P3.1 回归)', async () => {
543
+ resetDom();
544
+ mount();
545
+ const row = sessionRow('Alpha discussion', 'session-aaa');
546
+ // 侧边栏只剩一个分组,且它的行文本与注册表里任何 title/path 都不匹配
547
+ //(例如分组标题被插件改写)。注册表第 0 项是目录已失效的 wid-hidden。
548
+ // 旧实现会回退到「DOM 序号 0」= wid-hidden,把会话搬进一个不存在的目录分组。
549
+ const orphanHeader = header('某个侧边栏私有标题');
550
+ fixture({
551
+ rows: [row],
552
+ workspaces: [
553
+ { workspaceId: 'wid-hidden', path: 'C:/gone', title: '已失效分组', sessionIds: [] },
554
+ { workspaceId: 'wid-b', path: 'C:/proj-beta', title: 'proj-beta', sessionIds: [] },
555
+ ],
556
+ });
557
+ attach(orphanHeader);
558
+ await dragDrop(row, orphanHeader, 'session-aaa');
559
+ const calls = moveCalls();
560
+ assert.notEqual(calls[0]?.payload?.targetWorkspaceId, 'wid-hidden',
561
+ '不得把 DOM 第 0 项当作目标(那是注册表里的失效分组)');
562
+ assert.equal(calls.length, 0, '标题与路径都不匹配时必须中止,不得按序号回退');
563
+ });
564
+
565
+ test('弹窗 Escape 关闭并释放 keydown 监听器', async () => {
566
+ resetDom();
567
+ mount();
568
+ const row = sessionRow('Alpha discussion', 'session-aaa');
569
+ const beta = header('proj-beta');
570
+ fixture({ rows: [row] });
571
+ attach(beta);
572
+ window.confirm = () => true;
573
+ const dt = { getData: () => 'session-aaa' };
574
+ dispatch({ type: 'dragstart', target: row, dataTransfer: dt });
575
+ dispatch({ type: 'dragover', target: beta, dataTransfer: dt, preventDefault() {}, stopImmediatePropagation() {} });
576
+ await new Promise((r) => setTimeout(r, 0));
577
+ dispatch({ type: 'drop', target: beta, dataTransfer: dt, preventDefault() {}, stopImmediatePropagation() {} });
578
+ await new Promise((r) => setTimeout(r, 0));
579
+ const overlay = doc._body.querySelector('.wsm-overlay');
580
+ assert.ok(overlay, '应当出现确认弹窗');
581
+ const before = doc._listeners.length;
582
+ dispatch({ type: 'keydown', target: doc._body, key: 'Escape' });
583
+ assert.equal(doc._body.querySelector('.wsm-overlay'), null, 'Escape 应关闭弹窗');
584
+ assert.equal(doc._listeners.length, before - 1, '关闭后应移除 keydown 监听器');
585
+ assert.equal(moveCalls().length, 0, '取消后不得发起移动');
586
+ });
587
+
588
+ test('弹窗具有可访问名称,且焦点落在弹窗内(aria-labelledby 指向标题)', async () => {
589
+ resetDom();
590
+ mount();
591
+ const row = sessionRow('Alpha discussion', 'session-aaa');
592
+ const beta = header('proj-beta');
593
+ fixture({ rows: [row] });
594
+ attach(beta);
595
+ window.confirm = () => true;
596
+ const dt = { getData: () => 'session-aaa' };
597
+ dispatch({ type: 'dragstart', target: row, dataTransfer: dt });
598
+ dispatch({ type: 'dragover', target: beta, dataTransfer: dt, preventDefault() {}, stopImmediatePropagation() {} });
599
+ await new Promise((r) => setTimeout(r, 0));
600
+ dispatch({ type: 'drop', target: beta, dataTransfer: dt, preventDefault() {}, stopImmediatePropagation() {} });
601
+ await new Promise((r) => setTimeout(r, 0));
602
+
603
+ // 弹窗此刻仍然打开——在它关闭之前完成可达性断言。
604
+ const card = doc._body.querySelector('.wsm-card');
605
+ assert.ok(card, '应当出现弹窗卡片');
606
+ assert.equal(card.getAttribute('role'), 'dialog');
607
+ assert.equal(card.getAttribute('aria-modal'), 'true');
608
+ const labelledBy = card.getAttribute('aria-labelledby');
609
+ assert.ok(labelledBy, 'role=dialog 必须有 aria-labelledby');
610
+ const title = doc.getElementById(labelledBy);
611
+ assert.ok(title, `aria-labelledby="${labelledBy}" 必须指向真实存在的标题元素`);
612
+ assert.ok(title.textContent.length > 0, '标题元素必须有可读文本');
613
+ assert.ok(card.contains(doc.activeElement), '打开弹窗后焦点必须落在弹窗内');
614
+
615
+ // 收尾:关闭弹窗,避免影响后续用例
616
+ dispatch({ type: 'click', target: doc._body.querySelector('.wsm-cancel') });
617
+ await new Promise((r) => setTimeout(r, 0));
618
+ });
619
+
620
+ test('RPC 失败矩阵:busy / rollback-failed / 抛错 都不重试', async () => {
621
+ for (const scenario of [
622
+ { name: 'busy', res: { ok: false, error: { code: 'busy', message: 'session is busy' } } },
623
+ { name: 'rollback-failed', res: { ok: false, error: { code: 'rollback-failed', message: 'rolled back' } } },
624
+ { name: 'throw', throw: new Error('cannot get required service "connection" in inactive context') },
625
+ ]) {
626
+ resetDom();
627
+ mount();
628
+ const row = sessionRow('Alpha discussion', 'session-aaa');
629
+ const beta = header('proj-beta');
630
+ fixture({ rows: [row] });
631
+ attach(beta);
632
+ window.confirm = () => true;
633
+ const base = rpcResponder;
634
+ setResponder((endpoint, payload) => {
635
+ if (endpoint === 'mover.move') {
636
+ if (scenario.throw) throw scenario.throw;
637
+ return scenario.res;
638
+ }
639
+ return base(endpoint, payload);
640
+ });
641
+ await dragDrop(row, beta, 'session-aaa');
642
+ assert.equal(moveCalls().length, 1, `${scenario.name}: 失败后不得重试`);
643
+ // 不抛到调用方(toast 呈现即可)
644
+ assert.ok(true, `${scenario.name}: 未把异常抛给宿主`);
645
+ }
646
+ });
647
+
648
+ test('空 value / 空 results 不抛错', async () => {
649
+ resetDom();
650
+ mount();
651
+ const row = sessionRow('Alpha discussion', 'session-aaa');
652
+ const beta = header('proj-beta');
653
+ fixture({ rows: [row] });
654
+ attach(beta);
655
+ window.confirm = () => true;
656
+ setResponder((endpoint, payload) => {
657
+ if (endpoint === 'mover.move') return { ok: true, value: undefined };
658
+ if (endpoint === 'mover.workspaces') return { ok: true, value: { items: [
659
+ { workspaceId: 'wid-a', path: 'C:/proj-alpha', title: 'proj-alpha', sessionIds: ['session-aaa'] },
660
+ { workspaceId: 'wid-b', path: 'C:/proj-beta', title: 'proj-beta', sessionIds: [] },
661
+ ] } };
662
+ if (endpoint === 'mover.scan') return { ok: true, value: { items: [], counts: {} } };
663
+ return { ok: true, value: {} };
664
+ });
665
+ await dragDrop(row, beta, 'session-aaa');
666
+ assert.equal(moveCalls().length, 1);
667
+ });