@assistant-ui/core 0.1.16 → 0.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.
- package/dist/index.d.ts +7 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/react/index.d.ts +3 -1
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +2 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/primitive-hooks/useThreadListLoadMore.d.ts +5 -0
- package/dist/react/primitive-hooks/useThreadListLoadMore.d.ts.map +1 -0
- package/dist/react/primitive-hooks/useThreadListLoadMore.js +11 -0
- package/dist/react/primitive-hooks/useThreadListLoadMore.js.map +1 -0
- package/dist/react/primitives/message/MessageGroupedParts.d.ts +104 -0
- package/dist/react/primitives/message/MessageGroupedParts.d.ts.map +1 -0
- package/dist/react/primitives/message/MessageGroupedParts.js +74 -0
- package/dist/react/primitives/message/MessageGroupedParts.js.map +1 -0
- package/dist/react/primitives/message/MessageParts.d.ts +8 -1
- package/dist/react/primitives/message/MessageParts.d.ts.map +1 -1
- package/dist/react/primitives/message/MessageParts.js +45 -42
- package/dist/react/primitives/message/MessageParts.js.map +1 -1
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts +2 -4
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js +4 -3
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js.map +1 -1
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +8 -6
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +86 -38
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
- package/dist/react/runtimes/useLocalRuntime.d.ts +1 -1
- package/dist/react/utils/groupParts.d.ts +49 -0
- package/dist/react/utils/groupParts.d.ts.map +1 -0
- package/dist/react/utils/groupParts.js +97 -0
- package/dist/react/utils/groupParts.js.map +1 -0
- package/dist/runtime/api/assistant-runtime.d.ts +0 -33
- package/dist/runtime/api/assistant-runtime.d.ts.map +1 -1
- package/dist/runtime/api/assistant-runtime.js +0 -23
- package/dist/runtime/api/assistant-runtime.js.map +1 -1
- package/dist/runtime/api/bindings.d.ts +1 -3
- package/dist/runtime/api/bindings.d.ts.map +1 -1
- package/dist/runtime/api/composer-runtime.d.ts +3 -3
- package/dist/runtime/api/composer-runtime.d.ts.map +1 -1
- package/dist/runtime/api/composer-runtime.js +1 -1
- package/dist/runtime/api/composer-runtime.js.map +1 -1
- package/dist/runtime/api/message-runtime.d.ts +1 -6
- package/dist/runtime/api/message-runtime.d.ts.map +1 -1
- package/dist/runtime/api/message-runtime.js.map +1 -1
- package/dist/runtime/api/thread-list-item-runtime.d.ts +18 -3
- package/dist/runtime/api/thread-list-item-runtime.d.ts.map +1 -1
- package/dist/runtime/api/thread-list-item-runtime.js +1 -1
- package/dist/runtime/api/thread-list-item-runtime.js.map +1 -1
- package/dist/runtime/api/thread-list-runtime.d.ts +4 -0
- package/dist/runtime/api/thread-list-runtime.d.ts.map +1 -1
- package/dist/runtime/api/thread-list-runtime.js +6 -0
- package/dist/runtime/api/thread-list-runtime.js.map +1 -1
- package/dist/runtime/api/thread-runtime.d.ts +6 -29
- package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
- package/dist/runtime/api/thread-runtime.js +2 -21
- package/dist/runtime/api/thread-runtime.js.map +1 -1
- package/dist/runtime/base/base-composer-runtime-core.d.ts +4 -3
- package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
- package/dist/runtime/base/base-composer-runtime-core.js +47 -33
- package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
- package/dist/runtime/base/base-thread-runtime-core.d.ts +3 -4
- package/dist/runtime/base/base-thread-runtime-core.d.ts.map +1 -1
- package/dist/runtime/base/base-thread-runtime-core.js +11 -11
- package/dist/runtime/base/base-thread-runtime-core.js.map +1 -1
- package/dist/runtime/interfaces/composer-runtime-core.d.ts +28 -2
- package/dist/runtime/interfaces/composer-runtime-core.d.ts.map +1 -1
- package/dist/runtime/interfaces/thread-list-runtime-core.d.ts +3 -0
- package/dist/runtime/interfaces/thread-list-runtime-core.d.ts.map +1 -1
- package/dist/runtime/interfaces/thread-runtime-core.d.ts +35 -4
- package/dist/runtime/interfaces/thread-runtime-core.d.ts.map +1 -1
- package/dist/runtime/utils/chat-model-adapter.d.ts +0 -4
- package/dist/runtime/utils/chat-model-adapter.d.ts.map +1 -1
- package/dist/runtime/utils/external-store-message.d.ts +0 -4
- package/dist/runtime/utils/external-store-message.d.ts.map +1 -1
- package/dist/runtime/utils/external-store-message.js +0 -7
- package/dist/runtime/utils/external-store-message.js.map +1 -1
- package/dist/runtimes/assistant-transport/utils.d.ts +0 -9
- package/dist/runtimes/assistant-transport/utils.d.ts.map +1 -1
- package/dist/runtimes/assistant-transport/utils.js +0 -13
- package/dist/runtimes/assistant-transport/utils.js.map +1 -1
- package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +0 -1
- package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/external-store/external-store-thread-runtime-core.js +2 -5
- package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
- package/dist/runtimes/local/local-thread-runtime-core.d.ts +0 -1
- package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/local/local-thread-runtime-core.js +2 -6
- package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
- package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts +0 -1
- package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
- package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js +0 -3
- package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js.map +1 -1
- package/dist/runtimes/remote-thread-list/empty-thread-core.d.ts.map +1 -1
- package/dist/runtimes/remote-thread-list/empty-thread-core.js +0 -3
- package/dist/runtimes/remote-thread-list/empty-thread-core.js.map +1 -1
- package/dist/runtimes/remote-thread-list/remote-thread-state.d.ts +12 -1
- package/dist/runtimes/remote-thread-list/remote-thread-state.d.ts.map +1 -1
- package/dist/runtimes/remote-thread-list/remote-thread-state.js +34 -0
- package/dist/runtimes/remote-thread-list/remote-thread-state.js.map +1 -1
- package/dist/runtimes/remote-thread-list/types.d.ts +5 -1
- package/dist/runtimes/remote-thread-list/types.d.ts.map +1 -1
- package/dist/store/clients/thread-message-client.d.ts.map +1 -1
- package/dist/store/clients/thread-message-client.js +0 -1
- package/dist/store/clients/thread-message-client.js.map +1 -1
- package/dist/store/runtime-clients/composer-runtime-client.d.ts.map +1 -1
- package/dist/store/runtime-clients/composer-runtime-client.js +5 -6
- package/dist/store/runtime-clients/composer-runtime-client.js.map +1 -1
- package/dist/store/runtime-clients/thread-list-runtime-client.d.ts.map +1 -1
- package/dist/store/runtime-clients/thread-list-runtime-client.js +3 -0
- package/dist/store/runtime-clients/thread-list-runtime-client.js.map +1 -1
- package/dist/store/runtime-clients/thread-runtime-client.d.ts.map +1 -1
- package/dist/store/runtime-clients/thread-runtime-client.js +0 -1
- package/dist/store/runtime-clients/thread-runtime-client.js.map +1 -1
- package/dist/store/scopes/composer.d.ts +11 -1
- package/dist/store/scopes/composer.d.ts.map +1 -1
- package/dist/store/scopes/message.d.ts +1 -3
- package/dist/store/scopes/message.d.ts.map +1 -1
- package/dist/store/scopes/thread-list-item.d.ts +10 -0
- package/dist/store/scopes/thread-list-item.d.ts.map +1 -1
- package/dist/store/scopes/thread.d.ts +17 -4
- package/dist/store/scopes/thread.d.ts.map +1 -1
- package/dist/store/scopes/threads.d.ts +3 -0
- package/dist/store/scopes/threads.d.ts.map +1 -1
- package/dist/subscribable/subscribable.d.ts +4 -4
- package/dist/subscribable/subscribable.d.ts.map +1 -1
- package/dist/subscribable/subscribable.js +4 -4
- package/dist/subscribable/subscribable.js.map +1 -1
- package/package.json +25 -13
- package/src/index.ts +12 -6
- package/src/react/index.ts +3 -0
- package/src/react/primitive-hooks/useThreadListLoadMore.ts +15 -0
- package/src/react/primitives/message/MessageGroupedParts.tsx +186 -0
- package/src/react/primitives/message/MessageParts.tsx +80 -55
- package/src/react/runtimes/RemoteThreadListHookInstanceManager.tsx +7 -6
- package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +96 -43
- package/src/react/utils/groupParts.ts +152 -0
- package/src/runtime/api/assistant-runtime.ts +0 -62
- package/src/runtime/api/bindings.ts +1 -6
- package/src/runtime/api/composer-runtime.ts +10 -9
- package/src/runtime/api/message-runtime.ts +1 -8
- package/src/runtime/api/thread-list-item-runtime.ts +28 -6
- package/src/runtime/api/thread-list-runtime.ts +10 -0
- package/src/runtime/api/thread-runtime.ts +12 -53
- package/src/runtime/base/base-composer-runtime-core.ts +85 -42
- package/src/runtime/base/base-thread-runtime-core.ts +21 -13
- package/src/runtime/interfaces/composer-runtime-core.ts +39 -7
- package/src/runtime/interfaces/thread-list-runtime-core.ts +3 -0
- package/src/runtime/interfaces/thread-runtime-core.ts +42 -9
- package/src/runtime/utils/chat-model-adapter.ts +0 -5
- package/src/runtime/utils/external-store-message.ts +0 -8
- package/src/runtimes/assistant-transport/utils.ts +0 -28
- package/src/runtimes/external-store/external-store-thread-runtime-core.ts +2 -6
- package/src/runtimes/local/local-thread-runtime-core.ts +2 -7
- package/src/runtimes/readonly/ReadonlyThreadRuntimeCore.ts +0 -4
- package/src/runtimes/remote-thread-list/empty-thread-core.ts +0 -4
- package/src/runtimes/remote-thread-list/remote-thread-state.ts +54 -1
- package/src/runtimes/remote-thread-list/types.ts +6 -1
- package/src/store/clients/thread-message-client.ts +0 -1
- package/src/store/runtime-clients/composer-runtime-client.ts +5 -9
- package/src/store/runtime-clients/thread-list-runtime-client.ts +3 -0
- package/src/store/runtime-clients/thread-runtime-client.ts +0 -1
- package/src/store/scopes/composer.ts +11 -0
- package/src/store/scopes/message.ts +1 -6
- package/src/store/scopes/thread-list-item.ts +10 -0
- package/src/store/scopes/thread.ts +17 -5
- package/src/store/scopes/threads.ts +3 -0
- package/src/subscribable/subscribable.ts +10 -7
- package/src/tests/RemoteThreadListThreadListRuntimeCore-loadMore.test.ts +448 -0
- package/src/tests/RemoteThreadListThreadListRuntimeCore-reload.test.ts +6 -1
- package/src/tests/base-composer-runtime-core-addAttachment.test.ts +63 -0
- package/src/tests/groupParts.test.ts +114 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
import { afterEach, describe, it, expect, vi } from "vitest";
|
|
2
|
+
import type {
|
|
3
|
+
RemoteThreadListPageOptions,
|
|
4
|
+
RemoteThreadListResponse,
|
|
5
|
+
} from "../runtimes/remote-thread-list/types";
|
|
6
|
+
import {
|
|
7
|
+
createCore,
|
|
8
|
+
deferred,
|
|
9
|
+
makeAdapter,
|
|
10
|
+
} from "./remote-thread-list-test-helpers";
|
|
11
|
+
|
|
12
|
+
type ListFn = (
|
|
13
|
+
params?: RemoteThreadListPageOptions,
|
|
14
|
+
) => Promise<RemoteThreadListResponse>;
|
|
15
|
+
|
|
16
|
+
describe("RemoteThreadListThreadListRuntimeCore.loadMore", () => {
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
vi.restoreAllMocks();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("initial list response with nextCursor sets hasMore=true", async () => {
|
|
22
|
+
const adapter = makeAdapter({
|
|
23
|
+
list: vi.fn(async () => ({
|
|
24
|
+
threads: [
|
|
25
|
+
{
|
|
26
|
+
status: "regular",
|
|
27
|
+
remoteId: "t-1",
|
|
28
|
+
externalId: "t-1",
|
|
29
|
+
title: "First",
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
nextCursor: "cursor-1",
|
|
33
|
+
})),
|
|
34
|
+
});
|
|
35
|
+
const core = createCore(adapter);
|
|
36
|
+
|
|
37
|
+
await core.getLoadThreadsPromise();
|
|
38
|
+
expect(core.threadIds).toEqual(["t-1"]);
|
|
39
|
+
expect(core.hasMore).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("absent nextCursor leaves hasMore=false", async () => {
|
|
43
|
+
const adapter = makeAdapter({
|
|
44
|
+
list: vi.fn(async () => ({
|
|
45
|
+
threads: [
|
|
46
|
+
{
|
|
47
|
+
status: "regular",
|
|
48
|
+
remoteId: "t-1",
|
|
49
|
+
externalId: "t-1",
|
|
50
|
+
title: "Only",
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
})),
|
|
54
|
+
});
|
|
55
|
+
const core = createCore(adapter);
|
|
56
|
+
|
|
57
|
+
await core.getLoadThreadsPromise();
|
|
58
|
+
expect(core.hasMore).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("loadMore appends the next page and advances the cursor", async () => {
|
|
62
|
+
const listFn = vi
|
|
63
|
+
.fn<ListFn>()
|
|
64
|
+
.mockResolvedValueOnce({
|
|
65
|
+
threads: [
|
|
66
|
+
{ status: "regular", remoteId: "p1-a", externalId: "p1-a" },
|
|
67
|
+
{ status: "regular", remoteId: "p1-b", externalId: "p1-b" },
|
|
68
|
+
],
|
|
69
|
+
nextCursor: "c1",
|
|
70
|
+
})
|
|
71
|
+
.mockResolvedValueOnce({
|
|
72
|
+
threads: [
|
|
73
|
+
{ status: "regular", remoteId: "p2-a", externalId: "p2-a" },
|
|
74
|
+
{ status: "regular", remoteId: "p2-b", externalId: "p2-b" },
|
|
75
|
+
],
|
|
76
|
+
nextCursor: "c2",
|
|
77
|
+
});
|
|
78
|
+
const adapter = makeAdapter({ list: listFn });
|
|
79
|
+
const core = createCore(adapter);
|
|
80
|
+
|
|
81
|
+
await core.getLoadThreadsPromise();
|
|
82
|
+
expect(core.threadIds).toEqual(["p1-a", "p1-b"]);
|
|
83
|
+
expect(core.hasMore).toBe(true);
|
|
84
|
+
|
|
85
|
+
await core.loadMore();
|
|
86
|
+
|
|
87
|
+
expect(listFn).toHaveBeenNthCalledWith(2, { after: "c1" });
|
|
88
|
+
expect(core.threadIds).toEqual(["p1-a", "p1-b", "p2-a", "p2-b"]);
|
|
89
|
+
expect(core.hasMore).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("loadMore without nextCursor flips hasMore to false", async () => {
|
|
93
|
+
const listFn = vi
|
|
94
|
+
.fn<ListFn>()
|
|
95
|
+
.mockResolvedValueOnce({
|
|
96
|
+
threads: [{ status: "regular", remoteId: "a", externalId: "a" }],
|
|
97
|
+
nextCursor: "c1",
|
|
98
|
+
})
|
|
99
|
+
.mockResolvedValueOnce({
|
|
100
|
+
threads: [{ status: "regular", remoteId: "b", externalId: "b" }],
|
|
101
|
+
});
|
|
102
|
+
const adapter = makeAdapter({ list: listFn });
|
|
103
|
+
const core = createCore(adapter);
|
|
104
|
+
|
|
105
|
+
await core.getLoadThreadsPromise();
|
|
106
|
+
await core.loadMore();
|
|
107
|
+
|
|
108
|
+
expect(core.hasMore).toBe(false);
|
|
109
|
+
expect(core.threadIds).toEqual(["a", "b"]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("loadMore is a no-op when hasMore is false", async () => {
|
|
113
|
+
const listFn = vi.fn<ListFn>().mockResolvedValueOnce({
|
|
114
|
+
threads: [{ status: "regular", remoteId: "a", externalId: "a" }],
|
|
115
|
+
});
|
|
116
|
+
const adapter = makeAdapter({ list: listFn });
|
|
117
|
+
const core = createCore(adapter);
|
|
118
|
+
|
|
119
|
+
await core.getLoadThreadsPromise();
|
|
120
|
+
expect(core.hasMore).toBe(false);
|
|
121
|
+
|
|
122
|
+
await core.loadMore();
|
|
123
|
+
expect(listFn).toHaveBeenCalledTimes(1);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("loadMore is a no-op while the initial list is in flight", async () => {
|
|
127
|
+
const first = deferred<RemoteThreadListResponse>();
|
|
128
|
+
const listFn = vi.fn<ListFn>().mockReturnValueOnce(first.promise);
|
|
129
|
+
const adapter = makeAdapter({ list: listFn });
|
|
130
|
+
const core = createCore(adapter);
|
|
131
|
+
|
|
132
|
+
core.getLoadThreadsPromise();
|
|
133
|
+
await core.loadMore();
|
|
134
|
+
|
|
135
|
+
expect(listFn).toHaveBeenCalledTimes(1);
|
|
136
|
+
first.resolve({ threads: [] });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("concurrent loadMore calls dedupe to a single in-flight request", async () => {
|
|
140
|
+
const first = deferred<RemoteThreadListResponse>();
|
|
141
|
+
const listFn = vi
|
|
142
|
+
.fn<ListFn>()
|
|
143
|
+
.mockResolvedValueOnce({
|
|
144
|
+
threads: [{ status: "regular", remoteId: "a", externalId: "a" }],
|
|
145
|
+
nextCursor: "c1",
|
|
146
|
+
})
|
|
147
|
+
.mockReturnValueOnce(first.promise);
|
|
148
|
+
const adapter = makeAdapter({ list: listFn });
|
|
149
|
+
const core = createCore(adapter);
|
|
150
|
+
|
|
151
|
+
await core.getLoadThreadsPromise();
|
|
152
|
+
|
|
153
|
+
const p1 = core.loadMore();
|
|
154
|
+
const p2 = core.loadMore();
|
|
155
|
+
expect(listFn).toHaveBeenCalledTimes(2);
|
|
156
|
+
|
|
157
|
+
first.resolve({
|
|
158
|
+
threads: [{ status: "regular", remoteId: "b", externalId: "b" }],
|
|
159
|
+
});
|
|
160
|
+
await Promise.all([p1, p2]);
|
|
161
|
+
|
|
162
|
+
expect(listFn).toHaveBeenCalledTimes(2);
|
|
163
|
+
expect(core.threadIds).toEqual(["a", "b"]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("drops a stale loadMore when reload bumps the generation mid-flight", async () => {
|
|
167
|
+
const loadMoreCall = deferred<RemoteThreadListResponse>();
|
|
168
|
+
const listFn = vi
|
|
169
|
+
.fn<ListFn>()
|
|
170
|
+
.mockResolvedValueOnce({
|
|
171
|
+
threads: [{ status: "regular", remoteId: "a", externalId: "a" }],
|
|
172
|
+
nextCursor: "c1",
|
|
173
|
+
})
|
|
174
|
+
.mockReturnValueOnce(loadMoreCall.promise)
|
|
175
|
+
.mockResolvedValueOnce({
|
|
176
|
+
threads: [
|
|
177
|
+
{ status: "regular", remoteId: "fresh", externalId: "fresh" },
|
|
178
|
+
],
|
|
179
|
+
});
|
|
180
|
+
const adapter = makeAdapter({ list: listFn });
|
|
181
|
+
const core = createCore(adapter);
|
|
182
|
+
|
|
183
|
+
await core.getLoadThreadsPromise();
|
|
184
|
+
|
|
185
|
+
const stale = core.loadMore();
|
|
186
|
+
const reloaded = core.reload();
|
|
187
|
+
|
|
188
|
+
loadMoreCall.resolve({
|
|
189
|
+
threads: [{ status: "regular", remoteId: "stale", externalId: "stale" }],
|
|
190
|
+
nextCursor: "stale-cursor",
|
|
191
|
+
});
|
|
192
|
+
await Promise.all([stale, reloaded]);
|
|
193
|
+
|
|
194
|
+
expect(core.threadIds).toEqual(["fresh"]);
|
|
195
|
+
expect(core.hasMore).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("releases the dedup handle after a rejection so retries can proceed", async () => {
|
|
199
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
200
|
+
const listFn = vi
|
|
201
|
+
.fn<ListFn>()
|
|
202
|
+
.mockResolvedValueOnce({
|
|
203
|
+
threads: [{ status: "regular", remoteId: "a", externalId: "a" }],
|
|
204
|
+
nextCursor: "c1",
|
|
205
|
+
})
|
|
206
|
+
.mockRejectedValueOnce(new Error("network"))
|
|
207
|
+
.mockResolvedValueOnce({
|
|
208
|
+
threads: [{ status: "regular", remoteId: "b", externalId: "b" }],
|
|
209
|
+
});
|
|
210
|
+
const adapter = makeAdapter({ list: listFn });
|
|
211
|
+
const core = createCore(adapter);
|
|
212
|
+
|
|
213
|
+
await core.getLoadThreadsPromise();
|
|
214
|
+
await core.loadMore();
|
|
215
|
+
expect(core.threadIds).toEqual(["a"]);
|
|
216
|
+
expect(core.isLoadingMore).toBe(false);
|
|
217
|
+
|
|
218
|
+
await core.loadMore();
|
|
219
|
+
expect(core.threadIds).toEqual(["a", "b"]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("dedupes thread ids that appear on more than one page", async () => {
|
|
223
|
+
const listFn = vi
|
|
224
|
+
.fn<ListFn>()
|
|
225
|
+
.mockResolvedValueOnce({
|
|
226
|
+
threads: [{ status: "regular", remoteId: "a", externalId: "a" }],
|
|
227
|
+
nextCursor: "c1",
|
|
228
|
+
})
|
|
229
|
+
.mockResolvedValueOnce({
|
|
230
|
+
threads: [
|
|
231
|
+
{ status: "regular", remoteId: "a", externalId: "a" },
|
|
232
|
+
{ status: "regular", remoteId: "b", externalId: "b" },
|
|
233
|
+
],
|
|
234
|
+
});
|
|
235
|
+
const adapter = makeAdapter({ list: listFn });
|
|
236
|
+
const core = createCore(adapter);
|
|
237
|
+
|
|
238
|
+
await core.getLoadThreadsPromise();
|
|
239
|
+
await core.loadMore();
|
|
240
|
+
|
|
241
|
+
expect(core.threadIds).toEqual(["a", "b"]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("__internal_setOptions clears cursor and dedup handles on adapter swap, then refetches via the new adapter", async () => {
|
|
245
|
+
const firstAdapter = makeAdapter({
|
|
246
|
+
list: vi.fn(async () => ({
|
|
247
|
+
threads: [{ status: "regular", remoteId: "old", externalId: "old" }],
|
|
248
|
+
nextCursor: "old-cursor",
|
|
249
|
+
})),
|
|
250
|
+
});
|
|
251
|
+
const core = createCore(firstAdapter);
|
|
252
|
+
await core.getLoadThreadsPromise();
|
|
253
|
+
expect(core.threadIds).toEqual(["old"]);
|
|
254
|
+
expect(core.hasMore).toBe(true);
|
|
255
|
+
|
|
256
|
+
const secondList = vi.fn(async () => ({
|
|
257
|
+
threads: [{ status: "regular", remoteId: "new", externalId: "new" }],
|
|
258
|
+
}));
|
|
259
|
+
const secondAdapter = makeAdapter({ list: secondList });
|
|
260
|
+
core.__internal_setOptions({
|
|
261
|
+
adapter: secondAdapter,
|
|
262
|
+
runtimeHook: () => ({}) as never,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
expect(core.hasMore).toBe(false);
|
|
266
|
+
expect(core.threadIds).toEqual(["old"]);
|
|
267
|
+
|
|
268
|
+
await core.getLoadThreadsPromise();
|
|
269
|
+
expect(secondList).toHaveBeenCalledTimes(1);
|
|
270
|
+
expect(core.threadIds).toEqual(["new"]);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("ignores an in-flight loadMore response when the adapter swaps mid-flight", async () => {
|
|
274
|
+
const slow = deferred<RemoteThreadListResponse>();
|
|
275
|
+
const firstList = vi
|
|
276
|
+
.fn<
|
|
277
|
+
(
|
|
278
|
+
params?: RemoteThreadListPageOptions,
|
|
279
|
+
) => Promise<RemoteThreadListResponse>
|
|
280
|
+
>()
|
|
281
|
+
.mockResolvedValueOnce({
|
|
282
|
+
threads: [{ status: "regular", remoteId: "p1", externalId: "p1" }],
|
|
283
|
+
nextCursor: "c1",
|
|
284
|
+
})
|
|
285
|
+
.mockReturnValueOnce(slow.promise);
|
|
286
|
+
const firstAdapter = makeAdapter({ list: firstList });
|
|
287
|
+
const core = createCore(firstAdapter);
|
|
288
|
+
|
|
289
|
+
await core.getLoadThreadsPromise();
|
|
290
|
+
const stale = core.loadMore();
|
|
291
|
+
|
|
292
|
+
const secondAdapter = makeAdapter({
|
|
293
|
+
list: vi.fn(async () => ({
|
|
294
|
+
threads: [{ status: "regular", remoteId: "ignored", externalId: "x" }],
|
|
295
|
+
})),
|
|
296
|
+
});
|
|
297
|
+
core.__internal_setOptions({
|
|
298
|
+
adapter: secondAdapter,
|
|
299
|
+
runtimeHook: () => ({}) as never,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
slow.resolve({
|
|
303
|
+
threads: [{ status: "regular", remoteId: "stale", externalId: "stale" }],
|
|
304
|
+
nextCursor: "stale-cursor",
|
|
305
|
+
});
|
|
306
|
+
await stale;
|
|
307
|
+
|
|
308
|
+
expect(core.threadIds).toEqual(["p1"]);
|
|
309
|
+
expect(core.hasMore).toBe(false);
|
|
310
|
+
expect(core.isLoadingMore).toBe(false);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("drops the in-flight initial list when the adapter swaps mid-flight", async () => {
|
|
314
|
+
const slow = deferred<RemoteThreadListResponse>();
|
|
315
|
+
const firstList = vi.fn<ListFn>().mockReturnValueOnce(slow.promise);
|
|
316
|
+
const firstAdapter = makeAdapter({ list: firstList });
|
|
317
|
+
const core = createCore(firstAdapter);
|
|
318
|
+
|
|
319
|
+
core.getLoadThreadsPromise();
|
|
320
|
+
|
|
321
|
+
const secondList = vi.fn<ListFn>().mockResolvedValueOnce({
|
|
322
|
+
threads: [{ status: "regular", remoteId: "fresh", externalId: "fresh" }],
|
|
323
|
+
});
|
|
324
|
+
const secondAdapter = makeAdapter({ list: secondList });
|
|
325
|
+
core.__internal_setOptions({
|
|
326
|
+
adapter: secondAdapter,
|
|
327
|
+
runtimeHook: () => ({}) as never,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
slow.resolve({
|
|
331
|
+
threads: [{ status: "regular", remoteId: "stale", externalId: "stale" }],
|
|
332
|
+
nextCursor: "stale-cursor",
|
|
333
|
+
});
|
|
334
|
+
await Promise.resolve();
|
|
335
|
+
await Promise.resolve();
|
|
336
|
+
|
|
337
|
+
expect(core.threadIds).toEqual([]);
|
|
338
|
+
expect(core.hasMore).toBe(false);
|
|
339
|
+
|
|
340
|
+
await core.getLoadThreadsPromise();
|
|
341
|
+
expect(secondList).toHaveBeenCalledTimes(1);
|
|
342
|
+
expect(core.threadIds).toEqual(["fresh"]);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("dedupes thread ids that appear twice within a single page", async () => {
|
|
346
|
+
const listFn = vi
|
|
347
|
+
.fn<ListFn>()
|
|
348
|
+
.mockResolvedValueOnce({
|
|
349
|
+
threads: [{ status: "regular", remoteId: "a", externalId: "a" }],
|
|
350
|
+
nextCursor: "c1",
|
|
351
|
+
})
|
|
352
|
+
.mockResolvedValueOnce({
|
|
353
|
+
threads: [
|
|
354
|
+
{ status: "regular", remoteId: "b", externalId: "b" },
|
|
355
|
+
{ status: "regular", remoteId: "b", externalId: "b" },
|
|
356
|
+
],
|
|
357
|
+
});
|
|
358
|
+
const adapter = makeAdapter({ list: listFn });
|
|
359
|
+
const core = createCore(adapter);
|
|
360
|
+
|
|
361
|
+
await core.getLoadThreadsPromise();
|
|
362
|
+
await core.loadMore();
|
|
363
|
+
|
|
364
|
+
expect(core.threadIds).toEqual(["a", "b"]);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("treats an empty-string nextCursor as no more pages", async () => {
|
|
368
|
+
const listFn = vi.fn<ListFn>().mockResolvedValueOnce({
|
|
369
|
+
threads: [{ status: "regular", remoteId: "a", externalId: "a" }],
|
|
370
|
+
nextCursor: "",
|
|
371
|
+
});
|
|
372
|
+
const adapter = makeAdapter({ list: listFn });
|
|
373
|
+
const core = createCore(adapter);
|
|
374
|
+
|
|
375
|
+
await core.getLoadThreadsPromise();
|
|
376
|
+
expect(core.hasMore).toBe(false);
|
|
377
|
+
|
|
378
|
+
await core.loadMore();
|
|
379
|
+
expect(listFn).toHaveBeenCalledTimes(1);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("does not advance the cursor when the reducer rejects an unknown thread status", async () => {
|
|
383
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
384
|
+
const listFn = vi
|
|
385
|
+
.fn<ListFn>()
|
|
386
|
+
.mockResolvedValueOnce({
|
|
387
|
+
threads: [{ status: "regular", remoteId: "a", externalId: "a" }],
|
|
388
|
+
nextCursor: "c1",
|
|
389
|
+
})
|
|
390
|
+
.mockResolvedValueOnce({
|
|
391
|
+
threads: [
|
|
392
|
+
{
|
|
393
|
+
status: "weird" as unknown as "regular",
|
|
394
|
+
remoteId: "bad",
|
|
395
|
+
externalId: "bad",
|
|
396
|
+
},
|
|
397
|
+
],
|
|
398
|
+
nextCursor: "c2",
|
|
399
|
+
})
|
|
400
|
+
.mockResolvedValueOnce({
|
|
401
|
+
threads: [
|
|
402
|
+
{ status: "regular", remoteId: "retry", externalId: "retry" },
|
|
403
|
+
],
|
|
404
|
+
});
|
|
405
|
+
const adapter = makeAdapter({ list: listFn });
|
|
406
|
+
const core = createCore(adapter);
|
|
407
|
+
|
|
408
|
+
await core.getLoadThreadsPromise();
|
|
409
|
+
expect(core.hasMore).toBe(true);
|
|
410
|
+
|
|
411
|
+
await core.loadMore();
|
|
412
|
+
|
|
413
|
+
expect(core.threadIds).toEqual(["a"]);
|
|
414
|
+
expect(core.hasMore).toBe(true);
|
|
415
|
+
expect(core.isLoadingMore).toBe(false);
|
|
416
|
+
|
|
417
|
+
await core.loadMore();
|
|
418
|
+
expect(listFn).toHaveBeenNthCalledWith(2, { after: "c1" });
|
|
419
|
+
expect(listFn).toHaveBeenNthCalledWith(3, { after: "c1" });
|
|
420
|
+
expect(core.threadIds).toEqual(["a", "retry"]);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("reload resets cursor and hasMore so loadMore becomes a no-op", async () => {
|
|
424
|
+
const listFn = vi
|
|
425
|
+
.fn<ListFn>()
|
|
426
|
+
.mockResolvedValueOnce({
|
|
427
|
+
threads: [{ status: "regular", remoteId: "a", externalId: "a" }],
|
|
428
|
+
nextCursor: "c1",
|
|
429
|
+
})
|
|
430
|
+
.mockResolvedValueOnce({
|
|
431
|
+
threads: [
|
|
432
|
+
{ status: "regular", remoteId: "fresh", externalId: "fresh" },
|
|
433
|
+
],
|
|
434
|
+
});
|
|
435
|
+
const adapter = makeAdapter({ list: listFn });
|
|
436
|
+
const core = createCore(adapter);
|
|
437
|
+
|
|
438
|
+
await core.getLoadThreadsPromise();
|
|
439
|
+
expect(core.hasMore).toBe(true);
|
|
440
|
+
|
|
441
|
+
await core.reload();
|
|
442
|
+
expect(core.hasMore).toBe(false);
|
|
443
|
+
expect(core.threadIds).toEqual(["fresh"]);
|
|
444
|
+
|
|
445
|
+
await core.loadMore();
|
|
446
|
+
expect(listFn).toHaveBeenCalledTimes(2);
|
|
447
|
+
});
|
|
448
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from "vitest";
|
|
1
|
+
import { afterEach, describe, it, expect, vi } from "vitest";
|
|
2
2
|
import type { RemoteThreadListResponse } from "../runtimes/remote-thread-list/types";
|
|
3
3
|
import {
|
|
4
4
|
createCore,
|
|
@@ -7,6 +7,10 @@ import {
|
|
|
7
7
|
} from "./remote-thread-list-test-helpers";
|
|
8
8
|
|
|
9
9
|
describe("RemoteThreadListThreadListRuntimeCore.reload", () => {
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.restoreAllMocks();
|
|
12
|
+
});
|
|
13
|
+
|
|
10
14
|
it("refetches list() after a successful empty load", async () => {
|
|
11
15
|
const listFn = vi
|
|
12
16
|
.fn<() => Promise<RemoteThreadListResponse>>()
|
|
@@ -92,6 +96,7 @@ describe("RemoteThreadListThreadListRuntimeCore.reload", () => {
|
|
|
92
96
|
});
|
|
93
97
|
|
|
94
98
|
it("recovers after a failed initial load", async () => {
|
|
99
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
95
100
|
const listFn = vi
|
|
96
101
|
.fn<() => Promise<RemoteThreadListResponse>>()
|
|
97
102
|
.mockRejectedValueOnce(new Error("401"))
|
|
@@ -48,6 +48,13 @@ describe("BaseComposerRuntimeCore.addAttachment error events", () => {
|
|
|
48
48
|
).rejects.toThrow("Attachments are not supported");
|
|
49
49
|
|
|
50
50
|
expect(onError).toHaveBeenCalledTimes(1);
|
|
51
|
+
expect(onError).toHaveBeenCalledWith(
|
|
52
|
+
expect.objectContaining({
|
|
53
|
+
reason: "no-adapter",
|
|
54
|
+
message: "Attachments are not supported",
|
|
55
|
+
error: expect.any(Error),
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
51
58
|
expect(onAdd).not.toHaveBeenCalled();
|
|
52
59
|
});
|
|
53
60
|
|
|
@@ -63,6 +70,15 @@ describe("BaseComposerRuntimeCore.addAttachment error events", () => {
|
|
|
63
70
|
).rejects.toThrow(/File type text\/plain is not accepted/);
|
|
64
71
|
|
|
65
72
|
expect(onError).toHaveBeenCalledTimes(1);
|
|
73
|
+
expect(onError).toHaveBeenCalledWith(
|
|
74
|
+
expect.objectContaining({
|
|
75
|
+
reason: "not-accepted",
|
|
76
|
+
message: expect.stringContaining(
|
|
77
|
+
"File type text/plain is not accepted",
|
|
78
|
+
),
|
|
79
|
+
error: expect.any(Error),
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
66
82
|
expect(onAdd).not.toHaveBeenCalled();
|
|
67
83
|
});
|
|
68
84
|
|
|
@@ -84,6 +100,13 @@ describe("BaseComposerRuntimeCore.addAttachment error events", () => {
|
|
|
84
100
|
).rejects.toThrow("upload failed");
|
|
85
101
|
|
|
86
102
|
expect(onError).toHaveBeenCalledTimes(1);
|
|
103
|
+
expect(onError).toHaveBeenCalledWith(
|
|
104
|
+
expect.objectContaining({
|
|
105
|
+
reason: "adapter-error",
|
|
106
|
+
message: "upload failed",
|
|
107
|
+
error: expect.any(Error),
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
87
110
|
expect(onAdd).not.toHaveBeenCalled();
|
|
88
111
|
});
|
|
89
112
|
|
|
@@ -143,6 +166,14 @@ describe("BaseComposerRuntimeCore.addAttachment error events", () => {
|
|
|
143
166
|
).rejects.toThrow("network error");
|
|
144
167
|
|
|
145
168
|
expect(onError).toHaveBeenCalledTimes(1);
|
|
169
|
+
expect(onError).toHaveBeenCalledWith(
|
|
170
|
+
expect.objectContaining({
|
|
171
|
+
reason: "adapter-error",
|
|
172
|
+
message: "network error",
|
|
173
|
+
attachmentId: "att-1",
|
|
174
|
+
error: expect.any(Error),
|
|
175
|
+
}),
|
|
176
|
+
);
|
|
146
177
|
expect(onAdd).not.toHaveBeenCalled();
|
|
147
178
|
expect(composer.attachments).toHaveLength(1);
|
|
148
179
|
const att = composer.attachments[0]!;
|
|
@@ -151,4 +182,36 @@ describe("BaseComposerRuntimeCore.addAttachment error events", () => {
|
|
|
151
182
|
expect(att.status.reason).toBe("error");
|
|
152
183
|
}
|
|
153
184
|
});
|
|
185
|
+
|
|
186
|
+
it("emits attachmentAddError with attachment id when adapter yields an errored attachment", async () => {
|
|
187
|
+
const composer = makeComposer(
|
|
188
|
+
makeAdapter({
|
|
189
|
+
add: async ({ file }) => ({
|
|
190
|
+
id: "att-2",
|
|
191
|
+
type: "image",
|
|
192
|
+
name: file.name,
|
|
193
|
+
contentType: file.type,
|
|
194
|
+
file,
|
|
195
|
+
status: { type: "incomplete", reason: "error" },
|
|
196
|
+
}),
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
const onError = vi.fn();
|
|
200
|
+
const onAdd = vi.fn();
|
|
201
|
+
composer.unstable_on("attachmentAddError", onError);
|
|
202
|
+
composer.unstable_on("attachmentAdd", onAdd);
|
|
203
|
+
|
|
204
|
+
await composer.addAttachment(
|
|
205
|
+
new File(["x"], "f.png", { type: "image/png" }),
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
209
|
+
expect(onError).toHaveBeenCalledWith(
|
|
210
|
+
expect.objectContaining({
|
|
211
|
+
reason: "adapter-error",
|
|
212
|
+
attachmentId: "att-2",
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
215
|
+
expect(onAdd).not.toHaveBeenCalled();
|
|
216
|
+
});
|
|
154
217
|
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildGroupTree,
|
|
4
|
+
normalizeGroupKey,
|
|
5
|
+
type GroupNode,
|
|
6
|
+
} from "../react/utils/groupParts";
|
|
7
|
+
|
|
8
|
+
const asPaths = (keys: readonly (string | readonly string[] | null)[]) =>
|
|
9
|
+
keys.map((k) => normalizeGroupKey(k));
|
|
10
|
+
|
|
11
|
+
// Compact tree dump: "G:key#nodeKey[i,j]{...}" | "P:#nodeKey(i)"
|
|
12
|
+
const dump = (nodes: readonly GroupNode[]): string =>
|
|
13
|
+
nodes
|
|
14
|
+
.map((n) => {
|
|
15
|
+
if (n.type === "part") {
|
|
16
|
+
return `P:#${n.nodeKey}(${n.index})`;
|
|
17
|
+
}
|
|
18
|
+
const inner = dump(n.children);
|
|
19
|
+
return `G:${n.key}#${n.nodeKey}[${n.indices.join(",")}]{${inner}}`;
|
|
20
|
+
})
|
|
21
|
+
.join(",");
|
|
22
|
+
|
|
23
|
+
describe("normalizeGroupKey", () => {
|
|
24
|
+
it("maps null/undefined/[] to []", () => {
|
|
25
|
+
expect(normalizeGroupKey(null)).toEqual([]);
|
|
26
|
+
expect(normalizeGroupKey(undefined)).toEqual([]);
|
|
27
|
+
expect(normalizeGroupKey([])).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("wraps a string into a single-element array", () => {
|
|
31
|
+
expect(normalizeGroupKey("foo")).toEqual(["foo"]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("passes arrays through", () => {
|
|
35
|
+
expect(normalizeGroupKey(["a", "b"])).toEqual(["a", "b"]);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("buildGroupTree", () => {
|
|
40
|
+
it("returns an empty list for no parts", () => {
|
|
41
|
+
expect(buildGroupTree([])).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("emits one part leaf per ungrouped part (no coalescing)", () => {
|
|
45
|
+
const tree = buildGroupTree(asPaths([null, null, null]));
|
|
46
|
+
expect(dump(tree)).toBe("P:#0(0),P:#1(1),P:#2(2)");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("wraps adjacent same-key parts in one group with one part child each", () => {
|
|
50
|
+
const tree = buildGroupTree(asPaths(["a", "a", "a"]));
|
|
51
|
+
expect(dump(tree)).toBe("G:a#0[0,1,2]{P:#0.0(0),P:#0.1(1),P:#0.2(2)}");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("splits non-adjacent runs of the same key into separate groups", () => {
|
|
55
|
+
const tree = buildGroupTree(asPaths(["a", null, "a"]));
|
|
56
|
+
expect(dump(tree)).toBe("G:a#0[0]{P:#0.0(0)},P:#1(1),G:a#2[2]{P:#2.0(2)}");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("nests groups: parts at depth 1 sit alongside depth-2 subgroups", () => {
|
|
60
|
+
// ["A","B"], ["A","B"], ["A"], ["A"], ["A","C"]:
|
|
61
|
+
// Outer A spans 0..4. Inside A: a B subgroup (0,1), two depth-1 parts
|
|
62
|
+
// (2,3), then a C subgroup (4).
|
|
63
|
+
const tree = buildGroupTree(
|
|
64
|
+
asPaths([["A", "B"], ["A", "B"], ["A"], ["A"], ["A", "C"]]),
|
|
65
|
+
);
|
|
66
|
+
expect(dump(tree)).toBe(
|
|
67
|
+
"G:A#0[0,1,2,3,4]{G:B#0.0[0,1]{P:#0.0.0(0),P:#0.0.1(1)},P:#0.1(2),P:#0.2(3),G:C#0.3[4]{P:#0.3.0(4)}}",
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("treats longer prefix changes as group close+open", () => {
|
|
72
|
+
// ["A","B"], ["A","B","C"], ["A","B"]: opens C under B, closes back.
|
|
73
|
+
const tree = buildGroupTree(
|
|
74
|
+
asPaths([
|
|
75
|
+
["A", "B"],
|
|
76
|
+
["A", "B", "C"],
|
|
77
|
+
["A", "B"],
|
|
78
|
+
]),
|
|
79
|
+
);
|
|
80
|
+
expect(dump(tree)).toBe(
|
|
81
|
+
"G:A#0[0,1,2]{G:B#0.0[0,1,2]{P:#0.0.0(0),G:C#0.0.1[1]{P:#0.0.1.0(1)},P:#0.0.2(2)}}",
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("does not coalesce same-keyed groups separated by a divergent sibling", () => {
|
|
86
|
+
const tree = buildGroupTree(
|
|
87
|
+
asPaths([
|
|
88
|
+
["A", "B"],
|
|
89
|
+
["A", "C"],
|
|
90
|
+
["A", "B"],
|
|
91
|
+
]),
|
|
92
|
+
);
|
|
93
|
+
expect(dump(tree)).toBe(
|
|
94
|
+
"G:A#0[0,1,2]{G:B#0.0[0]{P:#0.0.0(0)},G:C#0.1[1]{P:#0.1.0(1)},G:B#0.2[2]{P:#0.2.0(2)}}",
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("accepts strings and arrays interchangeably via normalizeGroupKey", () => {
|
|
99
|
+
const tree = buildGroupTree([
|
|
100
|
+
normalizeGroupKey("A"),
|
|
101
|
+
normalizeGroupKey(["A"]),
|
|
102
|
+
]);
|
|
103
|
+
expect(dump(tree)).toBe("G:A#0[0,1]{P:#0.0(0),P:#0.1(1)}");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("assigns stable nodeKeys under append (existing keys do not shift)", () => {
|
|
107
|
+
const before = buildGroupTree(asPaths([["A"], null]));
|
|
108
|
+
const after = buildGroupTree(asPaths([["A"], null, ["B"]]));
|
|
109
|
+
|
|
110
|
+
expect(before[0]!.nodeKey).toBe(after[0]!.nodeKey);
|
|
111
|
+
expect(before[1]!.nodeKey).toBe(after[1]!.nodeKey);
|
|
112
|
+
expect(after[2]!.nodeKey).toBe("2");
|
|
113
|
+
});
|
|
114
|
+
});
|