Skip to content

Commit 528fcd1

Browse files
committed
test(course-outline): deduplicate card tests
Extract 8 shared card tests into describeCard factory (card-test-factory.tsx). Refactor SectionCard, SubsectionCard, UnitCard test files to call factory, keeping only unique tests. Extract 4 shared sidebar menu tests into describeSidebarMenus helper in InfoSidebar.test.tsx. 70/70 tests pass. No new lint/format/typecheck issues.
1 parent 4524836 commit 528fcd1

5 files changed

Lines changed: 583 additions & 780 deletions

File tree

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
/**
2+
* Shared card test factory for SectionCard / SubsectionCard / UnitCard.
3+
*
4+
* Extracts ~8 structurally identical test blocks into a parameterized
5+
* describeCard() call so each card test file keeps only its unique tests.
6+
*
7+
* NOTE: jest.mock() calls remain in each test file (Jest hoisting
8+
* requirement). This module provides the test bodies and beforeEach
9+
* setup, closing over mutable handles from testSetup.
10+
*/
11+
import React from 'react';
12+
import { getConfig, setConfig } from '@edx/frontend-platform';
13+
import {
14+
act,
15+
fireEvent,
16+
screen,
17+
waitFor,
18+
within,
19+
} from '@src/testUtils';
20+
import { userEvent } from '@testing-library/user-event';
21+
import { getXBlockApiUrl } from '@src/course-outline/data/api';
22+
import { Info } from '@openedx/paragon/icons';
23+
import { CourseInfoSidebar } from '@src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar';
24+
import * as OutlineSidebarContext from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
25+
import {
26+
mockAcceptLibBlockChanges,
27+
mockIgnoreLibBlockChanges,
28+
setupCardTestMocks,
29+
} from './testSetup';
30+
31+
/** Props that can be passed through to the card under test. */
32+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
33+
type AnyProps = Record<string, any>;
34+
35+
export interface CardTestConfig {
36+
/** Display name in describe() — e.g. 'SectionCard'. */
37+
name: string;
38+
/** data-testid on the card root — e.g. 'section-card'. */
39+
testId: string;
40+
/** data-testid on the card header — e.g. 'section-card-header'. */
41+
headerTestId: string;
42+
/** The primary mock block (section / subsection / unit). */
43+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
44+
mockBlock: any;
45+
/**
46+
* Render the card with optional prop overrides and URL entry.
47+
* Must call renderCard from testSetup so the component is wrapped with
48+
* CourseOutlineProvider + OutlineSidebarProvider.
49+
*/
50+
render: (props?: AnyProps, entry?: string) => { container: HTMLElement; };
51+
/** data-testid for child container (expand/collapse) — optional. */
52+
expandTestId?: string;
53+
/** aria-label / name for the "add child" button — optional. */
54+
childAddLabel?: string;
55+
/** Whether this card has expand/collapse behavior. */
56+
hasExpandCollapse?: boolean;
57+
/** NodeName for the sync preview modal heading (e.g. 'section name'). */
58+
syncNodeName: string;
59+
/**
60+
* Custom assertion for the align sidebar test.
61+
* Default: checks setSelectedContainerState with currentId + sectionId + index.
62+
* Pass null to skip the setSelectedContainerState check entirely.
63+
*/
64+
alignAssert?: ((mockSetSelectedContainerState: jest.Mock) => void) | null;
65+
/** Skip the align sidebar test entirely (e.g. when OutlineSidebarContext is pre-mocked). */
66+
skipAlignTest?: boolean;
67+
/** Extra assertions to run inside the "renders correctly" test. */
68+
extraRenderAssertions?: () => void;
69+
/** Skip the "hides actions by flag" test (UnitCard has no childAddable). */
70+
skipActionsHideTest?: boolean;
71+
/**
72+
* Custom assertion for the "hide actions" test after the menu opens.
73+
* Default: checks duplicate + delete buttons hidden.
74+
*/
75+
extraActionsHideAssertions?: () => void;
76+
/** The prop key used to pass the primary block (e.g. 'section', 'subsection', 'unit'). */
77+
blockPropKey: string;
78+
}
79+
80+
/**
81+
* Define the 8 shared card tests inside a describe() block.
82+
*/
83+
export function describeCard(config: CardTestConfig): void {
84+
describe(`<${config.name}>`, () => {
85+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
86+
let axiosMock: any;
87+
88+
beforeEach(() => {
89+
const mocks = setupCardTestMocks();
90+
axiosMock = mocks.axiosMock;
91+
// Seed the item cache so the card's useCourseItemData resolves
92+
axiosMock
93+
.onGet(getXBlockApiUrl(config.mockBlock.id))
94+
.reply(200, config.mockBlock);
95+
});
96+
97+
// ─── 1. renders correctly ──────────────────────────────────────
98+
it(`render ${config.name} component correctly`, () => {
99+
const { container } = config.render();
100+
101+
expect(screen.getByTestId(config.headerTestId)).toBeInTheDocument();
102+
const card = screen.getByTestId(config.testId);
103+
expect(card).not.toHaveClass('outline-card-selected');
104+
config.extraRenderAssertions?.();
105+
void container;
106+
});
107+
108+
// ─── 2. renders in selected state ───────────────────────────────
109+
it(`render ${config.name} component in selected state`, async () => {
110+
const user = userEvent.setup();
111+
const { container } = config.render();
112+
113+
expect(screen.getByTestId(config.headerTestId)).toBeInTheDocument();
114+
const card = screen.getByTestId(config.testId);
115+
expect(card).not.toHaveClass('outline-card-selected');
116+
117+
const el = container.querySelector('div.row.mx-0') as HTMLElement;
118+
expect(el).not.toBeNull();
119+
await user.click(el!);
120+
121+
expect(card).toHaveClass('outline-card-selected');
122+
});
123+
124+
// ─── 3. menu does not select ────────────────────────────────────
125+
it(`does not select ${config.name.toLowerCase()} when menu opens`, async () => {
126+
const user = userEvent.setup();
127+
config.render();
128+
129+
const card = screen.getByTestId(config.testId);
130+
const menuButton = await screen.findByTestId(`${config.headerTestId}__menu-button`);
131+
await user.click(menuButton);
132+
133+
expect(card).not.toHaveClass('outline-card-selected');
134+
});
135+
136+
// ─── 4. hides header ────────────────────────────────────────────
137+
it('hides header based on isHeaderVisible flag', () => {
138+
config.render({
139+
[config.blockPropKey]: { ...config.mockBlock, isHeaderVisible: false },
140+
});
141+
expect(screen.queryByTestId(config.headerTestId)).not.toBeInTheDocument();
142+
});
143+
144+
// ─── 5. hides actions based on flags ────────────────────────────
145+
if (!config.skipActionsHideTest) {
146+
it('hides add new, duplicate & delete option based on childAddable, duplicable & deletable action flag', async () => {
147+
const mockData = {
148+
...config.mockBlock,
149+
actions: { draggable: true, childAddable: false, deletable: false, duplicable: false },
150+
};
151+
axiosMock
152+
.onGet(getXBlockApiUrl(config.mockBlock.id))
153+
.reply(200, mockData);
154+
config.render({ [config.blockPropKey]: mockData });
155+
156+
const element = await screen.findByTestId(config.testId);
157+
const menu = await within(element).findByTestId(`${config.headerTestId}__menu-button`);
158+
await act(async () => fireEvent.click(menu));
159+
expect(within(element).queryByTestId(`${config.headerTestId}__menu-duplicate-button`))
160+
.not.toBeInTheDocument();
161+
expect(within(element).queryByTestId(`${config.headerTestId}__menu-delete-button`))
162+
.not.toBeInTheDocument();
163+
164+
if (config.childAddLabel) {
165+
expect(screen.queryByRole('button', { name: config.childAddLabel }))
166+
.not.toBeInTheDocument();
167+
}
168+
config.extraActionsHideAssertions?.();
169+
});
170+
}
171+
172+
// ─── 6. sync upstream ───────────────────────────────────────────
173+
it(`should sync ${config.name.toLowerCase()} changes from upstream`, async () => {
174+
config.render();
175+
176+
expect(await screen.findByTestId(config.headerTestId)).toBeInTheDocument();
177+
178+
const syncButton = screen.getByRole('button', { name: /update available - click to sync/i });
179+
fireEvent.click(syncButton);
180+
181+
expect(screen.getByRole('heading', {
182+
name: new RegExp(`preview changes: ${config.syncNodeName}`, 'i'),
183+
})).toBeInTheDocument();
184+
185+
const acceptChangesButton = screen.getByText(/accept changes/i);
186+
fireEvent.click(acceptChangesButton);
187+
188+
await waitFor(() => expect(mockAcceptLibBlockChanges).toHaveBeenCalled());
189+
});
190+
191+
// ─── 7. decline upstream ────────────────────────────────────────
192+
it(`should decline sync ${config.name.toLowerCase()} changes from upstream`, async () => {
193+
config.render();
194+
195+
expect(await screen.findByTestId(config.headerTestId)).toBeInTheDocument();
196+
197+
const syncButton = screen.getByRole('button', { name: /update available - click to sync/i });
198+
fireEvent.click(syncButton);
199+
200+
expect(screen.getByRole('heading', {
201+
name: new RegExp(`preview changes: ${config.syncNodeName}`, 'i'),
202+
})).toBeInTheDocument();
203+
204+
const ignoreChangesButton = screen.getByRole('button', { name: /ignore changes/i });
205+
fireEvent.click(ignoreChangesButton);
206+
207+
expect(screen.getByRole('heading', { name: /ignore these changes\?/i })).toBeInTheDocument();
208+
209+
const ignoreButton = screen.getByRole('button', { name: /ignore/i });
210+
fireEvent.click(ignoreButton);
211+
212+
await waitFor(() => expect(mockIgnoreLibBlockChanges).toHaveBeenCalled());
213+
});
214+
215+
// ─── 8. open align sidebar ──────────────────────────────────────
216+
if (!config.skipAlignTest) {
217+
it('should open align sidebar', async () => {
218+
const user = userEvent.setup();
219+
const mockSetCurrentPageKey = jest.fn();
220+
const mockSetSelectedContainerState = jest.fn();
221+
222+
const testSidebarPage = {
223+
component: CourseInfoSidebar,
224+
icon: Info,
225+
title: '',
226+
};
227+
228+
jest
229+
.spyOn(OutlineSidebarContext, 'useOutlineSidebarContext')
230+
.mockImplementation(() => ({
231+
setCurrentPageKey: mockSetCurrentPageKey,
232+
currentPageKey: 'info',
233+
sidebarPages: {
234+
info: testSidebarPage,
235+
help: testSidebarPage,
236+
add: testSidebarPage,
237+
},
238+
currentTabKey: 'info',
239+
setCurrentTabKey: jest.fn(),
240+
openContainerSidebar: jest.fn(),
241+
isOpen: true,
242+
open: jest.fn(),
243+
toggle: jest.fn(),
244+
currentFlow: undefined,
245+
startCurrentFlow: jest.fn(),
246+
stopCurrentFlow: jest.fn(),
247+
openContainerInfoSidebar: jest.fn(),
248+
clearSelection: jest.fn(),
249+
setSelectedContainerState: mockSetSelectedContainerState,
250+
}));
251+
setConfig({
252+
...getConfig(),
253+
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
254+
});
255+
config.render();
256+
const element = await screen.findByTestId(config.testId);
257+
const menu = await within(element).findByTestId(`${config.headerTestId}__menu-button`);
258+
await user.click(menu);
259+
260+
const manageTagsBtn = await within(element).findByTestId(`${config.headerTestId}__menu-manage-tags-button`);
261+
expect(manageTagsBtn).toBeInTheDocument();
262+
263+
await user.click(manageTagsBtn);
264+
265+
await waitFor(() => {
266+
expect(mockSetCurrentPageKey).toHaveBeenCalledWith('align');
267+
});
268+
269+
if (config.alignAssert) {
270+
config.alignAssert(mockSetSelectedContainerState);
271+
} else if (config.alignAssert !== null) {
272+
// Default: check setSelectedContainerState with currentId + sectionId + index
273+
expect(mockSetSelectedContainerState).toHaveBeenCalledWith({
274+
currentId: config.mockBlock.id,
275+
sectionId: config.mockBlock.id,
276+
index: 1,
277+
});
278+
}
279+
});
280+
}
281+
});
282+
}

0 commit comments

Comments
 (0)