@pie-element/image-cloze-association 10.3.1-beta.2 → 10.3.1
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/CHANGELOG.md +52 -26
- package/configure/CHANGELOG.md +44 -25
- package/configure/package.json +3 -3
- package/controller/CHANGELOG.md +6 -38
- package/controller/package.json +1 -1
- package/lib/image-container.js +12 -3
- package/lib/image-container.js.map +1 -1
- package/lib/image-drop-target.js +46 -6
- package/lib/image-drop-target.js.map +1 -1
- package/lib/index.js +2 -2
- package/lib/index.js.map +1 -1
- package/lib/keyboard-coordinates.js +111 -0
- package/lib/keyboard-coordinates.js.map +1 -0
- package/lib/possible-response.js +36 -5
- package/lib/possible-response.js.map +1 -1
- package/lib/possible-responses.js +39 -19
- package/lib/possible-responses.js.map +1 -1
- package/lib/root.js +120 -6
- package/lib/root.js.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/image-drop-target.test.jsx +169 -0
- package/src/__tests__/keyboard-coordinates.test.js +127 -0
- package/src/__tests__/possible-response.test.jsx +113 -0
- package/src/__tests__/possible-responses.test.jsx +63 -0
- package/src/__tests__/root.test.jsx +261 -2
- package/src/image-container.jsx +9 -0
- package/src/image-drop-target.jsx +44 -1
- package/src/index.js +2 -2
- package/src/keyboard-coordinates.js +115 -0
- package/src/possible-response.jsx +45 -1
- package/src/possible-responses.jsx +37 -16
- package/src/root.jsx +117 -2
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { KeyboardCode } from '@dnd-kit/core';
|
|
2
|
+
import { closestDroppableKeyboardCoordinates } from '../keyboard-coordinates';
|
|
3
|
+
|
|
4
|
+
function rectsToContext(rects) {
|
|
5
|
+
const droppableRects = new Map(Object.entries(rects));
|
|
6
|
+
const droppableContainers = new Map(Object.keys(rects).map((id) => [id, { disabled: false }]));
|
|
7
|
+
|
|
8
|
+
return { droppableRects, droppableContainers };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function makeEvent(code, shiftKey = false) {
|
|
12
|
+
return { code, preventDefault: () => {}, shiftKey };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Mirrors dnd-kit's own KeyboardSensor, which always derives collisionRect's top-left
|
|
16
|
+
// from the dragged item's current on-screen position on every keydown — so a test's
|
|
17
|
+
// collisionRect must track currentCoordinates the same way on every simulated press,
|
|
18
|
+
// not stay fixed while currentCoordinates changes across multiple presses.
|
|
19
|
+
function press(rects, currentCoordinates, itemSize, event) {
|
|
20
|
+
const context = {
|
|
21
|
+
...rectsToContext(rects),
|
|
22
|
+
collisionRect: { left: currentCoordinates.x, top: currentCoordinates.y, ...itemSize },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return closestDroppableKeyboardCoordinates(event, { context, currentCoordinates });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('closestDroppableKeyboardCoordinates', () => {
|
|
29
|
+
describe('arrow keys', () => {
|
|
30
|
+
it("nudges the dragged item by dnd-kit's own default step (25px), unchanged", () => {
|
|
31
|
+
const context = { ...rectsToContext({}), collisionRect: { left: 0, top: 0, width: 100, height: 40 } };
|
|
32
|
+
const currentCoordinates = { x: 10, y: 20 };
|
|
33
|
+
|
|
34
|
+
expect(
|
|
35
|
+
closestDroppableKeyboardCoordinates(makeEvent(KeyboardCode.Down), { context, currentCoordinates }),
|
|
36
|
+
).toEqual({ x: 10, y: 45 });
|
|
37
|
+
expect(
|
|
38
|
+
closestDroppableKeyboardCoordinates(makeEvent(KeyboardCode.Up), { context, currentCoordinates }),
|
|
39
|
+
).toEqual({ x: 10, y: -5 });
|
|
40
|
+
expect(
|
|
41
|
+
closestDroppableKeyboardCoordinates(makeEvent(KeyboardCode.Right), { context, currentCoordinates }),
|
|
42
|
+
).toEqual({ x: 35, y: 20 });
|
|
43
|
+
expect(
|
|
44
|
+
closestDroppableKeyboardCoordinates(makeEvent(KeyboardCode.Left), { context, currentCoordinates }),
|
|
45
|
+
).toEqual({ x: -15, y: 20 });
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('Tab / Shift+Tab', () => {
|
|
50
|
+
// Two response containers plus the choices pool ("ica-board"), laid out
|
|
51
|
+
// top-to-bottom like they would be over an image.
|
|
52
|
+
const container0Rect = { left: 0, top: 0, width: 200, height: 40, right: 200, bottom: 40 };
|
|
53
|
+
const container1Rect = { left: 0, top: 60, width: 200, height: 40, right: 200, bottom: 100 };
|
|
54
|
+
const poolRect = { left: 0, top: 120, width: 400, height: 300, right: 400, bottom: 420 };
|
|
55
|
+
const baseRects = {
|
|
56
|
+
'response-container-0': container0Rect,
|
|
57
|
+
'response-container-1': container1Rect,
|
|
58
|
+
'ica-board': poolRect,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
it('jumps to the next droppable in top-to-bottom order, landing at its center-left point', () => {
|
|
62
|
+
const currentCoordinates = { x: container0Rect.left, y: container0Rect.top };
|
|
63
|
+
|
|
64
|
+
const next = press(baseRects, currentCoordinates, { width: 200, height: 40 }, makeEvent('Tab'));
|
|
65
|
+
|
|
66
|
+
expect(next).toEqual({ x: container1Rect.left, y: container1Rect.top + container1Rect.height / 2 });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('cycles backwards with Shift+Tab, wrapping to the pool', () => {
|
|
70
|
+
const currentCoordinates = { x: container0Rect.left, y: container0Rect.top };
|
|
71
|
+
|
|
72
|
+
const next = press(baseRects, currentCoordinates, { width: 200, height: 40 }, makeEvent('Tab', true));
|
|
73
|
+
|
|
74
|
+
expect(next).toEqual({ x: poolRect.left, y: poolRect.top + poolRect.height / 2 });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('keeps advancing on repeated Shift+Tab when a container is much wider than the dragged item', () => {
|
|
78
|
+
// Regression case for the bug already found and fixed in match-list: matching the
|
|
79
|
+
// "current" target by reconstructing a center from the dragged item's own small
|
|
80
|
+
// size (instead of checking which target's rect actually contains it) makes the
|
|
81
|
+
// *next* press re-match a completely different droppable once the item is
|
|
82
|
+
// actually sitting on a wide target, so it looks like the press does nothing.
|
|
83
|
+
// Only two targets here (a wide container and the pool), to isolate this from
|
|
84
|
+
// sort-order effects.
|
|
85
|
+
const wideContainerRect = { left: 0, top: 0, width: 900, height: 40, right: 900, bottom: 40 };
|
|
86
|
+
const rects = { 'response-container-0': wideContainerRect, 'ica-board': poolRect };
|
|
87
|
+
const itemSize = { width: 100, height: 40 };
|
|
88
|
+
|
|
89
|
+
// Start inside the pool, Shift+Tab to the only other target: the wide container.
|
|
90
|
+
const afterFirst = press(rects, { x: poolRect.left, y: poolRect.top }, itemSize, makeEvent('Tab', true));
|
|
91
|
+
|
|
92
|
+
expect(afterFirst).toEqual({ x: 0, y: 20 }); // wide container's center-left point
|
|
93
|
+
|
|
94
|
+
// Now sitting exactly at that dropPosition — Shift+Tab again must advance back
|
|
95
|
+
// to the pool, not re-match some other droppable.
|
|
96
|
+
const afterSecond = press(rects, afterFirst, itemSize, makeEvent('Tab', true));
|
|
97
|
+
|
|
98
|
+
expect(afterSecond).toEqual({ x: poolRect.left, y: poolRect.top + poolRect.height / 2 });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("correctly identifies the pool as the current target when picking up an item positioned near the pool's edge, close to a small neighboring container", () => {
|
|
102
|
+
// Regression case for the OTHER failure mode: matching "current target" purely
|
|
103
|
+
// by nearest-dropPosition (instead of containment first) can misidentify the
|
|
104
|
+
// pool as some small, unrelated nearby container, because a large droppable's
|
|
105
|
+
// dropPosition is anchored at ITS OWN vertical middle — which can be far from an
|
|
106
|
+
// item that's sitting near the droppable's edge, even though the item is clearly
|
|
107
|
+
// still inside it.
|
|
108
|
+
const tallPoolRect = { left: 0, top: 0, width: 400, height: 600, right: 400, bottom: 600 };
|
|
109
|
+
const smallContainerRect = { left: 0, top: -60, width: 100, height: 40, right: 100, bottom: -20 };
|
|
110
|
+
const rects = { 'response-container-0': smallContainerRect, 'ica-board': tallPoolRect };
|
|
111
|
+
const itemSize = { width: 100, height: 40 };
|
|
112
|
+
|
|
113
|
+
// The item sits just inside the pool's top edge (y: 10-50) — nowhere near the
|
|
114
|
+
// pool's own vertical middle (y: 300), but visually still inside it, and closer
|
|
115
|
+
// in raw distance to the small container's anchor point (top: -60, center y:
|
|
116
|
+
// -40) than to the pool's anchor point (center y: 300).
|
|
117
|
+
const currentCoordinates = { x: 0, y: 10 };
|
|
118
|
+
|
|
119
|
+
const next = press(rects, currentCoordinates, itemSize, makeEvent('Tab'));
|
|
120
|
+
|
|
121
|
+
// Tab forward from the pool lands on the only other target: the small
|
|
122
|
+
// container — proving the pool, not the small container, was identified as
|
|
123
|
+
// "current".
|
|
124
|
+
expect(next).toEqual({ x: smallContainerRect.left, y: smallContainerRect.top + smallContainerRect.height / 2 });
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, fireEvent } from '@testing-library/react';
|
|
3
|
+
import PossibleResponse from '../possible-response';
|
|
4
|
+
|
|
5
|
+
jest.mock('@dnd-kit/core', () => ({
|
|
6
|
+
useDraggable: () => ({
|
|
7
|
+
setNodeRef: jest.fn(),
|
|
8
|
+
attributes: {},
|
|
9
|
+
listeners: {},
|
|
10
|
+
isDragging: false,
|
|
11
|
+
}),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
describe('PossibleResponse', () => {
|
|
15
|
+
const baseProps = {
|
|
16
|
+
canDrag: true,
|
|
17
|
+
data: { id: '0', value: 'Choice A' },
|
|
18
|
+
onDragBegin: jest.fn(),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
it('selects a pool item on click when nothing else is selected', () => {
|
|
22
|
+
const onSelectClick = jest.fn();
|
|
23
|
+
const onPlacementClick = jest.fn();
|
|
24
|
+
const { container } = render(
|
|
25
|
+
<PossibleResponse
|
|
26
|
+
{...baseProps}
|
|
27
|
+
selectedResponse={null}
|
|
28
|
+
onSelectClick={onSelectClick}
|
|
29
|
+
onPlacementClick={onPlacementClick}
|
|
30
|
+
/>,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
fireEvent.click(container.firstChild);
|
|
34
|
+
|
|
35
|
+
expect(onSelectClick).toHaveBeenCalledWith(baseProps.data);
|
|
36
|
+
expect(onPlacementClick).not.toHaveBeenCalled();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('toggles off (calls onSelectClick again) when clicking the already-selected item', () => {
|
|
40
|
+
const onSelectClick = jest.fn();
|
|
41
|
+
const onPlacementClick = jest.fn();
|
|
42
|
+
const { container } = render(
|
|
43
|
+
<PossibleResponse
|
|
44
|
+
{...baseProps}
|
|
45
|
+
selectedResponse={{ id: '0', containerIndex: undefined }}
|
|
46
|
+
onSelectClick={onSelectClick}
|
|
47
|
+
onPlacementClick={onPlacementClick}
|
|
48
|
+
/>,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
fireEvent.click(container.firstChild);
|
|
52
|
+
|
|
53
|
+
expect(onSelectClick).toHaveBeenCalledWith(baseProps.data);
|
|
54
|
+
expect(onPlacementClick).not.toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('selects a different pool item on click even while something else is selected (pool items are never placement targets)', () => {
|
|
58
|
+
const onSelectClick = jest.fn();
|
|
59
|
+
const onPlacementClick = jest.fn();
|
|
60
|
+
const { container } = render(
|
|
61
|
+
<PossibleResponse
|
|
62
|
+
{...baseProps}
|
|
63
|
+
selectedResponse={{ id: '1', containerIndex: 2 }}
|
|
64
|
+
onSelectClick={onSelectClick}
|
|
65
|
+
onPlacementClick={onPlacementClick}
|
|
66
|
+
/>,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
fireEvent.click(container.firstChild);
|
|
70
|
+
|
|
71
|
+
expect(onSelectClick).toHaveBeenCalledWith(baseProps.data);
|
|
72
|
+
expect(onPlacementClick).not.toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('places the current selection into this tile\'s container when clicking an already-placed tile that is not the selection', () => {
|
|
76
|
+
const onSelectClick = jest.fn();
|
|
77
|
+
const onPlacementClick = jest.fn();
|
|
78
|
+
const placedData = { id: '5', value: 'Choice B', containerIndex: 3 };
|
|
79
|
+
const { container } = render(
|
|
80
|
+
<PossibleResponse
|
|
81
|
+
{...baseProps}
|
|
82
|
+
data={placedData}
|
|
83
|
+
selectedResponse={{ id: '1', containerIndex: undefined }}
|
|
84
|
+
onSelectClick={onSelectClick}
|
|
85
|
+
onPlacementClick={onPlacementClick}
|
|
86
|
+
/>,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
fireEvent.click(container.firstChild);
|
|
90
|
+
|
|
91
|
+
expect(onPlacementClick).toHaveBeenCalledWith(3);
|
|
92
|
+
expect(onSelectClick).not.toHaveBeenCalled();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('does nothing on click when disabled (canDrag=false)', () => {
|
|
96
|
+
const onSelectClick = jest.fn();
|
|
97
|
+
const onPlacementClick = jest.fn();
|
|
98
|
+
const { container } = render(
|
|
99
|
+
<PossibleResponse
|
|
100
|
+
{...baseProps}
|
|
101
|
+
canDrag={false}
|
|
102
|
+
selectedResponse={null}
|
|
103
|
+
onSelectClick={onSelectClick}
|
|
104
|
+
onPlacementClick={onPlacementClick}
|
|
105
|
+
/>,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
fireEvent.click(container.firstChild);
|
|
109
|
+
|
|
110
|
+
expect(onSelectClick).not.toHaveBeenCalled();
|
|
111
|
+
expect(onPlacementClick).not.toHaveBeenCalled();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, fireEvent } from '@testing-library/react';
|
|
3
|
+
import PossibleResponses from '../possible-responses';
|
|
4
|
+
|
|
5
|
+
jest.mock('@pie-lib/drag', () => ({
|
|
6
|
+
ICADroppablePlaceholder: ({ children }) => <div data-testid="ica-board">{children}</div>,
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
jest.mock('../possible-response', () => (props) => (
|
|
10
|
+
<div data-testid={`possible-response-${props.data.id}`} onClick={(e) => { e.stopPropagation(); props.onSelectClick?.(props.data); }}>
|
|
11
|
+
{props.data.value}
|
|
12
|
+
</div>
|
|
13
|
+
));
|
|
14
|
+
|
|
15
|
+
describe('PossibleResponses', () => {
|
|
16
|
+
const baseProps = {
|
|
17
|
+
canDrag: true,
|
|
18
|
+
data: [{ id: '0', value: 'Choice A' }],
|
|
19
|
+
onDragBegin: jest.fn(),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
it('places the selection into the pool when clicking the pool background', () => {
|
|
23
|
+
const onPlacementClick = jest.fn();
|
|
24
|
+
const { container } = render(
|
|
25
|
+
<PossibleResponses
|
|
26
|
+
{...baseProps}
|
|
27
|
+
selectedResponse={{ id: '9', containerIndex: 1 }}
|
|
28
|
+
onPlacementClick={onPlacementClick}
|
|
29
|
+
/>,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
fireEvent.click(container.firstChild);
|
|
33
|
+
|
|
34
|
+
expect(onPlacementClick).toHaveBeenCalledWith(undefined);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('does nothing when clicking the pool background with nothing selected', () => {
|
|
38
|
+
const onPlacementClick = jest.fn();
|
|
39
|
+
const { container } = render(<PossibleResponses {...baseProps} onPlacementClick={onPlacementClick} />);
|
|
40
|
+
|
|
41
|
+
fireEvent.click(container.firstChild);
|
|
42
|
+
|
|
43
|
+
expect(onPlacementClick).not.toHaveBeenCalled();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('clicking a specific pool item selects it, not the pool background handler', () => {
|
|
47
|
+
const onPlacementClick = jest.fn();
|
|
48
|
+
const onSelectClick = jest.fn();
|
|
49
|
+
const { getByTestId } = render(
|
|
50
|
+
<PossibleResponses
|
|
51
|
+
{...baseProps}
|
|
52
|
+
selectedResponse={{ id: '9', containerIndex: 1 }}
|
|
53
|
+
onSelectClick={onSelectClick}
|
|
54
|
+
onPlacementClick={onPlacementClick}
|
|
55
|
+
/>,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
fireEvent.click(getByTestId('possible-response-0'));
|
|
59
|
+
|
|
60
|
+
expect(onSelectClick).toHaveBeenCalledWith(baseProps.data[0]);
|
|
61
|
+
expect(onPlacementClick).not.toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { render } from '@testing-library/react';
|
|
2
|
+
import { render, fireEvent } from '@testing-library/react';
|
|
3
3
|
import { ImageClozeAssociationComponent as Root } from '../root';
|
|
4
|
+
import { closestDroppableKeyboardCoordinates } from '../keyboard-coordinates';
|
|
4
5
|
|
|
5
6
|
jest.mock('@dnd-kit/core', () => ({
|
|
6
7
|
DragOverlay: ({ children }) => <div>{children}</div>,
|
|
@@ -15,8 +16,13 @@ jest.mock('@dnd-kit/core', () => ({
|
|
|
15
16
|
}),
|
|
16
17
|
}));
|
|
17
18
|
|
|
19
|
+
let capturedDragProviderProps;
|
|
20
|
+
|
|
18
21
|
jest.mock('@pie-lib/drag', () => ({
|
|
19
|
-
DragProvider: (
|
|
22
|
+
DragProvider: (props) => {
|
|
23
|
+
capturedDragProviderProps = props;
|
|
24
|
+
return <div>{props.children}</div>;
|
|
25
|
+
},
|
|
20
26
|
ICADroppablePlaceholder: ({ children }) => <div>{children}</div>,
|
|
21
27
|
}));
|
|
22
28
|
|
|
@@ -34,6 +40,18 @@ const model = {
|
|
|
34
40
|
describe('Root', () => {
|
|
35
41
|
const updateAnswer = jest.fn();
|
|
36
42
|
|
|
43
|
+
// Warm-up render: the very first `render(<Root .../>)` call in this file's jest
|
|
44
|
+
// worker doesn't reliably invoke the mocked `DragProvider` (its assignment to
|
|
45
|
+
// `capturedDragProviderProps` never runs, even though React reports the correct
|
|
46
|
+
// component reference and the surrounding tree renders successfully) — a one-time
|
|
47
|
+
// environment quirk, not anything about the mock or Root itself, since every
|
|
48
|
+
// subsequent render works correctly. Priming it here, once, before any test's
|
|
49
|
+
// assertions depend on it, avoids the tests that check `capturedDragProviderProps`
|
|
50
|
+
// being order-dependent on some other, unrelated test happening to render first.
|
|
51
|
+
beforeAll(() => {
|
|
52
|
+
render(<Root model={model} session={{ answers: [] }} updateAnswer={jest.fn()} />).unmount();
|
|
53
|
+
});
|
|
54
|
+
|
|
37
55
|
const mkWrapper = (opts = {}) => {
|
|
38
56
|
const props = {
|
|
39
57
|
model,
|
|
@@ -96,4 +114,245 @@ describe('Root', () => {
|
|
|
96
114
|
});
|
|
97
115
|
});
|
|
98
116
|
|
|
117
|
+
describe('selection state', () => {
|
|
118
|
+
it('mirrors an active drag into selectedResponse on drag start', () => {
|
|
119
|
+
const instance = createInstance();
|
|
120
|
+
const data = { id: '3', value: 'X', containerIndex: 1 };
|
|
121
|
+
|
|
122
|
+
instance.onDragStart({ active: { data: { current: data } } });
|
|
123
|
+
|
|
124
|
+
expect(instance.state.selectedResponse).toEqual(data);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('toggleResponseSelection selects, then deselects the same response', () => {
|
|
128
|
+
const instance = createInstance();
|
|
129
|
+
const data = { id: '3', value: 'X', containerIndex: undefined };
|
|
130
|
+
|
|
131
|
+
instance.toggleResponseSelection(data);
|
|
132
|
+
expect(instance.state.selectedResponse).toEqual(data);
|
|
133
|
+
|
|
134
|
+
instance.toggleResponseSelection(data);
|
|
135
|
+
expect(instance.state.selectedResponse).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('toggleResponseSelection switches selection to a different response', () => {
|
|
139
|
+
const instance = createInstance();
|
|
140
|
+
const first = { id: '3', value: 'X', containerIndex: undefined };
|
|
141
|
+
const second = { id: '4', value: 'Y', containerIndex: undefined };
|
|
142
|
+
|
|
143
|
+
instance.toggleResponseSelection(first);
|
|
144
|
+
instance.toggleResponseSelection(second);
|
|
145
|
+
|
|
146
|
+
expect(instance.state.selectedResponse).toEqual(second);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('placeSelectedResponse places the selection into a container via handleOnAnswerSelect', () => {
|
|
150
|
+
const instance = createInstance();
|
|
151
|
+
const data = { id: '0', value: 'firstImage', containerIndex: undefined };
|
|
152
|
+
|
|
153
|
+
instance.toggleResponseSelection(data);
|
|
154
|
+
instance.placeSelectedResponse(1);
|
|
155
|
+
|
|
156
|
+
expect(instance.state.answers).toEqual([{ id: '0', value: 'firstImage', containerIndex: 1 }]);
|
|
157
|
+
expect(instance.state.selectedResponse).toBeNull();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('placeSelectedResponse with containerIndex undefined removes the placed response (returns it to the pool)', () => {
|
|
161
|
+
const instance = createInstance();
|
|
162
|
+
instance.handleOnAnswerSelect({ value: 'firstImage', id: '0' }, 0);
|
|
163
|
+
|
|
164
|
+
instance.toggleResponseSelection({ id: '0', value: 'firstImage', containerIndex: 0 });
|
|
165
|
+
instance.placeSelectedResponse(undefined);
|
|
166
|
+
|
|
167
|
+
expect(instance.state.answers).toEqual([]);
|
|
168
|
+
expect(instance.state.possibleResponses).toEqual([
|
|
169
|
+
{ value: 'secondImage', id: '1' },
|
|
170
|
+
{ value: 'firstImage', id: '0' },
|
|
171
|
+
]);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('placeSelectedResponse does nothing when nothing is selected', () => {
|
|
175
|
+
const instance = createInstance();
|
|
176
|
+
|
|
177
|
+
instance.placeSelectedResponse(1);
|
|
178
|
+
|
|
179
|
+
expect(instance.state.answers).toEqual([]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('placeSelectedResponse(undefined) is a no-op when the selection is already a pool item, even if its id collides with an unrelated placed answer (C1)', () => {
|
|
183
|
+
// Pool items are indexed 0..n-1 over possibleResponses, and placed answers are
|
|
184
|
+
// re-indexed 0..n-1 independently over the session's initial answers — so a pool
|
|
185
|
+
// item's id can coincide with an unrelated placed answer's id purely from the
|
|
186
|
+
// initial model/session, with no further user action required.
|
|
187
|
+
const instance = createInstance({ session: { answers: [{ value: 'thirdImage', containerIndex: 1 }] } });
|
|
188
|
+
|
|
189
|
+
const unrelatedPlacedAnswer = { value: 'thirdImage', containerIndex: 1, id: '0' };
|
|
190
|
+
expect(instance.state.answers).toEqual([unrelatedPlacedAnswer]);
|
|
191
|
+
|
|
192
|
+
// 'firstImage' is a pool item whose id ('0') collides with the unrelated placed
|
|
193
|
+
// answer's id above, but it is NOT currently placed anywhere
|
|
194
|
+
// (containerIndex undefined).
|
|
195
|
+
const poolItem = { value: 'firstImage', id: '0' };
|
|
196
|
+
instance.toggleResponseSelection(poolItem);
|
|
197
|
+
|
|
198
|
+
instance.placeSelectedResponse(undefined);
|
|
199
|
+
|
|
200
|
+
// The unrelated placed answer must survive untouched...
|
|
201
|
+
expect(instance.state.answers).toEqual([unrelatedPlacedAnswer]);
|
|
202
|
+
// ...and nothing should have been incorrectly pushed into possibleResponses either.
|
|
203
|
+
expect(instance.state.possibleResponses).toEqual([
|
|
204
|
+
{ value: 'firstImage', id: '0' },
|
|
205
|
+
{ value: 'secondImage', id: '1' },
|
|
206
|
+
]);
|
|
207
|
+
expect(instance.state.selectedResponse).toBeNull();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('onDragEnd dropping a pool item back onto the pool ("ica-board") is a no-op, even if its id collides with an unrelated placed answer (C1, drag path)', () => {
|
|
211
|
+
// Same hazard as the click-path C1 test above, but reached via a real
|
|
212
|
+
// pointer/keyboard drag ending on the pool ('ica-board') instead of a click:
|
|
213
|
+
// pool tiles are draggable too, and 'ica-board' is a valid Tab/Shift+Tab
|
|
214
|
+
// keyboard-navigable drop target, so draggedItem can be a pool item
|
|
215
|
+
// (containerIndex undefined) rather than a placed answer being moved back.
|
|
216
|
+
const instance = createInstance({ session: { answers: [{ value: 'thirdImage', containerIndex: 1 }] } });
|
|
217
|
+
|
|
218
|
+
const unrelatedPlacedAnswer = { value: 'thirdImage', containerIndex: 1, id: '0' };
|
|
219
|
+
expect(instance.state.answers).toEqual([unrelatedPlacedAnswer]);
|
|
220
|
+
|
|
221
|
+
// 'firstImage' is a pool item whose id ('0') collides with the unrelated placed
|
|
222
|
+
// answer's id above, but it is NOT currently placed anywhere.
|
|
223
|
+
const poolItem = { id: '0', value: 'firstImage', containerIndex: undefined };
|
|
224
|
+
|
|
225
|
+
instance.onDragEnd({ active: { data: { current: poolItem } }, over: { id: 'ica-board' } });
|
|
226
|
+
|
|
227
|
+
// The unrelated placed answer must survive untouched.
|
|
228
|
+
expect(instance.state.answers).toEqual([unrelatedPlacedAnswer]);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('onResponseClick and onPlacementClick are ignored for a short window right after a drag ends', () => {
|
|
232
|
+
const instance = createInstance();
|
|
233
|
+
const data = { id: '0', value: 'firstImage', containerIndex: undefined };
|
|
234
|
+
|
|
235
|
+
instance.onDragEnd({ active: null, over: null });
|
|
236
|
+
instance.onResponseClick(data);
|
|
237
|
+
|
|
238
|
+
expect(instance.state.selectedResponse).toBeNull();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('onPlacementClick is ignored for a short window right after a drag ends, even with an active selection', () => {
|
|
242
|
+
const instance = createInstance();
|
|
243
|
+
const data = { id: '0', value: 'firstImage', containerIndex: undefined };
|
|
244
|
+
|
|
245
|
+
instance.toggleResponseSelection(data);
|
|
246
|
+
instance.lastDragEndAt = Date.now(); // simulate the guard window without clearing selectedResponse
|
|
247
|
+
|
|
248
|
+
instance.onPlacementClick(1);
|
|
249
|
+
|
|
250
|
+
expect(instance.state.answers).toEqual([]);
|
|
251
|
+
expect(instance.state.selectedResponse).toEqual(data); // still selected — the call was ignored, not processed
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('endAnyLiveKeyboardDrag does not dispatch a document Escape keydown when no drag is live (I4)', () => {
|
|
255
|
+
const instance = createInstance();
|
|
256
|
+
const dispatchSpy = jest.spyOn(document, 'dispatchEvent');
|
|
257
|
+
|
|
258
|
+
// No onDragStart call happened, so draggingElement.id is falsy.
|
|
259
|
+
instance.endAnyLiveKeyboardDrag();
|
|
260
|
+
|
|
261
|
+
expect(dispatchSpy).not.toHaveBeenCalled();
|
|
262
|
+
dispatchSpy.mockRestore();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('endAnyLiveKeyboardDrag dispatches a document Escape keydown when a drag is live (I4)', () => {
|
|
266
|
+
const instance = createInstance();
|
|
267
|
+
const dispatchSpy = jest.spyOn(document, 'dispatchEvent');
|
|
268
|
+
|
|
269
|
+
instance.onDragStart({ active: { data: { current: { id: '3', value: 'X', containerIndex: undefined } } } });
|
|
270
|
+
instance.endAnyLiveKeyboardDrag();
|
|
271
|
+
|
|
272
|
+
expect(dispatchSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'keydown', code: 'Escape' }));
|
|
273
|
+
dispatchSpy.mockRestore();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('onResponseClick ends a still-live keyboard drag before applying the click selection, so the new selection sticks (I1)', () => {
|
|
277
|
+
const instance = createInstance();
|
|
278
|
+
const tileA = { id: '3', value: 'A', containerIndex: undefined };
|
|
279
|
+
const tileB = { id: '4', value: 'B', containerIndex: undefined };
|
|
280
|
+
|
|
281
|
+
// Pick up tile A via keyboard: starts a real dnd-kit drag and mirrors it into
|
|
282
|
+
// selectedResponse.
|
|
283
|
+
instance.onDragStart({ active: { data: { current: tileA } } });
|
|
284
|
+
expect(instance.state.selectedResponse).toEqual(tileA);
|
|
285
|
+
expect(instance.state.draggingElement).toEqual(tileA);
|
|
286
|
+
|
|
287
|
+
// Now click tile B with the mouse. Real dnd-kit's own document Escape listener
|
|
288
|
+
// would react to the synthetic Escape dispatched by endAnyLiveKeyboardDrag and
|
|
289
|
+
// cancel the drag (calling onDragCancel) — simulate that here the same way,
|
|
290
|
+
// since @dnd-kit/core itself is mocked out of this test file.
|
|
291
|
+
const originalDispatch = document.dispatchEvent.bind(document);
|
|
292
|
+
const dispatchSpy = jest.spyOn(document, 'dispatchEvent').mockImplementation((event) => {
|
|
293
|
+
if (event.code === 'Escape') {
|
|
294
|
+
instance.onDragCancel();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return originalDispatch(event);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
instance.onResponseClick(tileB);
|
|
301
|
+
|
|
302
|
+
expect(dispatchSpy).toHaveBeenCalled();
|
|
303
|
+
expect(instance.state.draggingElement).toEqual({ id: '', value: '' });
|
|
304
|
+
// B's selection must be the one that sticks, not wiped out by ending A's drag.
|
|
305
|
+
expect(instance.state.selectedResponse).toEqual(tileB);
|
|
306
|
+
|
|
307
|
+
dispatchSpy.mockRestore();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe('DragProvider wiring', () => {
|
|
313
|
+
it('passes the Tab/Shift+Tab coordinate getter and keyboard codes to DragProvider', () => {
|
|
314
|
+
mkWrapper();
|
|
315
|
+
|
|
316
|
+
expect(capturedDragProviderProps.keyboardCoordinateGetter).toBe(closestDroppableKeyboardCoordinates);
|
|
317
|
+
expect(capturedDragProviderProps.keyboardCodes).toEqual({
|
|
318
|
+
start: ['Space', 'Enter'],
|
|
319
|
+
cancel: ['Escape'],
|
|
320
|
+
end: ['Space', 'Enter'],
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('passes onDragCancel to DragProvider (forward-compatible with the pie-lib fix once bumped)', () => {
|
|
325
|
+
mkWrapper();
|
|
326
|
+
|
|
327
|
+
expect(typeof capturedDragProviderProps.onDragCancel).toBe('function');
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe('full component tree (I5)', () => {
|
|
332
|
+
// Only @dnd-kit/core and @pie-lib/drag are mocked in this file — everything else,
|
|
333
|
+
// including image-container.jsx and image-drop-target.jsx, renders for real. This
|
|
334
|
+
// proves the whole click-to-select/click-to-place prop chain works end to end,
|
|
335
|
+
// rather than each component's own test mocking its immediate child.
|
|
336
|
+
it('clicking a real pool tile then a real response container places the answer, exercising the full prop chain', () => {
|
|
337
|
+
updateAnswer.mockClear();
|
|
338
|
+
|
|
339
|
+
const { getByText, container } = mkWrapper();
|
|
340
|
+
|
|
341
|
+
// Click a real pool tile (rendered by possible-responses.jsx -> possible-response.jsx).
|
|
342
|
+
const poolTile = getByText('firstImage');
|
|
343
|
+
fireEvent.click(poolTile);
|
|
344
|
+
|
|
345
|
+
// Both response containers (rendered by image-container.jsx -> image-drop-target.jsx)
|
|
346
|
+
// are still empty at this point, so both are native tab stops with role="button" —
|
|
347
|
+
// this is the only role="button" element in the tree (pool tiles don't set one).
|
|
348
|
+
const dropTargets = container.querySelectorAll('[role="button"]');
|
|
349
|
+
expect(dropTargets.length).toBe(2);
|
|
350
|
+
|
|
351
|
+
// Click the first response container to place the selected pool tile into it.
|
|
352
|
+
fireEvent.click(dropTargets[0]);
|
|
353
|
+
|
|
354
|
+
expect(updateAnswer).toHaveBeenCalledWith([{ value: 'firstImage', id: '0', containerIndex: 0 }]);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
99
358
|
});
|
package/src/image-container.jsx
CHANGED
|
@@ -27,6 +27,9 @@ class ImageContainer extends Component {
|
|
|
27
27
|
responseContainerPadding,
|
|
28
28
|
imageDropTargetPadding,
|
|
29
29
|
maxResponsePerZone,
|
|
30
|
+
selectedResponse,
|
|
31
|
+
onSelectClick,
|
|
32
|
+
onPlacementClick,
|
|
30
33
|
} = this.props;
|
|
31
34
|
|
|
32
35
|
return (
|
|
@@ -62,6 +65,9 @@ class ImageContainer extends Component {
|
|
|
62
65
|
responseContainerPadding={responseContainerPadding}
|
|
63
66
|
imageDropTargetPadding={imageDropTargetPadding}
|
|
64
67
|
maxResponsePerZone={maxResponsePerZone}
|
|
68
|
+
selectedResponse={selectedResponse}
|
|
69
|
+
onSelectClick={onSelectClick}
|
|
70
|
+
onPlacementClick={onPlacementClick}
|
|
65
71
|
/>
|
|
66
72
|
);
|
|
67
73
|
})}
|
|
@@ -85,6 +91,9 @@ ImageContainer.propTypes = {
|
|
|
85
91
|
responseContainerPadding: PropTypes.string,
|
|
86
92
|
imageDropTargetPadding: PropTypes.string,
|
|
87
93
|
maxResponsePerZone: PropTypes.number,
|
|
94
|
+
selectedResponse: PropTypes.object,
|
|
95
|
+
onSelectClick: PropTypes.func,
|
|
96
|
+
onPlacementClick: PropTypes.func,
|
|
88
97
|
};
|
|
89
98
|
|
|
90
99
|
export default ImageContainer;
|