@aleph-alpha/chat-kit 5.5.0 → 5.7.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/{CkHistory.vue_vue_type_script_setup_true_lang-C1busuyf.js → CkHistory.vue_vue_type_script_setup_true_lang-YHwE2DYx.js} +27 -9
- package/dist/adapters/responses-api.d.ts +3 -1
- package/dist/adapters/responses-api.d.ts.map +1 -1
- package/dist/adapters/responses-api.js +158 -3
- package/dist/chat-kit.css +1 -0
- package/dist/components/base/CkConversationLayout/CkConversationLayout.vue.d.ts +6 -0
- package/dist/components/base/CkConversationLayout/CkConversationLayout.vue.d.ts.map +1 -1
- package/dist/components/composed/CkConversation/CkConversation.vue.d.ts +6 -0
- package/dist/components/composed/CkConversation/CkConversation.vue.d.ts.map +1 -1
- package/dist/components/index.js +1 -1
- package/dist/composables/index.js +4 -4
- package/dist/handlers/index.js +4 -4
- package/dist/handlers/useMessageHandler.d.ts +7 -2
- package/dist/handlers/useMessageHandler.d.ts.map +1 -1
- package/dist/helpers/conversationTree.d.ts +44 -4
- package/dist/helpers/conversationTree.d.ts.map +1 -1
- package/dist/{index-CyrVnbS9.js → index-Dn4ctxk8.js} +7 -6
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -11
- package/dist/services/types.d.ts +7 -0
- package/dist/services/types.d.ts.map +1 -1
- package/dist/types.d.ts +38 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/{useChatKitLabels-DLy0BFx8.js → useChatKitLabels-CMOr3Kjq.js} +2 -2
- package/dist/{useMessageHandler-N50Yj-NU.js → useMessageHandler-Tellov3w.js} +112 -30
- package/package.json +4 -2
- package/src/adapters/arena.spec.ts +86 -0
- package/src/adapters/responses-api.ts +276 -2
- package/src/components/base/CkConversationLayout/CkConversationLayout.vue +28 -2
- package/src/components/composed/CkConversation/CkConversation.vue +10 -0
- package/src/composables/useConversations.spec.ts +2 -0
- package/src/composables/useConversations.ts +1 -0
- package/src/handlers/useMessageHandler.ts +46 -6
- package/src/helpers/comparison.spec.ts +114 -0
- package/src/helpers/conversationTree.spec.ts +98 -0
- package/src/helpers/conversationTree.ts +211 -26
- package/src/index.ts +10 -0
- package/src/services/types.ts +7 -0
- package/src/types.ts +40 -0
- package/src/uno-virtual.d.ts +2 -0
|
@@ -7,9 +7,15 @@ const props = defineProps<{
|
|
|
7
7
|
messages: T[];
|
|
8
8
|
}>();
|
|
9
9
|
|
|
10
|
-
defineSlots<{
|
|
10
|
+
const slots = defineSlots<{
|
|
11
11
|
'user-message': (props: { message: T }) => void;
|
|
12
12
|
'agent-message': (props: { message: T }) => void;
|
|
13
|
+
/**
|
|
14
|
+
* Extra content appended after the last message, inside the scroll flow and
|
|
15
|
+
* aligned like an agent turn. Use for a trailing element that continues the
|
|
16
|
+
* thread but isn't a tree message (e.g. an in-progress preference comparison).
|
|
17
|
+
*/
|
|
18
|
+
'thread-footer': () => void;
|
|
13
19
|
}>();
|
|
14
20
|
|
|
15
21
|
const scrollContainerRef = ref<HTMLElement | null>(null);
|
|
@@ -188,12 +194,23 @@ watch(
|
|
|
188
194
|
|
|
189
195
|
// Follow agent content: handles both new agent messages and streaming updates.
|
|
190
196
|
// First shrinks the spacer to match the growing content, then scrolls when it overflows.
|
|
197
|
+
//
|
|
198
|
+
// The trailing content can also be the `thread-footer` slot (e.g. streaming
|
|
199
|
+
// preference-comparison lanes), which continues the thread after a trunk whose
|
|
200
|
+
// last message is the shared *user* prompt. That case must run the same
|
|
201
|
+
// spacer-shrink/follow pass — otherwise the full-viewport spacer set by
|
|
202
|
+
// `scrollUserMessageToTop` is never reduced, leaving a viewport-sized gap that
|
|
203
|
+
// pushes the prompt and answers out of view. `updateSpacerHeight` already
|
|
204
|
+
// measures the footer (it carries `data-message-row="agent"`, so it is the
|
|
205
|
+
// `lastRow`); it just needs to be invoked here. The watch fires on every tree
|
|
206
|
+
// mutation because the messages array is recreated per projection.
|
|
191
207
|
watch(
|
|
192
208
|
() => props.messages,
|
|
193
209
|
() => {
|
|
194
210
|
if (props.messages.length === 0) return;
|
|
195
211
|
const lastMessage = props.messages[props.messages.length - 1];
|
|
196
|
-
|
|
212
|
+
const hasFooter = Boolean(slots['thread-footer']);
|
|
213
|
+
if ((lastMessage && lastMessage.role !== 'user') || hasFooter) {
|
|
197
214
|
nextTick(() => {
|
|
198
215
|
updateSpacerHeight();
|
|
199
216
|
if (isAutoScrollEnabled.value) {
|
|
@@ -233,6 +250,15 @@ watch(
|
|
|
233
250
|
<slot name="agent-message" :message="message" />
|
|
234
251
|
</div>
|
|
235
252
|
</div>
|
|
253
|
+
<!-- Trailing non-message content (e.g. an open comparison), rendered as
|
|
254
|
+
the continuation of the thread inside the scroll flow. -->
|
|
255
|
+
<div
|
|
256
|
+
v-if="$slots['thread-footer']"
|
|
257
|
+
data-message-row="agent"
|
|
258
|
+
class="flex min-w-0 justify-start"
|
|
259
|
+
>
|
|
260
|
+
<slot name="thread-footer" />
|
|
261
|
+
</div>
|
|
236
262
|
<!-- Bottom spacer: shrinks as agent content grows to keep viewport stable -->
|
|
237
263
|
<div
|
|
238
264
|
v-if="hasBottomSpacer"
|
|
@@ -58,6 +58,12 @@ const slots = defineSlots<{
|
|
|
58
58
|
code: string;
|
|
59
59
|
language: string;
|
|
60
60
|
}) => void;
|
|
61
|
+
/**
|
|
62
|
+
* Trailing content appended after the last message, inside the scroll flow
|
|
63
|
+
* and aligned like an agent turn — e.g. an in-progress preference comparison
|
|
64
|
+
* that continues the thread but isn't a tree message.
|
|
65
|
+
*/
|
|
66
|
+
'thread-footer': () => void;
|
|
61
67
|
}>();
|
|
62
68
|
|
|
63
69
|
const labels = useChatKitLabels();
|
|
@@ -229,5 +235,9 @@ function hasHeaderActivity(message: BaseMessage): boolean {
|
|
|
229
235
|
</CkAgentMessage>
|
|
230
236
|
</slot>
|
|
231
237
|
</template>
|
|
238
|
+
|
|
239
|
+
<template v-if="$slots['thread-footer']" #thread-footer>
|
|
240
|
+
<slot name="thread-footer" />
|
|
241
|
+
</template>
|
|
232
242
|
</CkConversationLayout>
|
|
233
243
|
</template>
|
|
@@ -80,6 +80,7 @@ describe('useConversations', () => {
|
|
|
80
80
|
title: 'hey again',
|
|
81
81
|
createdAt: new Date(1_777_456_746 * 1000),
|
|
82
82
|
updatedAt: new Date(1_777_456_750 * 1000),
|
|
83
|
+
metadata: { title: 'hey again' },
|
|
83
84
|
});
|
|
84
85
|
});
|
|
85
86
|
|
|
@@ -290,6 +291,7 @@ describe('useConversations', () => {
|
|
|
290
291
|
title: 'far away',
|
|
291
292
|
createdAt: new Date(1_777_456_700 * 1000),
|
|
292
293
|
updatedAt: new Date(1_777_456_701 * 1000),
|
|
294
|
+
metadata: { title: 'far away' },
|
|
293
295
|
});
|
|
294
296
|
});
|
|
295
297
|
|
|
@@ -4,12 +4,15 @@ import {
|
|
|
4
4
|
createEmptyTree,
|
|
5
5
|
cutBranchAfter as treeCutBranchAfter,
|
|
6
6
|
cutBranchAt as treeCutBranchAt,
|
|
7
|
-
|
|
7
|
+
markComparison as treeMarkComparison,
|
|
8
8
|
navigate as treeNavigate,
|
|
9
|
+
projectActivePath,
|
|
9
10
|
updateNode,
|
|
10
11
|
updateNodeId,
|
|
12
|
+
voteComparison as treeVoteComparison,
|
|
11
13
|
} from '../helpers';
|
|
12
14
|
import type {
|
|
15
|
+
ActiveComparison,
|
|
13
16
|
BaseMessage,
|
|
14
17
|
ConversationTree,
|
|
15
18
|
MessageContent,
|
|
@@ -24,10 +27,20 @@ export const useMessageHandler = () => {
|
|
|
24
27
|
const tree = reactive<ConversationTree>(createEmptyTree());
|
|
25
28
|
const currentUserMessage = ref('');
|
|
26
29
|
|
|
27
|
-
|
|
30
|
+
// One walk produces the linear trunk and (at most) one active comparison, so
|
|
31
|
+
// the two projections can never drift.
|
|
32
|
+
const projection = computed(() => projectActivePath(tree));
|
|
33
|
+
const messages = computed<BaseMessage[]>(() => projection.value.trunk);
|
|
34
|
+
const activeComparison = computed<ActiveComparison | null>(
|
|
35
|
+
() => projection.value.activeComparison,
|
|
36
|
+
);
|
|
28
37
|
|
|
29
38
|
const currentLeafId = computed<string | null>(() => {
|
|
30
|
-
|
|
39
|
+
// No single leaf while a comparison is open at the tail — sending is gated
|
|
40
|
+
// on `activeComparison` and "continue with winner" passes an explicit
|
|
41
|
+
// parentId (the winner branch).
|
|
42
|
+
if (projection.value.activeComparison) return null;
|
|
43
|
+
const list = projection.value.trunk;
|
|
31
44
|
return list.length > 0 ? list[list.length - 1].id : null;
|
|
32
45
|
});
|
|
33
46
|
|
|
@@ -114,10 +127,18 @@ export const useMessageHandler = () => {
|
|
|
114
127
|
status: MessageStatus;
|
|
115
128
|
metadata?: MessageMetadata;
|
|
116
129
|
parentId?: string;
|
|
130
|
+
comparing?: boolean;
|
|
117
131
|
}) {
|
|
118
132
|
const existing = tree.nodes[message.id];
|
|
119
133
|
|
|
120
134
|
if (existing) {
|
|
135
|
+
// `parentId` is ignored for an already-present node (it is never
|
|
136
|
+
// re-linked), so resolve it only to mark a comparison — and use the node's
|
|
137
|
+
// *actual* parent, so a stale/unknown `parentId` on the patch is a safe
|
|
138
|
+
// no-op rather than an `Unknown parent message id` throw.
|
|
139
|
+
if (message.comparing && existing.parentId) {
|
|
140
|
+
treeMarkComparison(tree, existing.parentId);
|
|
141
|
+
}
|
|
121
142
|
updateNode(tree, message.id, {
|
|
122
143
|
content: message.content,
|
|
123
144
|
role: message.role,
|
|
@@ -127,9 +148,13 @@ export const useMessageHandler = () => {
|
|
|
127
148
|
: {}),
|
|
128
149
|
});
|
|
129
150
|
} else {
|
|
151
|
+
const parentId = resolveParentId(message.parentId);
|
|
152
|
+
if (message.comparing && parentId) {
|
|
153
|
+
treeMarkComparison(tree, parentId);
|
|
154
|
+
}
|
|
130
155
|
const node: TreeMessage = {
|
|
131
156
|
id: message.id,
|
|
132
|
-
parentId
|
|
157
|
+
parentId,
|
|
133
158
|
childrenIds: [],
|
|
134
159
|
activeChildIndex: 0,
|
|
135
160
|
role: message.role,
|
|
@@ -187,9 +212,13 @@ export const useMessageHandler = () => {
|
|
|
187
212
|
/**
|
|
188
213
|
* Drop every message that comes after `messageId` on every branch beneath
|
|
189
214
|
* it. The target message is kept and becomes the new leaf of its branch.
|
|
215
|
+
*
|
|
216
|
+
* Pass `keepChildId` (a direct child of `messageId`) to spare one branch: it
|
|
217
|
+
* survives as the target's sole, active child while its siblings are dropped
|
|
218
|
+
* — e.g. keeping a chosen comparison winner and discarding the rest.
|
|
190
219
|
*/
|
|
191
|
-
function cutBranchAfter(messageId: string) {
|
|
192
|
-
treeCutBranchAfter(tree, messageId);
|
|
220
|
+
function cutBranchAfter(messageId: string, keepChildId?: string) {
|
|
221
|
+
treeCutBranchAfter(tree, messageId, keepChildId);
|
|
193
222
|
}
|
|
194
223
|
|
|
195
224
|
/**
|
|
@@ -205,8 +234,18 @@ export const useMessageHandler = () => {
|
|
|
205
234
|
return readonly(tree);
|
|
206
235
|
}
|
|
207
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Record the winner of the comparison rooted at `parentId` and reveal it.
|
|
239
|
+
* Does not collapse the comparison — it stays a labelled, revealed view
|
|
240
|
+
* until the user continues past the winner (appends a turn under it).
|
|
241
|
+
*/
|
|
242
|
+
function voteComparison(parentId: string, winnerBranchId: string) {
|
|
243
|
+
treeVoteComparison(tree, parentId, winnerBranchId);
|
|
244
|
+
}
|
|
245
|
+
|
|
208
246
|
return {
|
|
209
247
|
messages: messages as Ref<BaseMessage[]>,
|
|
248
|
+
activeComparison,
|
|
210
249
|
currentUserMessage: readonly(currentUserMessage),
|
|
211
250
|
currentLeafId,
|
|
212
251
|
updateMessage,
|
|
@@ -220,6 +259,7 @@ export const useMessageHandler = () => {
|
|
|
220
259
|
navigate,
|
|
221
260
|
cutBranchAfter,
|
|
222
261
|
cutBranchAt,
|
|
262
|
+
voteComparison,
|
|
223
263
|
getTree,
|
|
224
264
|
};
|
|
225
265
|
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
addNode,
|
|
5
|
+
createEmptyTree,
|
|
6
|
+
markComparison,
|
|
7
|
+
projectActivePath,
|
|
8
|
+
voteComparison,
|
|
9
|
+
} from './conversationTree';
|
|
10
|
+
import type { ConversationTree, MessageContent, TreeMessage } from '../types';
|
|
11
|
+
|
|
12
|
+
function node(
|
|
13
|
+
id: string,
|
|
14
|
+
parentId: string | null,
|
|
15
|
+
role: TreeMessage['role'],
|
|
16
|
+
text = id,
|
|
17
|
+
): TreeMessage {
|
|
18
|
+
const content: MessageContent[] = [
|
|
19
|
+
{ type: 'text', id: `${id}-t`, text, status: 'completed' },
|
|
20
|
+
];
|
|
21
|
+
return {
|
|
22
|
+
id,
|
|
23
|
+
parentId,
|
|
24
|
+
childrenIds: [],
|
|
25
|
+
activeChildIndex: 0,
|
|
26
|
+
role,
|
|
27
|
+
content,
|
|
28
|
+
status: 'completed',
|
|
29
|
+
createdAt: new Date(0),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** user U with two assistant children A/B, marked as a comparison. */
|
|
34
|
+
function comparisonTree(): ConversationTree {
|
|
35
|
+
const tree = createEmptyTree();
|
|
36
|
+
addNode(tree, node('U', null, 'user', 'prompt'));
|
|
37
|
+
addNode(tree, node('A', 'U', 'assistant', 'answer A'), { activate: false });
|
|
38
|
+
addNode(tree, node('B', 'U', 'assistant', 'answer B'), { activate: false });
|
|
39
|
+
markComparison(tree, 'U');
|
|
40
|
+
return tree;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('projectActivePath', () => {
|
|
44
|
+
it('surfaces an unresolved comparison as blind lanes at the trunk tail', () => {
|
|
45
|
+
const { trunk, activeComparison } = projectActivePath(comparisonTree());
|
|
46
|
+
|
|
47
|
+
expect(trunk.map((m) => m.id)).toEqual(['U']);
|
|
48
|
+
expect(activeComparison).not.toBeNull();
|
|
49
|
+
expect(activeComparison!.resolved).toBe(false);
|
|
50
|
+
expect(activeComparison!.winnerBranchId).toBeNull();
|
|
51
|
+
expect(activeComparison!.lanes.map((l) => l.branchId)).toEqual(['A', 'B']);
|
|
52
|
+
expect(activeComparison!.lanes[0]!.path.map((m) => m.id)).toEqual(['A']);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('keeps the comparison active + revealed after a vote (not yet continued)', () => {
|
|
56
|
+
const tree = comparisonTree();
|
|
57
|
+
voteComparison(tree, 'U', 'A');
|
|
58
|
+
|
|
59
|
+
const { trunk, activeComparison } = projectActivePath(tree);
|
|
60
|
+
expect(trunk.map((m) => m.id)).toEqual(['U']);
|
|
61
|
+
expect(activeComparison).not.toBeNull();
|
|
62
|
+
expect(activeComparison!.resolved).toBe(true);
|
|
63
|
+
expect(activeComparison!.winnerBranchId).toBe('A');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('collapses to a linear winner chain once the winner is continued', () => {
|
|
67
|
+
const tree = comparisonTree();
|
|
68
|
+
voteComparison(tree, 'U', 'A');
|
|
69
|
+
// Continue past the winner: a follow-up turn under A.
|
|
70
|
+
addNode(tree, node('U2', 'A', 'user', 'follow up'));
|
|
71
|
+
|
|
72
|
+
const { trunk, activeComparison } = projectActivePath(tree);
|
|
73
|
+
expect(activeComparison).toBeNull();
|
|
74
|
+
// History: the fork is transparent, trunk flows through the winner.
|
|
75
|
+
expect(trunk.map((m) => m.id)).toEqual(['U', 'A', 'U2']);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('does not treat ordinary regenerate siblings as a comparison', () => {
|
|
79
|
+
const tree = createEmptyTree();
|
|
80
|
+
addNode(tree, node('U', null, 'user'));
|
|
81
|
+
addNode(tree, node('A1', 'U', 'assistant'));
|
|
82
|
+
addNode(tree, node('A2', 'U', 'assistant')); // regenerate: latest active
|
|
83
|
+
const { trunk, activeComparison } = projectActivePath(tree);
|
|
84
|
+
expect(activeComparison).toBeNull();
|
|
85
|
+
expect(trunk.map((m) => m.id)).toEqual(['U', 'A2']);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('addNode activate flag', () => {
|
|
90
|
+
it('does not move activeChildIndex when activate:false', () => {
|
|
91
|
+
const tree = createEmptyTree();
|
|
92
|
+
addNode(tree, node('U', null, 'user'));
|
|
93
|
+
addNode(tree, node('A', 'U', 'assistant'), { activate: false });
|
|
94
|
+
addNode(tree, node('B', 'U', 'assistant'), { activate: false });
|
|
95
|
+
expect(tree.nodes['U']!.activeChildIndex).toBe(0);
|
|
96
|
+
expect(tree.nodes['U']!.childrenIds).toEqual(['A', 'B']);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('voteComparison', () => {
|
|
101
|
+
it('records the winner and points activeChildIndex at it', () => {
|
|
102
|
+
const tree = comparisonTree();
|
|
103
|
+
voteComparison(tree, 'U', 'B');
|
|
104
|
+
expect(tree.nodes['U']!.comparisonWinnerId).toBe('B');
|
|
105
|
+
expect(tree.nodes['U']!.activeChildIndex).toBe(1);
|
|
106
|
+
expect(tree.nodes['U']!.comparing).toBe(true); // stays a comparison
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('is a no-op for an unknown winner id', () => {
|
|
110
|
+
const tree = comparisonTree();
|
|
111
|
+
voteComparison(tree, 'U', 'nope');
|
|
112
|
+
expect(tree.nodes['U']!.comparisonWinnerId).toBeUndefined();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -518,6 +518,67 @@ describe('cutBranchAfter', () => {
|
|
|
518
518
|
|
|
519
519
|
expect(Object.keys(tree.nodes).sort()).toEqual(before);
|
|
520
520
|
});
|
|
521
|
+
|
|
522
|
+
it('clears comparison state on the target — no children left to compare', () => {
|
|
523
|
+
const tree = makeBranchingTree();
|
|
524
|
+
tree.nodes.u2.comparing = true;
|
|
525
|
+
tree.nodes.u2.comparisonWinnerId = 'a2';
|
|
526
|
+
|
|
527
|
+
cutBranchAfter(tree, 'u2');
|
|
528
|
+
|
|
529
|
+
expect(tree.nodes.u2.comparing).toBeUndefined();
|
|
530
|
+
expect(tree.nodes.u2.comparisonWinnerId).toBeUndefined();
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it('spares the exempted child and makes it the sole active child', () => {
|
|
534
|
+
const tree = makeBranchingTree();
|
|
535
|
+
// u2 has children [a2, a3]; keep a3.
|
|
536
|
+
cutBranchAfter(tree, 'u2', 'a3');
|
|
537
|
+
|
|
538
|
+
expect(tree.nodes.a2).toBeUndefined();
|
|
539
|
+
expect(tree.nodes.a3).toBeDefined();
|
|
540
|
+
expect(tree.nodes.u2.childrenIds).toEqual(['a3']);
|
|
541
|
+
expect(tree.nodes.u2.activeChildIndex).toBe(0);
|
|
542
|
+
expect(getLinearPath(tree).map((m) => m.id)).toEqual([
|
|
543
|
+
'u1',
|
|
544
|
+
'a1',
|
|
545
|
+
'u2',
|
|
546
|
+
'a3',
|
|
547
|
+
]);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('keeps the exempted child subtree intact', () => {
|
|
551
|
+
const tree = makeBranchingTree();
|
|
552
|
+
// Give a3 a follow-up so we can confirm the whole branch survives.
|
|
553
|
+
addNode(tree, makeTreeMessage({ id: 'u4', parentId: 'a3', role: 'user' }));
|
|
554
|
+
|
|
555
|
+
cutBranchAfter(tree, 'u2', 'a3');
|
|
556
|
+
|
|
557
|
+
expect(tree.nodes.a3).toBeDefined();
|
|
558
|
+
expect(tree.nodes.u4).toBeDefined();
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('clears comparison state even when a child is spared (single lane left)', () => {
|
|
562
|
+
const tree = makeBranchingTree();
|
|
563
|
+
tree.nodes.u2.comparing = true;
|
|
564
|
+
tree.nodes.u2.comparisonWinnerId = 'a3';
|
|
565
|
+
|
|
566
|
+
cutBranchAfter(tree, 'u2', 'a3');
|
|
567
|
+
|
|
568
|
+
expect(tree.nodes.u2.childrenIds).toEqual(['a3']);
|
|
569
|
+
expect(tree.nodes.u2.comparing).toBeUndefined();
|
|
570
|
+
expect(tree.nodes.u2.comparisonWinnerId).toBeUndefined();
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it('ignores an unknown or non-child keepChildId and removes every branch', () => {
|
|
574
|
+
const tree = makeBranchingTree();
|
|
575
|
+
|
|
576
|
+
cutBranchAfter(tree, 'u2', 'nonexistent');
|
|
577
|
+
|
|
578
|
+
expect(tree.nodes.a2).toBeUndefined();
|
|
579
|
+
expect(tree.nodes.a3).toBeUndefined();
|
|
580
|
+
expect(tree.nodes.u2.childrenIds).toEqual([]);
|
|
581
|
+
});
|
|
521
582
|
});
|
|
522
583
|
|
|
523
584
|
describe('cutBranchAt', () => {
|
|
@@ -637,6 +698,43 @@ describe('cutBranchAt', () => {
|
|
|
637
698
|
|
|
638
699
|
expect(Object.keys(tree.nodes).sort()).toEqual(before);
|
|
639
700
|
});
|
|
701
|
+
|
|
702
|
+
it('clears the parent comparison when fewer than two lanes remain', () => {
|
|
703
|
+
const tree = makeThreeSiblingTree();
|
|
704
|
+
tree.nodes.p.comparing = true;
|
|
705
|
+
// Down to [cA] after removing cB and cC: nothing left to compare.
|
|
706
|
+
cutBranchAt(tree, 'cB');
|
|
707
|
+
cutBranchAt(tree, 'cC');
|
|
708
|
+
|
|
709
|
+
expect(tree.nodes.p.childrenIds).toEqual(['cA']);
|
|
710
|
+
expect(tree.nodes.p.comparing).toBeUndefined();
|
|
711
|
+
expect(tree.nodes.p.comparisonWinnerId).toBeUndefined();
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it('drops the dangling winner when the winning lane is removed but others remain', () => {
|
|
715
|
+
const tree = makeThreeSiblingTree();
|
|
716
|
+
tree.nodes.p.comparing = true;
|
|
717
|
+
tree.nodes.p.comparisonWinnerId = 'cA';
|
|
718
|
+
|
|
719
|
+
cutBranchAt(tree, 'cA');
|
|
720
|
+
|
|
721
|
+
// Still a comparison (cB, cC remain) but the recorded vote is gone.
|
|
722
|
+
expect(tree.nodes.p.childrenIds).toEqual(['cB', 'cC']);
|
|
723
|
+
expect(tree.nodes.p.comparing).toBe(true);
|
|
724
|
+
expect(tree.nodes.p.comparisonWinnerId).toBeUndefined();
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
it('keeps the comparison and winner when a losing lane is removed', () => {
|
|
728
|
+
const tree = makeThreeSiblingTree();
|
|
729
|
+
tree.nodes.p.comparing = true;
|
|
730
|
+
tree.nodes.p.comparisonWinnerId = 'cA';
|
|
731
|
+
|
|
732
|
+
cutBranchAt(tree, 'cB');
|
|
733
|
+
|
|
734
|
+
expect(tree.nodes.p.childrenIds).toEqual(['cA', 'cC']);
|
|
735
|
+
expect(tree.nodes.p.comparing).toBe(true);
|
|
736
|
+
expect(tree.nodes.p.comparisonWinnerId).toBe('cA');
|
|
737
|
+
});
|
|
640
738
|
});
|
|
641
739
|
|
|
642
740
|
describe('integration: full branching scenario', () => {
|