1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 |
|
9 |
|
10 |
|
11 |
|
12 |
|
13 |
|
14 |
|
15 |
|
16 |
|
17 | import { expect } from 'chai';
|
18 | import { MockTreeModel } from './test/mock-tree-model';
|
19 | import { createTreeTestContainer } from './test/tree-test-container';
|
20 | import { TreeModel } from './tree-model';
|
21 | import { SelectableTreeNode, TreeSelection } from './tree-selection';
|
22 | import { TreeSelectionState } from './tree-selection-state';
|
23 |
|
24 | namespace TreeSelectionState {
|
25 |
|
26 | export interface Expectation {
|
27 | readonly focus?: string | undefined;
|
28 | readonly selection?: string[];
|
29 | }
|
30 |
|
31 | export interface Assert {
|
32 | readonly nextState: (type: 'default' | 'toggle' | 'range', nodeId: string, expectation?: Expectation) => Assert;
|
33 | }
|
34 |
|
35 | }
|
36 |
|
37 | const LARGE_FLAT_MOCK_ROOT = (length = 250000) => {
|
38 | const children = Array.from({ length }, (_, idx) => ({ 'id': (idx + 1).toString() }));
|
39 | return MockTreeModel.Node.toTreeNode({
|
40 | 'id': 'ROOT',
|
41 | 'children': [
|
42 | ...children
|
43 | ]
|
44 | });
|
45 | };
|
46 |
|
47 | describe('tree-selection-state', () => {
|
48 |
|
49 | const model = createTreeModel();
|
50 | const findNode = (nodeId: string) => model.getNode(nodeId) as (SelectableTreeNode);
|
51 |
|
52 | beforeEach(() => {
|
53 | model.root = MockTreeModel.FLAT_MOCK_ROOT();
|
54 | expect(model.selectedNodes).to.be.empty;
|
55 | });
|
56 |
|
57 | it('should remove the selection but not the focus when toggling the one and only selected node', () => {
|
58 | newState()
|
59 | .nextState('toggle', '1', {
|
60 | focus: '1',
|
61 | selection: ['1']
|
62 | })
|
63 | .nextState('toggle', '1', {
|
64 | focus: '1',
|
65 | selection: []
|
66 | });
|
67 | });
|
68 |
|
69 | it('should keep the focus on the `fromNode` when selecting a range', () => {
|
70 | newState()
|
71 | .nextState('toggle', '1')
|
72 | .nextState('range', '3', {
|
73 | focus: '1',
|
74 | selection: ['1', '2', '3']
|
75 | });
|
76 | });
|
77 |
|
78 | it('should always have one single focus node when toggling node in a range', () => {
|
79 | newState()
|
80 | .nextState('toggle', '1')
|
81 | .nextState('range', '3')
|
82 | .nextState('toggle', '2', {
|
83 | focus: '1',
|
84 | selection: ['1', '3']
|
85 | })
|
86 | .nextState('toggle', '2', {
|
87 | focus: '1',
|
88 | selection: ['1', '2', '3']
|
89 | });
|
90 | });
|
91 |
|
92 | it('should calculate the range from the focus even unselecting a node in the previously created range', () => {
|
93 | newState()
|
94 | .nextState('toggle', '5')
|
95 | .nextState('range', '2', {
|
96 | focus: '5',
|
97 | selection: ['2', '3', '4', '5']
|
98 | })
|
99 | .nextState('toggle', '3', {
|
100 | focus: '5',
|
101 | selection: ['2', '4', '5']
|
102 | })
|
103 | .nextState('range', '7', {
|
104 | focus: '5',
|
105 | selection: ['2', '4', '5', '6', '7']
|
106 | });
|
107 | });
|
108 |
|
109 | it('should discard the previous range selection state if the current one has the same focus with other direction', () => {
|
110 | newState()
|
111 | .nextState('toggle', '4')
|
112 | .nextState('range', '1')
|
113 | .nextState('range', '6', {
|
114 | focus: '4',
|
115 | selection: ['4', '5', '6']
|
116 | });
|
117 | });
|
118 |
|
119 | it('should discard the previous range selection state if the current one overlaps the previous one', () => {
|
120 | newState()
|
121 | .nextState('toggle', '4')
|
122 | .nextState('range', '6')
|
123 | .nextState('range', '8', {
|
124 | focus: '4',
|
125 | selection: ['4', '5', '6', '7', '8']
|
126 | });
|
127 | });
|
128 |
|
129 | it('should move the focus to the most recently selected node if the previous selection was a toggle', () => {
|
130 | newState()
|
131 | .nextState('toggle', '4')
|
132 | .nextState('toggle', '5', {
|
133 | focus: '5',
|
134 | selection: ['4', '5']
|
135 | });
|
136 | });
|
137 |
|
138 | it('should keep the focus on the previous node if the current toggling happens in the most recent range', () => {
|
139 | newState()
|
140 | .nextState('toggle', '4')
|
141 | .nextState('range', '2', {
|
142 | focus: '4',
|
143 | selection: ['4', '3', '2']
|
144 | });
|
145 | });
|
146 |
|
147 | it('should move the focus to the next toggle node if that is out of the latest range selection', () => {
|
148 | newState()
|
149 | .nextState('toggle', '3')
|
150 | .nextState('range', '5', {
|
151 | focus: '3',
|
152 | selection: ['3', '4', '5']
|
153 | })
|
154 | .nextState('toggle', '1', {
|
155 | focus: '1',
|
156 | selection: ['3', '4', '5', '1']
|
157 | });
|
158 | });
|
159 |
|
160 | it('should not change the focus when removing a selection from an individual node', () => {
|
161 | newState()
|
162 | .nextState('toggle', '3')
|
163 | .nextState('range', '5', {
|
164 | focus: '3',
|
165 | selection: ['5', '4', '3']
|
166 | })
|
167 | .nextState('toggle', '1', {
|
168 | focus: '1',
|
169 | selection: ['1', '5', '4', '3']
|
170 | })
|
171 | .nextState('toggle', '4', {
|
172 | focus: '1',
|
173 | selection: ['1', '5', '3']
|
174 | });
|
175 | });
|
176 |
|
177 | it('should discard the previously selected range if each node from the previous range is contained in the current one', () => {
|
178 | newState()
|
179 | .nextState('toggle', '5')
|
180 | .nextState('range', '2')
|
181 | .nextState('toggle', '3', {
|
182 | focus: '5',
|
183 | selection: ['5', '4', '2']
|
184 | })
|
185 | .nextState('range', '1', {
|
186 | focus: '5',
|
187 | selection: ['5', '4', '3', '2', '1']
|
188 | })
|
189 | .nextState('range', '7', {
|
190 | focus: '5',
|
191 | selection: ['5', '6', '7']
|
192 | });
|
193 | });
|
194 |
|
195 | it('should be possible to remove the selection of a node which is contained in two overlapping ranges', () => {
|
196 | newState()
|
197 | .nextState('toggle', '5')
|
198 | .nextState('range', '4')
|
199 | .nextState('toggle', '3')
|
200 | .nextState('range', '2', {
|
201 | focus: '3',
|
202 | selection: ['2', '3', '4', '5']
|
203 | })
|
204 | .nextState('range', '5', {
|
205 | focus: '3',
|
206 | selection: ['5', '4', '3']
|
207 | })
|
208 | .nextState('toggle', '4', {
|
209 | focus: '3',
|
210 | selection: ['5', '3']
|
211 | });
|
212 | });
|
213 |
|
214 | it('should be possible to traverse with range selections', () => {
|
215 | newState()
|
216 | .nextState('toggle', '2')
|
217 | .nextState('range', '3', {
|
218 | focus: '2',
|
219 | selection: ['2', '3']
|
220 | })
|
221 | .nextState('range', '4', {
|
222 | focus: '2',
|
223 | selection: ['2', '3', '4']
|
224 | });
|
225 | });
|
226 |
|
227 | it('should remove the selection from a node inside a range instead of setting the focus', () => {
|
228 | newState()
|
229 | .nextState('toggle', '2')
|
230 | .nextState('range', '3', {
|
231 | focus: '2',
|
232 | selection: ['2', '3']
|
233 | })
|
234 | .nextState('toggle', '5', {
|
235 | focus: '5',
|
236 | selection: ['2', '3', '5']
|
237 | })
|
238 | .nextState('range', '6', {
|
239 | focus: '5',
|
240 | selection: ['2', '3', '5', '6']
|
241 | })
|
242 | .nextState('toggle', '3', {
|
243 | focus: '5',
|
244 | selection: ['2', '5', '6']
|
245 | });
|
246 | });
|
247 |
|
248 | it('should merge all individual selections into a range', () => {
|
249 | newState()
|
250 | .nextState('toggle', '1')
|
251 | .nextState('toggle', '3')
|
252 | .nextState('toggle', '6')
|
253 | .nextState('toggle', '2')
|
254 | .nextState('range', '7', {
|
255 | focus: '2',
|
256 | selection: ['1', '2', '3', '4', '5', '6', '7']
|
257 | });
|
258 | });
|
259 |
|
260 | it('should keep focus on the most recent even when removing selection from individual nodes', () => {
|
261 | newState()
|
262 | .nextState('toggle', '9')
|
263 | .nextState('range', '6', {
|
264 | focus: '9',
|
265 | selection: ['9', '8', '7', '6']
|
266 | })
|
267 | .nextState('range', '10', {
|
268 | focus: '9',
|
269 | selection: ['9', '10']
|
270 | })
|
271 | .nextState('range', '2', {
|
272 | focus: '9',
|
273 | selection: ['9', '8', '7', '6', '5', '4', '3', '2']
|
274 | })
|
275 | .nextState('toggle', '4', {
|
276 | focus: '9',
|
277 | selection: ['9', '8', '7', '6', '5', '3', '2']
|
278 | })
|
279 | .nextState('toggle', '5', {
|
280 | focus: '9',
|
281 | selection: ['9', '8', '7', '6', '3', '2']
|
282 | })
|
283 | .nextState('toggle', '6', {
|
284 | focus: '9',
|
285 | selection: ['9', '8', '7', '3', '2']
|
286 | })
|
287 | .nextState('toggle', '7', {
|
288 | focus: '9',
|
289 | selection: ['9', '8', '3', '2']
|
290 | })
|
291 | .nextState('range', '5', {
|
292 | focus: '9',
|
293 | selection: ['9', '8', '7', '6', '5', '3', '2']
|
294 | });
|
295 | });
|
296 |
|
297 | it('should expand the range instead of discarding it if individual elements have created a hole in the range', () => {
|
298 | newState()
|
299 | .nextState('toggle', '9')
|
300 | .nextState('range', '3', {
|
301 | focus: '9',
|
302 | selection: ['9', '8', '7', '6', '5', '4', '3']
|
303 | })
|
304 | .nextState('toggle', '4', {
|
305 | focus: '9',
|
306 | selection: ['9', '8', '7', '6', '5', '3']
|
307 | })
|
308 | .nextState('range', '10', {
|
309 | focus: '9',
|
310 | selection: ['10', '9', '8', '7', '6', '5', '3']
|
311 | });
|
312 | });
|
313 |
|
314 | it('should reset the selection state and the focus when using the default selection', () => {
|
315 | newState()
|
316 | .nextState('toggle', '1')
|
317 | .nextState('toggle', '2', {
|
318 | focus: '2',
|
319 | selection: ['1', '2']
|
320 | })
|
321 | .nextState('default', '2', {
|
322 | focus: '2',
|
323 | selection: ['2']
|
324 | })
|
325 | .nextState('toggle', '1', {
|
326 | focus: '1',
|
327 | selection: ['1', '2']
|
328 | })
|
329 | .nextState('default', '2', {
|
330 | focus: '2',
|
331 | selection: ['2']
|
332 | });
|
333 | });
|
334 |
|
335 | it('should remove the selection but keep the focus when toggling the single selected node', () => {
|
336 | newState()
|
337 | .nextState('toggle', '1', {
|
338 | focus: '1',
|
339 | selection: ['1']
|
340 | })
|
341 | .nextState('toggle', '1', {
|
342 | focus: '1',
|
343 | selection: []
|
344 | });
|
345 | });
|
346 |
|
347 | it('should treat ranges with the same from and to node as a single element range', () => {
|
348 | newState()
|
349 | .nextState('toggle', '2')
|
350 | .nextState('range', '1', {
|
351 | focus: '2',
|
352 | selection: ['1', '2']
|
353 | })
|
354 | .nextState('range', '2', {
|
355 | focus: '2',
|
356 | selection: ['2']
|
357 | })
|
358 | .nextState('range', '3', {
|
359 | focus: '2',
|
360 | selection: ['2', '3']
|
361 | })
|
362 | .nextState('range', '2', {
|
363 | focus: '2',
|
364 | selection: ['2']
|
365 | })
|
366 | .nextState('range', '1', {
|
367 | focus: '2',
|
368 | selection: ['1', '2']
|
369 | });
|
370 | });
|
371 |
|
372 | it('should remember the most recent range selection start after toggling individual nodes', () => {
|
373 | newState()
|
374 | .nextState('toggle', '10', {
|
375 | focus: '10',
|
376 | selection: ['10']
|
377 | })
|
378 | .nextState('range', '3', {
|
379 | focus: '10',
|
380 | selection: ['3', '4', '5', '6', '7', '8', '9', '10']
|
381 | })
|
382 | .nextState('toggle', '7', {
|
383 | focus: '10',
|
384 | selection: ['3', '4', '5', '6', '8', '9', '10']
|
385 | })
|
386 | .nextState('toggle', '8', {
|
387 | focus: '10',
|
388 | selection: ['3', '4', '5', '6', '9', '10']
|
389 | })
|
390 | .nextState('toggle', '6', {
|
391 | focus: '10',
|
392 | selection: ['3', '4', '5', '9', '10']
|
393 | })
|
394 | .nextState('toggle', '5', {
|
395 | focus: '10',
|
396 | selection: ['3', '4', '9', '10']
|
397 | })
|
398 | .nextState('range', '2', {
|
399 | focus: '10',
|
400 | selection: ['2', '3', '4', '5', '6', '7', '8', '9', '10']
|
401 | });
|
402 | });
|
403 |
|
404 | it('should be able to handle range selection on large tree', () => {
|
405 | model.root = LARGE_FLAT_MOCK_ROOT();
|
406 | expect(model.selectedNodes).to.be.empty;
|
407 |
|
408 | const start = 10;
|
409 | const end = 20;
|
410 | newState()
|
411 | .nextState('toggle', start.toString(), {
|
412 | focus: start.toString(),
|
413 | selection: [start.toString()]
|
414 | })
|
415 | .nextState('range', end.toString(), {
|
416 | focus: start.toString(),
|
417 | selection: Array.from({ length: end - start + 1 }, (_, idx) => (start + idx).toString())
|
418 | });
|
419 | });
|
420 |
|
421 | function newState(): TreeSelectionState.Assert {
|
422 | return nextState(new TreeSelectionState(model));
|
423 | }
|
424 |
|
425 | function nextState(state: TreeSelectionState): TreeSelectionState.Assert {
|
426 | return {
|
427 | nextState: (nextType, nextId, expectation) => {
|
428 | const node = findNode(nextId);
|
429 | const type = ((t: 'default' | 'toggle' | 'range') => {
|
430 | switch (t) {
|
431 | case 'default': return TreeSelection.SelectionType.DEFAULT;
|
432 | case 'toggle': return TreeSelection.SelectionType.TOGGLE;
|
433 | case 'range': return TreeSelection.SelectionType.RANGE;
|
434 | default: throw new Error(`Unexpected selection type: ${t}.`);
|
435 | }
|
436 | })(nextType);
|
437 | const next = state.nextState({ node, type });
|
438 | if (!!expectation) {
|
439 | const { focus, selection } = expectation;
|
440 | if ('focus' in expectation) {
|
441 | if (focus === undefined) {
|
442 | expect(next.focus).to.be.undefined;
|
443 | } else {
|
444 | expect(next.focus).to.be.not.undefined;
|
445 | expect(next.focus!.id).to.be.equal(focus);
|
446 | // TODO: we need tree-selection tests too, otherwise, we cannot verify whether there is one focus or not.
|
447 | }
|
448 | }
|
449 | if (selection) {
|
450 | expect(next.selection().map(n => n.id).sort()).to.be.deep.equal(selection.sort());
|
451 | }
|
452 | }
|
453 | return nextState(next);
|
454 | }
|
455 | };
|
456 | }
|
457 |
|
458 | function createTreeModel(): TreeModel {
|
459 | return createTreeTestContainer().get(TreeModel);
|
460 | }
|
461 |
|
462 | });
|
463 |
|
\ | No newline at end of file |