0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2026-08-06 06:47:38 +00:00

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.
This commit is contained in:
Gazook89
2026-05-29 11:08:05 -05:00
parent f716c390b9
commit fa07af2d82
5 changed files with 318 additions and 1 deletions
@@ -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(
<Dropdown groupName='Events'>
<button className='menu-item' role='menuitem' onClick={onClick}>Click Me</button>
</Dropdown>
);
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(
<Dropdown groupName='Dismiss'>
<button className='menu-item' role='menuitem'>Close</button>
</Dropdown>
);
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(
<Dropdown groupName={`No Dismiss ${noDismissValue || 'empty'}`}>
<button className='menu-item' role='menuitem' no-dismiss={noDismissValue}>Stay Open</button>
</Dropdown>
);
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(
<Dropdown groupName='No Dismiss False'>
<button className='menu-item' role='menuitem' no-dismiss='false'>Close</button>
</Dropdown>
);
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(
<Dropdown groupName='Top'>
<Dropdown groupName='Child'>
<button className='menu-item' role='menuitem'>Leaf</button>
</Dropdown>
</Dropdown>
);
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(
<Dropdown groupName='Iframe Dismiss'>
<button className='menu-item' role='menuitem'>Item</button>
</Dropdown>
);
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();
});
});
@@ -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(
<Dropdown groupName='Actions' className='extra-class' color='blue'>
<button className='menu-item' role='menuitem'>Do Thing</button>
</Dropdown>
);
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(
<div>
<Dropdown groupName='Repeated Label'>
<button className='menu-item' role='menuitem'>A</button>
</Dropdown>
<Dropdown groupName='Repeated Label'>
<button className='menu-item' role='menuitem'>B</button>
</Dropdown>
</div>
);
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(
<Dropdown groupName='Anchored'>
<button className='menu-item' role='menuitem'>Item</button>
</Dropdown>
);
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(<Dropdown groupName='Empty' />);
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(
<Dropdown groupName='Icons' icon='fas fa-star'>
<button className='menu-item' role='menuitem'>Item</button>
</Dropdown>
);
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(
<Dropdown groupName='Custom Trigger' customTrigger={<span className='custom-trigger'>Open Menu</span>}>
<button className='menu-item' role='menuitem'>Item</button>
</Dropdown>
);
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(
<Dropdown groupName='Level 1'>
<Dropdown groupName='Level 2'>
<Dropdown groupName='Level 3'>
<button className='menu-item' role='menuitem'>Leaf</button>
</Dropdown>
</Dropdown>
</Dropdown>
);
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();
});
});
@@ -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 };
+5 -1
View File
@@ -29,6 +29,7 @@
"test:coverage": "jest --coverage --silent --runInBand", "test:coverage": "jest --coverage --silent --runInBand",
"test:dev": "jest --verbose --watch", "test:dev": "jest --verbose --watch",
"test:basic": "jest tests/markdown/basic.test.js --verbose", "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:variables": "jest tests/markdown/variables.test.js --verbose",
"test:mustache-syntax": "jest \".*(mustache-syntax).*\" --verbose --noStackTrace", "test:mustache-syntax": "jest \".*(mustache-syntax).*\" --verbose --noStackTrace",
"test:mustache-syntax:inline": "jest \".*(mustache-syntax).*\" -t '^Inline:.*' --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)/)" "node_modules/(?!(nanoid|@exodus/bytes|parse5|@asamuzakjp|@csstools|entities)/)"
], ],
"transform": { "transform": {
"^.+\\.[jt]s$": "babel-jest", "^.+\\.[jt]sx?$": "babel-jest",
"^.+\\.mjs$": "babel-jest" "^.+\\.mjs$": "babel-jest"
}, },
"moduleNameMapper": {
"\\.(less|css)$": "<rootDir>/tests/__mocks__/styleMock.js"
},
"coveragePathIgnorePatterns": [ "coveragePathIgnorePatterns": [
"build/*" "build/*"
], ],
+1
View File
@@ -0,0 +1 @@
module.exports = {};