Skip to content

Commit c9e0a95

Browse files
refactor: migrate QTIEditor components to Testing Library and standardize 0-based indexing for question items.
Signed-off-by: Abhishek-Punhani <punhani.manavabhi@gmail.com>
1 parent e85e2e7 commit c9e0a95

8 files changed

Lines changed: 172 additions & 257 deletions

File tree

contentcuration/contentcuration/frontend/shared/strings/commonStrings.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,8 @@ export const commonStrings = createTranslator('CommonStrings', {
4242
message: 'Copy channel token',
4343
context: 'A label for an action that copies the channel token to the clipboard',
4444
},
45+
optionsLabel: {
46+
message: 'Options',
47+
context: 'Tooltip for the generic options menu icon',
48+
},
4549
});
Original file line numberDiff line numberDiff line change
@@ -1,147 +1,97 @@
1-
import { mount } from '@vue/test-utils';
1+
import { render, screen } from '@testing-library/vue';
2+
import userEvent from '@testing-library/user-event';
23
import CollapsibleToolbar from '../index.vue';
4+
import { commonStrings } from 'shared/strings/commonStrings';
35

4-
// Minimal KIconButton stub that renders a clickable button with a data-icon attr
5-
const KIconButtonStub = {
6-
name: 'KIconButton',
7-
props: ['icon', 'tooltip', 'ariaLabel', 'disabled', 'color'],
8-
template: `
9-
<button
10-
:data-icon="icon"
11-
:aria-label="ariaLabel"
12-
:disabled="disabled"
13-
@click="$emit('click')"
14-
>
15-
<slot name="menu" />
16-
</button>
17-
`,
18-
};
19-
20-
const KDropdownMenuStub = {
21-
name: 'KDropdownMenu',
22-
props: ['options'],
23-
template: `<div class="k-dropdown-menu"><slot /></div>`,
24-
methods: {
25-
// expose a helper so tests can directly trigger a select
26-
selectOption(value) {
27-
this.$emit('select', { value });
28-
},
29-
},
30-
};
6+
const { optionsLabel$ } = commonStrings;
317

32-
function makeAction(overrides = {}) {
33-
return {
34-
id: 'action-1',
35-
icon: 'edit',
36-
label: 'Edit',
37-
handler: jest.fn(),
38-
collapsed: false,
39-
disabled: false,
40-
...overrides,
41-
};
42-
}
8+
const makeAction = (overrides = {}) => ({
9+
id: 'action-1',
10+
icon: 'edit',
11+
label: 'Edit',
12+
handler: jest.fn(),
13+
collapsed: false,
14+
disabled: false,
15+
...overrides,
16+
});
4317

44-
function makeWrapper(actions = [], optionsLabel = 'Options') {
45-
return mount(CollapsibleToolbar, {
46-
propsData: { actions, optionsLabel },
47-
stubs: { KIconButton: KIconButtonStub, KDropdownMenu: KDropdownMenuStub },
18+
const renderComponent = (actions = [], optionsLabel = null) => {
19+
return render(CollapsibleToolbar, {
20+
props: { actions, optionsLabel },
4821
});
49-
}
22+
};
5023

5124
describe('CollapsibleToolbar', () => {
5225
describe('visible icon actions', () => {
53-
it('renders a KIconButton for each non-collapsed action that has an icon', () => {
26+
test('renders icon buttons for non-collapsed actions with icons', () => {
5427
const actions = [
55-
makeAction({ id: 'a1', icon: 'edit', collapsed: false }),
56-
makeAction({ id: 'a2', icon: 'delete', collapsed: false }),
28+
makeAction({ id: 'a1', label: 'Edit', icon: 'edit', collapsed: false }),
29+
makeAction({ id: 'a2', label: 'Move up', icon: 'chevronUp', collapsed: false }),
5730
];
58-
const wrapper = makeWrapper(actions);
59-
const iconBtns = wrapper.find('.icon-actions-wrapper').findAll('button');
60-
expect(iconBtns.length).toBe(2);
31+
renderComponent(actions);
32+
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument();
33+
expect(screen.getByRole('button', { name: 'Move up' })).toBeInTheDocument();
6134
});
6235

63-
it('does not render icon buttons for collapsed actions', () => {
36+
test('does not render an icon button for collapsed actions', () => {
6437
const actions = [
65-
makeAction({ id: 'a1', icon: 'edit', collapsed: false }),
66-
makeAction({ id: 'a2', icon: 'delete', collapsed: true }),
38+
makeAction({ id: 'a1', label: 'Edit', icon: 'edit', collapsed: false }),
39+
makeAction({ id: 'a2', label: 'Delete', icon: 'delete', collapsed: true }),
6740
];
68-
const wrapper = makeWrapper(actions);
69-
const iconBtns = wrapper.find('.icon-actions-wrapper').findAll('button');
70-
expect(iconBtns.length).toBe(1);
41+
renderComponent(actions);
42+
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument();
43+
// 'Delete' only appears inside the dropdown, not as a standalone icon button
44+
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument();
7145
});
7246

73-
it('does not render icon buttons for actions without an icon', () => {
74-
const actions = [makeAction({ id: 'a1', icon: null, collapsed: false })];
75-
const wrapper = makeWrapper(actions);
76-
const iconBtns = wrapper.find('.icon-actions-wrapper').findAll('button');
77-
expect(iconBtns.length).toBe(0);
78-
});
79-
80-
it('calls the action handler when an icon button is clicked', async () => {
47+
test('calls the action handler when an icon button is clicked', async () => {
48+
const user = userEvent.setup();
8149
const handler = jest.fn();
82-
const actions = [makeAction({ id: 'a1', icon: 'edit', collapsed: false, handler })];
83-
const wrapper = makeWrapper(actions);
84-
await wrapper.find('.icon-actions-wrapper button').trigger('click');
85-
expect(handler).toHaveBeenCalledTimes(1);
86-
});
87-
88-
it('passes the correct ariaLabel to each icon button', () => {
8950
const actions = [
90-
makeAction({ id: 'a1', icon: 'edit', label: 'Edit item', collapsed: false }),
51+
makeAction({ id: 'a1', label: 'Edit', icon: 'edit', collapsed: false, handler }),
9152
];
92-
const wrapper = makeWrapper(actions);
93-
const btn = wrapper.find('.icon-actions-wrapper button');
94-
expect(btn.attributes('aria-label')).toBe('Edit item');
53+
renderComponent(actions);
54+
await user.click(screen.getByRole('button', { name: 'Edit' }));
55+
expect(handler).toHaveBeenCalledTimes(1);
9556
});
9657
});
9758

9859
describe('collapsed dropdown menu', () => {
99-
it('does not render the options menu button when all actions are visible', () => {
60+
test('does not render the options button when there are no collapsed actions', () => {
10061
const actions = [makeAction({ id: 'a1', icon: 'edit', collapsed: false })];
101-
const wrapper = makeWrapper(actions);
102-
// Only 1 button should exist (the single icon action) — no options button
103-
expect(wrapper.findAll('button').length).toBe(1);
62+
renderComponent(actions);
63+
expect(screen.queryByRole('button', { name: optionsLabel$() })).not.toBeInTheDocument();
10464
});
10565

106-
it('renders the options menu button when there are collapsed actions', () => {
66+
test('renders the options button when there are collapsed actions', () => {
10767
const actions = [
10868
makeAction({ id: 'a1', icon: 'edit', collapsed: false }),
10969
makeAction({ id: 'a2', icon: null, label: 'Delete', collapsed: true, handler: jest.fn() }),
11070
];
111-
const wrapper = makeWrapper(actions);
112-
// 1 icon button + 1 options button
113-
expect(wrapper.findAll('button').length).toBe(2);
71+
renderComponent(actions);
72+
expect(screen.getByRole('button', { name: optionsLabel$() })).toBeInTheDocument();
11473
});
11574

116-
it('renders the options menu button when action has no icon (forces menu)', () => {
117-
// Even if collapsed: false, no icon → goes to menu
118-
const actions = [makeAction({ id: 'a1', icon: null, collapsed: false })];
119-
const wrapper = makeWrapper(actions);
120-
// 0 icon buttons + 1 options button
121-
expect(wrapper.findAll('button').length).toBe(1);
122-
// The options button should have the optionsVertical icon
123-
expect(wrapper.find('button').attributes('data-icon')).toBe('optionsVertical');
75+
test('renders the options button when an action has no icon (forces it to menu)', () => {
76+
const actions = [makeAction({ id: 'a1', icon: null, label: 'Delete', collapsed: false })];
77+
renderComponent(actions);
78+
expect(screen.getByRole('button', { name: optionsLabel$() })).toBeInTheDocument();
12479
});
12580

126-
it('calls the correct handler when a dropdown option is selected', async () => {
127-
const handler = jest.fn();
128-
const actions = [
129-
makeAction({ id: 'delete', icon: null, label: 'Delete', collapsed: true, handler }),
130-
];
131-
const wrapper = makeWrapper(actions);
132-
// Programmatically emit a select from the dropdown stub
133-
const dropdown = wrapper.findComponent(KDropdownMenuStub);
134-
await dropdown.vm.selectOption('delete');
135-
expect(handler).toHaveBeenCalledTimes(1);
81+
test('uses the provided optionsLabel prop for the menu button', () => {
82+
const actions = [makeAction({ id: 'a1', icon: null, label: 'Delete', collapsed: true })];
83+
renderComponent(actions, 'Custom options label');
84+
expect(screen.getByRole('button', { name: 'Custom options label' })).toBeInTheDocument();
13685
});
13786
});
13887

13988
describe('disabled state', () => {
140-
it('renders a disabled icon button when action.disabled is true', () => {
141-
const actions = [makeAction({ id: 'a1', icon: 'edit', collapsed: false, disabled: true })];
142-
const wrapper = makeWrapper(actions);
143-
const btn = wrapper.find('.icon-actions-wrapper button');
144-
expect(btn.attributes('disabled')).toBeDefined();
89+
test('renders the icon button as disabled when action.disabled is true', () => {
90+
const actions = [
91+
makeAction({ id: 'a1', label: 'Edit', icon: 'edit', collapsed: false, disabled: true }),
92+
];
93+
renderComponent(actions);
94+
expect(screen.getByRole('button', { name: 'Edit' })).toBeDisabled();
14595
});
14696
});
14797
});

contentcuration/contentcuration/frontend/shared/views/QTIEditor/components/CollapsibleToolbar/index.vue

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@
1818
v-if="collapsedMenuActions.length > 0"
1919
icon="optionsVertical"
2020
:color="$themePalette.grey.v_800"
21-
:tooltip="optionsLabel"
22-
:ariaLabel="optionsLabel"
21+
:tooltip="optionsLabel || optionsLabel$()"
22+
:ariaLabel="optionsLabel || optionsLabel$()"
2323
>
2424
<template #menu>
2525
<KDropdownMenu
@@ -35,12 +35,14 @@
3535

3636
<script>
3737
38-
import { computed, defineComponent } from 'vue';
38+
import { computed } from 'vue';
39+
import { commonStrings } from 'shared/strings/commonStrings';
3940
40-
export default defineComponent({
41+
export default {
4142
name: 'CollapsibleToolbar',
4243
4344
setup(props) {
45+
const { optionsLabel$ } = commonStrings;
4446
/** Actions with an icon that are not explicitly collapsed */
4547
const visibleIconActions = computed(() => {
4648
return props.actions.filter(a => !a.collapsed && Boolean(a.icon));
@@ -71,6 +73,7 @@
7173
collapsedMenuActions,
7274
dropdownOptions,
7375
handleSelect,
76+
optionsLabel$,
7477
};
7578
},
7679
@@ -101,10 +104,10 @@
101104
/** Tooltip text for the generic options menu icon */
102105
optionsLabel: {
103106
type: String,
104-
default: 'Options',
107+
default: null,
105108
},
106109
},
107-
});
110+
};
108111
109112
</script>
110113

Lines changed: 49 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,84 +1,76 @@
1-
import { mount } from '@vue/test-utils';
1+
import { render, screen, fireEvent } from '@testing-library/vue';
22
import QTIItemEditor from '../index.vue';
3+
import { qtiEditorStrings } from '../../../qtiEditorStrings';
34
import { QtiInteraction } from '../../../constants';
45

6+
const { closeBtnLabel$, questionContentPlaceholder$ } = qtiEditorStrings;
7+
58
const defaultProps = {
69
item: {
710
id: 'test-item-id',
811
type: QtiInteraction.CHOICE,
912
title: 'Test Choice Interaction',
1013
},
11-
index: 1,
14+
index: 0,
1215
total: 5,
1316
mode: 'view',
1417
displayAnswersPreview: false,
1518
};
1619

17-
function makeWrapper(props = {}, slots = {}) {
18-
return mount(QTIItemEditor, {
19-
propsData: { ...defaultProps, ...props },
20+
const renderComponent = (props = {}, slots = {}) => {
21+
return render(QTIItemEditor, {
22+
props: { ...defaultProps, ...props },
2023
slots,
21-
stubs: {
22-
KPageContainer: { template: '<div class="k-page-container"><slot></slot></div>' },
23-
KButton: { template: '<button @click="$emit(\'click\')"></button>' },
24-
},
2524
});
26-
}
25+
};
2726

2827
describe('QTIItemEditor', () => {
29-
it('renders correctly in view mode', () => {
30-
const wrapper = makeWrapper({ mode: 'view' });
31-
32-
// Card should exist
33-
expect(wrapper.find('[data-test="item"]').exists()).toBe(true);
34-
35-
// Should display the closed card label including interaction type (e.g., Choice)
36-
expect(wrapper.text()).toContain('1 of 5');
37-
38-
// Body should be hidden
39-
expect(wrapper.find('.question-card-body').exists()).toBe(false);
40-
41-
// Footer/Close button should be hidden
42-
expect(wrapper.find('[data-test="closeBtn"]').exists()).toBe(false);
43-
});
44-
45-
it('renders correctly in edit mode', () => {
46-
const wrapper = makeWrapper({ mode: 'edit' });
47-
48-
// Body should be visible
49-
expect(wrapper.find('.question-card-body').exists()).toBe(true);
50-
51-
// Footer/Close button should be visible
52-
expect(wrapper.find('[data-test="closeBtn"]').exists()).toBe(true);
28+
describe('view mode', () => {
29+
test('does not show the card body', () => {
30+
renderComponent({ mode: 'view' });
31+
expect(screen.queryByText(questionContentPlaceholder$())).not.toBeInTheDocument();
32+
});
33+
34+
test('does not show the close button', () => {
35+
renderComponent({ mode: 'view' });
36+
expect(screen.queryByRole('button', { name: closeBtnLabel$() })).not.toBeInTheDocument();
37+
});
5338
});
5439

55-
it('forces the body to render in view mode if displayAnswersPreview is true', () => {
56-
const wrapper = makeWrapper({ mode: 'view', displayAnswersPreview: true });
57-
58-
// Body should be visible due to displayAnswersPreview
59-
expect(wrapper.find('.question-card-body').exists()).toBe(true);
60-
61-
// Footer/Close button should STILL be hidden (only shown in edit mode)
62-
expect(wrapper.find('[data-test="closeBtn"]').exists()).toBe(false);
40+
describe('edit mode', () => {
41+
test('shows the card body', () => {
42+
renderComponent({ mode: 'edit' });
43+
expect(screen.getByText(questionContentPlaceholder$())).toBeInTheDocument();
44+
});
45+
46+
test('shows the close button', () => {
47+
renderComponent({ mode: 'edit' });
48+
expect(screen.getByRole('button', { name: closeBtnLabel$() })).toBeInTheDocument();
49+
});
50+
51+
test('emits a close event when the close button is clicked', async () => {
52+
const { emitted } = renderComponent({ mode: 'edit' });
53+
await fireEvent.click(screen.getByRole('button', { name: closeBtnLabel$() }));
54+
expect(emitted().close).toHaveLength(1);
55+
});
6356
});
6457

65-
it('correctly mounts the toolbarActions slot', () => {
66-
const wrapper = makeWrapper(
67-
{ mode: 'view' },
68-
{ toolbarActions: '<div data-test="injected-toolbar">Toolbar actions</div>' },
69-
);
58+
describe('displayAnswersPreview', () => {
59+
test('shows the card body in view mode when displayAnswersPreview is true', () => {
60+
renderComponent({ mode: 'view', displayAnswersPreview: true });
61+
expect(screen.getByText(questionContentPlaceholder$())).toBeInTheDocument();
62+
});
7063

71-
expect(wrapper.find('[data-test="injected-toolbar"]').exists()).toBe(true);
72-
expect(wrapper.text()).toContain('Toolbar actions');
64+
test('does not show the close button even when displayAnswersPreview is true', () => {
65+
renderComponent({ mode: 'view', displayAnswersPreview: true });
66+
expect(screen.queryByRole('button', { name: closeBtnLabel$() })).not.toBeInTheDocument();
67+
});
7368
});
7469

75-
it('emits a close event when the close button is clicked in edit mode', async () => {
76-
const wrapper = makeWrapper({ mode: 'edit' });
77-
78-
const closeBtn = wrapper.find('[data-test="closeBtn"]');
79-
await closeBtn.trigger('click');
80-
81-
expect(wrapper.emitted('close')).toBeTruthy();
82-
expect(wrapper.emitted('close').length).toBe(1);
70+
describe('toolbarActions slot', () => {
71+
test('renders content injected into the toolbarActions slot', () => {
72+
renderComponent({}, { toolbarActions: '<button>Edit</button>' });
73+
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument();
74+
});
8375
});
8476
});

0 commit comments

Comments
 (0)