From fa07af2d828ea5c6cb2e8bb49415c1d8af30588c Mon Sep 17 00:00:00 2001 From: Gazook89 Date: Fri, 29 May 2026 11:08:05 -0500 Subject: [PATCH] Add tests for the Dropdown structure and behavior Largely this is written by an LLM after I provided the targets, and I've looked through the tests themselves to confirm they are accurate. There may be improvements in how to actually *set up* the tests, though, as I am less familiar with those. This has two test suites: the first for html structure, the second for behavior. Each of them calls another script that provides helper functions for setting things up and taking them down. This keeps the test suites themselves fairly lean. --- .../dropdown/dropdown.behavior.test.jsx | 126 ++++++++++++++++ .../dropdown/dropdown.structure.test.jsx | 137 ++++++++++++++++++ .../components/dropdown/dropdown.testUtils.js | 49 +++++++ package.json | 6 +- tests/__mocks__/styleMock.js | 1 + 5 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 client/components/dropdown/dropdown.behavior.test.jsx create mode 100644 client/components/dropdown/dropdown.structure.test.jsx create mode 100644 client/components/dropdown/dropdown.testUtils.js create mode 100644 tests/__mocks__/styleMock.js diff --git a/client/components/dropdown/dropdown.behavior.test.jsx b/client/components/dropdown/dropdown.behavior.test.jsx new file mode 100644 index 000000000..51387a436 --- /dev/null +++ b/client/components/dropdown/dropdown.behavior.test.jsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { Dropdown } from './dropdown.jsx'; +import { createOpenMenu, renderDropdown, setupDropdownTestLifecycle } from './dropdown.testUtils.js'; + +setupDropdownTestLifecycle(); + +describe('Dropdown behavior', ()=>{ + it('calls child click handlers when menu items are clicked', async ()=>{ + const onClick = jest.fn(); + const { host, cleanup } = await renderDropdown( + + + + ); + + host.querySelector('.menu-list .menu-item').click(); + expect(onClick).toHaveBeenCalledTimes(1); + + await cleanup(); + }); + + it('dismisses open menus when clicking a normal menu action', async ()=>{ + const openMenu1 = createOpenMenu(); + const openMenu2 = createOpenMenu(); + const querySpy = jest.spyOn(document, 'querySelectorAll').mockImplementation((selector)=>{ + if(selector === '.menu-list:popover-open') return [openMenu1, openMenu2]; + return []; + }); + + const { host, cleanup } = await renderDropdown( + + + + ); + + host.querySelector('.menu-list .menu-item').click(); + + expect(querySpy).toHaveBeenCalledWith('.menu-list:popover-open'); + expect(openMenu1.hidePopover).toHaveBeenCalledTimes(1); + expect(openMenu2.hidePopover).toHaveBeenCalledTimes(1); + + await cleanup(); + }); + + it('does not dismiss when no-dismiss is set to empty string or true', async ()=>{ + for (const noDismissValue of ['', 'true']) { + const openMenu = createOpenMenu(); + const querySpy = jest.spyOn(document, 'querySelectorAll').mockImplementation((selector)=>{ + if(selector === '.menu-list:popover-open') return [openMenu]; + return []; + }); + + const { host, cleanup } = await renderDropdown( + + + + ); + + host.querySelector('.menu-list .menu-item').click(); + expect(querySpy).not.toHaveBeenCalled(); + expect(openMenu.hidePopover).not.toHaveBeenCalled(); + + await cleanup(); + querySpy.mockRestore(); + } + }); + + it('does dismiss when no-dismiss is explicitly false', async ()=>{ + const openMenu = createOpenMenu(); + const querySpy = jest.spyOn(document, 'querySelectorAll').mockImplementation((selector)=>{ + if(selector === '.menu-list:popover-open') return [openMenu]; + return []; + }); + + const { host, cleanup } = await renderDropdown( + + + + ); + + host.querySelector('.menu-list .menu-item').click(); + + expect(querySpy).toHaveBeenCalledWith('.menu-list:popover-open'); + expect(openMenu.hidePopover).toHaveBeenCalledTimes(1); + + await cleanup(); + }); + + it('does not dismiss when clicking submenu trigger actions', async ()=>{ + const querySpy = jest.spyOn(document, 'querySelectorAll'); + const { host, cleanup } = await renderDropdown( + + + + + + ); + + const submenuTrigger = host.querySelectorAll('.menu-wrapper > .menu-item')[1]; + submenuTrigger.click(); + + expect(submenuTrigger.hasAttribute('popovertarget')).toBe(true); + expect(querySpy).not.toHaveBeenCalledWith('.menu-list:popover-open'); + + await cleanup(); + }); + + it('handles iframe-click by hiding the open popover for this menu', async ()=>{ + const { host, cleanup } = await renderDropdown( + + + + ); + + const menu = host.querySelector('.menu-list'); + menu.matches = jest.fn((selector)=>selector === ':popover-open'); + menu.hidePopover = jest.fn(); + + document.dispatchEvent(new Event('iframe-click')); + + expect(menu.matches).toHaveBeenCalledWith(':popover-open'); + expect(menu.hidePopover).toHaveBeenCalledTimes(1); + + await cleanup(); + }); +}); diff --git a/client/components/dropdown/dropdown.structure.test.jsx b/client/components/dropdown/dropdown.structure.test.jsx new file mode 100644 index 000000000..ceacfe4af --- /dev/null +++ b/client/components/dropdown/dropdown.structure.test.jsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { Dropdown } from './dropdown.jsx'; +import { renderDropdown, setupDropdownTestLifecycle } from './dropdown.testUtils.js'; + +setupDropdownTestLifecycle(); + +describe('Dropdown structure', ()=>{ + it('renders expected structure, classes, and accessibility roles', async ()=>{ + const { host, cleanup } = await renderDropdown( + + + + ); + + const wrapper = host.querySelector('.menu-wrapper'); + const trigger = wrapper.querySelector('button.menu-item.blue'); + const menu = wrapper.querySelector('.menu-list'); + + expect(wrapper).toBeTruthy(); + expect(wrapper.className).toContain('extra-class'); + expect(wrapper.getAttribute('role')).toBe('none'); + expect(trigger.getAttribute('aria-haspopup')).toBe('menu'); + expect(trigger.getAttribute('role')).toBe('menuitem'); + expect(menu.getAttribute('role')).toBe('menu'); + expect(menu.getAttribute('popover')).toBe('auto'); + + await cleanup(); + }); + + it('creates ids and popovertarget values that match and are unique per instance', async ()=>{ + const { host, cleanup } = await renderDropdown( +
+ + + + + + +
+ ); + + const triggers = Array.from(host.querySelectorAll('.menu-wrapper > .menu-item')); + const menus = Array.from(host.querySelectorAll('.menu-list')); + const menuIds = menus.map((menu)=>menu.id); + + expect(triggers).toHaveLength(2); + expect(menus).toHaveLength(2); + expect(new Set(menuIds).size).toBe(2); + expect(triggers[0].getAttribute('popovertarget')).toBe(menuIds[0]); + expect(triggers[1].getAttribute('popovertarget')).toBe(menuIds[1]); + expect(triggers[0].id).toBe(`${menuIds[0]}-trigger`); + + await cleanup(); + }); + + it('applies css anchor styles to trigger and menu', async ()=>{ + const { host, cleanup } = await renderDropdown( + + + + ); + + const trigger = host.querySelector('.menu-wrapper > .menu-item'); + const menu = host.querySelector('.menu-list'); + const menuId = menu.id; + + expect(trigger.getAttribute('style')).toBe(`anchor-name: --${menuId}`); + expect(menu.getAttribute('style')).toBe(`position-anchor: --${menuId}`); + + await cleanup(); + }); + + it('disables the trigger when no children are present', async ()=>{ + const { host, cleanup } = await renderDropdown(); + + expect(host.querySelector('.menu-wrapper > .menu-item').disabled).toBe(true); + + await cleanup(); + }); + + it('renders icon class and default caret', async ()=>{ + const { host, cleanup } = await renderDropdown( + + + + ); + + const trigger = host.querySelector('.menu-wrapper > .menu-item'); + expect(trigger.querySelector('i.fas.fa-star')).toBeTruthy(); + expect(trigger.querySelector('i.caret').className).toContain('fa-caret-down'); + + await cleanup(); + }); + + it('renders a custom trigger when provided', async ()=>{ + const { host, cleanup } = await renderDropdown( + Open Menu}> + + + ); + + const trigger = host.querySelector('.menu-wrapper > .menu-item'); + expect(trigger.querySelector('.custom-trigger')).toBeTruthy(); + expect(trigger.querySelector('.caret')).toBeFalsy(); + + await cleanup(); + }); + + it('supports nested submenus to at least 3 levels deep', async ()=>{ + const { host, cleanup } = await renderDropdown( + + + + + + + + ); + + const triggers = Array.from(host.querySelectorAll('.menu-wrapper > .menu-item')); + const menus = Array.from(host.querySelectorAll('.menu-list')); + + expect(triggers).toHaveLength(3); + expect(menus).toHaveLength(3); + + for (const trigger of triggers) { + const targetId = trigger.getAttribute('popovertarget'); + expect(targetId).toBeTruthy(); + expect(host.querySelector(`#${targetId}`)).toBeTruthy(); + } + + expect(triggers[1].querySelector('.caret').className).toContain('fa-caret-right'); + expect(triggers[2].querySelector('.caret').className).toContain('fa-caret-right'); + + await cleanup(); + }); +}); diff --git a/client/components/dropdown/dropdown.testUtils.js b/client/components/dropdown/dropdown.testUtils.js new file mode 100644 index 000000000..f7467bea7 --- /dev/null +++ b/client/components/dropdown/dropdown.testUtils.js @@ -0,0 +1,49 @@ +import globalJsdom from 'jsdom-global'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; + +let cleanupJsdom; + +const setupDropdownTestLifecycle = ()=>{ + beforeAll(()=>{ + cleanupJsdom = globalJsdom(); + }); + + afterAll(()=>{ + cleanupJsdom?.(); + }); + + afterEach(()=>{ + document.body.innerHTML = ''; + jest.restoreAllMocks(); + }); +}; + +const renderDropdown = async (element)=>{ + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async ()=>{ + root.render(element); + }); + + return { + host, + cleanup : async ()=>{ + await act(async ()=>{ + root.unmount(); + }); + host.remove(); + } + }; +}; + +const createOpenMenu = ()=>{ + const menu = document.createElement('div'); + menu.className = 'menu-list'; + menu.hidePopover = jest.fn(); + return menu; +}; + +export { setupDropdownTestLifecycle, renderDropdown, createOpenMenu }; diff --git a/package.json b/package.json index 7ed436d6d..9b8a2d937 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "test:coverage": "jest --coverage --silent --runInBand", "test:dev": "jest --verbose --watch", "test:basic": "jest tests/markdown/basic.test.js --verbose", + "test:components": "jest \"client/components/.*\\.test\\.(js|jsx)$\" --verbose", "test:variables": "jest tests/markdown/variables.test.js --verbose", "test:mustache-syntax": "jest \".*(mustache-syntax).*\" --verbose --noStackTrace", "test:mustache-syntax:inline": "jest \".*(mustache-syntax).*\" -t '^Inline:.*' --verbose --noStackTrace", @@ -61,9 +62,12 @@ "node_modules/(?!(nanoid|@exodus/bytes|parse5|@asamuzakjp|@csstools|entities)/)" ], "transform": { - "^.+\\.[jt]s$": "babel-jest", + "^.+\\.[jt]sx?$": "babel-jest", "^.+\\.mjs$": "babel-jest" }, + "moduleNameMapper": { + "\\.(less|css)$": "/tests/__mocks__/styleMock.js" + }, "coveragePathIgnorePatterns": [ "build/*" ], diff --git a/tests/__mocks__/styleMock.js b/tests/__mocks__/styleMock.js new file mode 100644 index 000000000..f053ebf79 --- /dev/null +++ b/tests/__mocks__/styleMock.js @@ -0,0 +1 @@ +module.exports = {};