|
| 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