@aiti-ai/aiti-folder 0.0.0-stage → 0.1.8

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,241 @@
1
+ /**
2
+ * folder-dock(aiti-folder 文件夹区块)单测 —— node --test。
3
+ *
4
+ * 覆盖 2026-09-18 修复的两处:
5
+ * 1. faces 重注册 render/meta 引用稳定化:宿主 ExtSlot 以 `<it.render/>` 为元素类型,
6
+ * 重注册换新闭包 = React 卸载重挂整棵子树(FolderBrowser root/展开集全丢)——
7
+ * 「点钉住 → folder 打回旧值」。本测试钉死:同路径重注册引用不变、差量新增/移除
8
+ * 不影响既有引用。
9
+ * 2. 钉住清单语义:toggle 翻转 + 广播、清单缺席(旧式 tab)→ 追加钉住条目(不得静默 no-op)。
10
+ *
11
+ * helper 是 TSX + react import,node 直读不了——先 esbuild 打包为可测 bundle(react 用
12
+ * stub 替身;FolderBrowser 是动态 import 的懒模块,测试不渲染故不会执行其初始化)。
13
+ */
14
+ import { test } from "node:test";
15
+ import { pathToFileURL } from "node:url";
16
+ import assert from "node:assert/strict";
17
+ import { runEsbuild } from "../../../_shared/esbuild-bin.ts";
18
+ import { existsSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join, dirname, resolve } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ const here = dirname(fileURLToPath(import.meta.url));
24
+ const KEY = "pi:sidebar-dock:folders";
25
+ const EVENT = "pi:sidebar-dock:folders-changed";
26
+
27
+ /** 极简 window/document/localStorage 桩(同 _shared/ext-float-dock.test.mjs 范式)。 */
28
+ function makeDomStub() {
29
+ const listeners = new Map(); // type -> Set<fn>
30
+ const storage = new Map();
31
+ const makeEl = (tag = "div") => {
32
+ const el = {
33
+ tag, style: {}, className: "", textContent: "", children: [], attrs: {}, handlers: {}, parentElement: null,
34
+ setAttribute(k, v) { this.attrs[k] = String(v); },
35
+ getAttribute(k) { return this.attrs[k] ?? null; },
36
+ removeAttribute(k) { delete this.attrs[k]; },
37
+ appendChild(c) { this.children.push(c); c.parentElement = this; },
38
+ remove() {
39
+ const p = this.parentElement;
40
+ if (!p) return;
41
+ const i = p.children.indexOf(this);
42
+ if (i >= 0) p.children.splice(i, 1);
43
+ this.parentElement = null;
44
+ },
45
+ addEventListener(type, fn) { (this.handlers[type] ||= []).push(fn); },
46
+ removeEventListener(type, fn) { this.handlers[type] = (this.handlers[type] || []).filter((f) => f !== fn); },
47
+ click() { for (const fn of this.handlers.click || []) fn({ target: this, stopPropagation() {} }); },
48
+ querySelector: () => null,
49
+ querySelectorAll: (sel) => (sel === "button" ? this.children.filter((c) => c.tag === "button") : []),
50
+ contains: (n) => { for (const c of this.children) { if (c === n || c.contains(n)) return true; } return false; },
51
+ };
52
+ return el;
53
+ };
54
+ const window = {
55
+ addEventListener(type, fn) {
56
+ if (!listeners.has(type)) listeners.set(type, new Set());
57
+ listeners.get(type).add(fn);
58
+ },
59
+ removeEventListener(type, fn) { listeners.get(type)?.delete(fn); },
60
+ dispatchEvent(ev) {
61
+ for (const fn of listeners.get(ev.type) ?? []) fn(ev);
62
+ return true;
63
+ },
64
+ innerWidth: 1600, innerHeight: 900,
65
+ localStorage: {
66
+ getItem: (k) => (storage.has(k) ? storage.get(k) : null),
67
+ setItem: (k, v) => storage.set(k, String(v)),
68
+ removeItem: (k) => storage.delete(k),
69
+ },
70
+ };
71
+ const document = {
72
+ getElementById: () => makeEl(),
73
+ createElement: (tag) => makeEl(tag),
74
+ createElementNS: (_ns, tag) => makeEl(tag),
75
+ querySelector: () => null,
76
+ querySelectorAll: () => [],
77
+ addEventListener() {}, removeEventListener() {},
78
+ head: { appendChild() {} }, body: makeEl("body"),
79
+ };
80
+ return { window, document, listeners };
81
+ }
82
+
83
+ function esbuildBin() {
84
+ const local = resolve(here, "..", "..", "node_modules", "esbuild", "bin", "esbuild");
85
+ if (existsSync(local)) return local;
86
+ return resolve(here, "..", "..", "..", "_shared", "..", "aiti-float", "node_modules", "esbuild", "bin", "esbuild");
87
+ }
88
+
89
+ /** 打包 folder-dock.tsx(react → stub)并安装 DOM 桩后 import;每测试独立模块实例。 */
90
+ async function loadFolderDock(t) {
91
+ const dir = mkdtempSync(join(tmpdir(), "afd-test-"));
92
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
93
+ const reactStub = join(dir, "react-stub.js");
94
+ writeFileSync(reactStub,
95
+ "export const useEffect = () => {};\n" +
96
+ "export const useState = (v) => [v, () => {}];\n" +
97
+ "export const useLayoutEffect = () => {};\n" +
98
+ "export const useRef = () => null;\n" +
99
+ "export const useCallback = (f) => f;\n" +
100
+ "export const useMemo = (f) => f();\n" +
101
+ "export const memo = (f) => f;\n" +
102
+ "export const createElement = () => null;\n" +
103
+ "export const Fragment = null;\n");
104
+ const outfile = join(dir, "bundle.mjs");
105
+ // 派生形态(Linux 原生 ELF / Windows 官方 .exe,或 node 直跑 JS 入口)由 esbuild-bin 收口
106
+ await runEsbuild(esbuildBin(), [join(here, "folder-dock.tsx"), "--bundle", "--format=esm", "--platform=node",
107
+ "--alias:react=" + reactStub, "--outfile=" + outfile, "--log-level=error"]);
108
+
109
+ const dom = makeDomStub();
110
+ globalThis.window = dom.window;
111
+ globalThis.document = dom.document;
112
+ globalThis.localStorage = dom.window.localStorage;
113
+ t.after(() => {
114
+ delete globalThis.window;
115
+ delete globalThis.document;
116
+ delete globalThis.localStorage;
117
+ });
118
+ const mod = await import(pathToFileURL(outfile).href);
119
+ return { mod, dom };
120
+ }
121
+
122
+ const readEntries = () => JSON.parse(globalThis.localStorage.getItem(KEY) ?? "[]");
123
+ const writeEntries = (entries) => globalThis.localStorage.setItem(KEY, JSON.stringify(entries));
124
+
125
+ /** 从注册调用批次里取某 slot 最后一次的 item 映射(id → item)。 */
126
+ function itemsOf(batches, slot) {
127
+ const last = batches.filter(([s]) => s === slot).pop();
128
+ return new Map((last?.[1] ?? []).map((it) => [it.id, it]));
129
+ }
130
+
131
+ test("钉住清单:toggle 翻转 + 事件广播 + 清单缺席追加钉住条目(不得静默 no-op)", async (t) => {
132
+ const { mod, dom } = await loadFolderDock(t);
133
+ writeEntries([{ p: "/a" }, { p: "/b", pin: 1 }]);
134
+ let fired = 0;
135
+ dom.window.addEventListener(EVENT, () => fired++);
136
+
137
+ mod.toggleFolderPin("/a");
138
+ assert.deepEqual(readEntries(), [{ p: "/a", pin: 1 }, { p: "/b", pin: 1 }], "未钉 → 钉住");
139
+ assert.equal(mod.isFolderPinned("/a"), true);
140
+ assert.equal(fired, 1, "钉住翻转应广播 INSTANCES_EVENT");
141
+
142
+ mod.toggleFolderPin("/b");
143
+ assert.deepEqual(readEntries(), [{ p: "/a", pin: 1 }, { p: "/b" }], "已钉 → 取消");
144
+
145
+ // 清单缺席(旧式 folder tab / 非 dock 实例):追加钉住条目而不是无声无息
146
+ mod.toggleFolderPin("/c");
147
+ assert.deepEqual(readEntries(), [{ p: "/a", pin: 1 }, { p: "/b" }, { p: "/c", pin: 1 }], "缺席路径应追加钉住条目");
148
+ assert.equal(mod.isFolderPinned("/c"), true);
149
+ assert.equal(fired, 3, "每次 toggle 都应广播");
150
+
151
+ // 空路径忽略;尾斜杠差异按原样比较(清单内精确匹配)
152
+ const before = globalThis.localStorage.getItem(KEY);
153
+ mod.toggleFolderPin("");
154
+ assert.equal(globalThis.localStorage.getItem(KEY), before, "空路径 no-op");
155
+ });
156
+
157
+ test("未钉 FIFO 配额:>3 未钉挤最旧,钉住豁免", async (t) => {
158
+ const { mod } = await loadFolderDock(t);
159
+ writeEntries([]);
160
+ mod.addFolderInstance("/f1");
161
+ mod.addFolderInstance("/f2");
162
+ mod.addFolderInstance("/f3");
163
+ mod.addFolderInstance("/f4"); // 第 4 个未钉 → /f1 被挤出
164
+ assert.deepEqual(readEntries().map((e) => e.p), ["/f2", "/f3", "/f4"], "溢出挤掉最旧未钉");
165
+
166
+ mod.toggleFolderPin("/f2");
167
+ mod.addFolderInstance("/f5");
168
+ mod.addFolderInstance("/f6"); // 未钉 = /f3,/f4,/f5 → 第 4 个挤出 /f3
169
+ assert.deepEqual(readEntries().map((e) => e.p), ["/f2", "/f4", "/f5", "/f6"], "钉住豁免配额");
170
+ assert.equal(mod.isFolderPinned("/f2"), true);
171
+ });
172
+
173
+ test("faces 重注册:同路径 render/meta 引用恒定(pin 翻转零重挂),差量增删不改既有引用", async (t) => {
174
+ const { mod } = await loadFolderDock(t);
175
+ writeEntries([{ p: "/a" }, { p: "/b" }]);
176
+ const batches = [];
177
+ const ctx = {
178
+ registerItems: (slot, items) => batches.push([slot, items]),
179
+ disposeItems: () => {},
180
+ };
181
+
182
+ mod.syncFolderDockFaces(ctx);
183
+ const first = itemsOf(batches, "sidebarExplorerDock");
184
+ assert.deepEqual([...first.keys()].sort(), ["folder:/a", "folder:/b"], "两条 face 注册");
185
+ const a1 = first.get("folder:/a");
186
+ const b1 = first.get("folder:/b");
187
+
188
+ // ① 仅 pin 翻转(清单路径未变)→ 重注册但 render/meta 引用不变(宿主 unchanged → 不 bump、不重挂)
189
+ batches.length = 0;
190
+ mod.toggleFolderPin("/a");
191
+ mod.syncFolderDockFaces(ctx);
192
+ const afterPin = itemsOf(batches, "sidebarExplorerDock");
193
+ assert.equal(afterPin.get("folder:/a").render, a1.render, "既有路径 render 引用必须不变(防 Remount → root 回退)");
194
+ assert.equal(afterPin.get("folder:/a").meta, a1.meta, "既有路径 meta 引用必须不变");
195
+ assert.equal(afterPin.get("folder:/b").render, b1.render, "未变动路径 render 引用必须不变");
196
+
197
+ // ② 新增路径 → 新 face 注册,既有引用保持
198
+ batches.length = 0;
199
+ mod.addFolderInstance("/c");
200
+ mod.syncFolderDockFaces(ctx);
201
+ const afterAdd = itemsOf(batches, "sidebarExplorerDock");
202
+ assert.equal(afterAdd.get("folder:/a").render, a1.render, "新增路径不得换既有 render 引用");
203
+ assert.equal(afterAdd.get("folder:/b").render, b1.render, "新增路径不得换既有 render 引用");
204
+ assert.ok(afterAdd.has("folder:/c"), "新路径 face 已注册");
205
+
206
+ // ③ 移除路径 → 该 face 被 dispose,剩余引用保持(缓存同步回收)
207
+ const disposed = [];
208
+ const ctx2 = {
209
+ registerItems: (slot, items) => batches.push([slot, items]),
210
+ disposeItems: (slot, ids) => disposed.push([slot, ids]),
211
+ };
212
+ batches.length = 0;
213
+ mod.removeFolderInstance("/b");
214
+ mod.syncFolderDockFaces(ctx2);
215
+ const afterRemove = itemsOf(batches, "sidebarExplorerDock");
216
+ assert.deepEqual([...afterRemove.keys()].sort(), ["folder:/a", "folder:/c"], "移除路径不再注册");
217
+ assert.equal(afterRemove.get("folder:/a").render, a1.render, "移除路径不得换幸存者 render 引用");
218
+ assert.ok(disposed.some(([slot, ids]) => slot === "sidebarExplorerDock" && ids.includes("folder:/b")), "移除路径应 dispose face");
219
+
220
+ // ④ 重新加入被移除路径 → 重建闭包(旧缓存已回收),不必与旧引用相同,但功能仍在
221
+ batches.length = 0;
222
+ mod.addFolderInstance("/b");
223
+ mod.syncFolderDockFaces(ctx2);
224
+ const afterReadd = itemsOf(batches, "sidebarExplorerDock");
225
+ assert.ok(afterReadd.has("folder:/b"), "重新加入路径重建 face");
226
+ });
227
+
228
+ test("openFolderDocked 透传 auto:恢复性落位(宿主 select:false 后台打开)标记随状态机事件出栈", async (t) => {
229
+ const { mod, dom } = await loadFolderDock(t);
230
+ writeEntries([]);
231
+ const seen = [];
232
+ dom.window.addEventListener("piwe-float-present-change", (e) => seen.push(e.detail));
233
+
234
+ mod.openFolderDocked("/user-picked", "right-panel");
235
+ mod.openFolderDocked("/restored", "right-panel", { auto: true });
236
+
237
+ assert.deepEqual(seen, [
238
+ { key: "aiti-folder:folder:/user-picked", state: "docked:right-panel" },
239
+ { key: "aiti-folder:folder:/restored", state: "docked:right-panel", auto: true },
240
+ ], "用户主动落位不带 auto;auto 必须原样透传(宿主据此不拉小屏右栏、不抢 tab 焦点)");
241
+ });