import Masonry from 'masonry-layout'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { MediaGridController, type ExtendedMasonry, type MediaGridConstructorOptions, } from '../../src/components-lib/media-grid-controller'; import { createSlot, createSlotHost, MutationObserverMock, ResizeObserverMock, } from '../test-utils'; vi.mock('lodash-es', async () => ({ ...(await vi.importActual('lodash-es')), throttle: vi.fn((fn) => { const throttled = vi.fn(fn) as unknown as { cancel: () => void }; throttled.cancel = vi.fn(); return throttled; }), })); const masonry = mock(); // The source calls `new Masonry(...)`, and a mock implementation must be // callable with `new`, so it cannot be an arrow function. vi.mock('masonry-layout', () => ({ default: vi.fn().mockImplementation(function () { return masonry; }), })); const createChildren = (childIDs?: string[], idAttribute?: string): HTMLElement[] => { const children: HTMLElement[] = []; for (let i = 0; i < (childIDs?.length ?? 3); ++i) { const child = document.createElement('div'); if (childIDs) { child.setAttribute(idAttribute ?? 'grid-id', childIDs[i]); } children.push(child); } return children; }; const setElementWidth = (element: HTMLElement, width: number): void => { element.getBoundingClientRect = vi.fn().mockReturnValue({ width: width, }); }; const createParent = (options?: { children?: HTMLElement[]; width?: number; }): HTMLElement => { const host = document.createElement('div'); if (options?.children) { host.append(...options.children); } // Default Lovelace card width is 492. setElementWidth(host, options?.width ?? 492); return host; }; const createController = (host: HTMLElement, options?: MediaGridConstructorOptions) => { return new MediaGridController(host, options); }; const triggerMutationObserver = ( hostOrCell: 'cell' | 'host', attributeName?: string, ): void => { const mutationObserverTrigger = vi.mocked(global.MutationObserver).mock.calls[ hostOrCell === 'host' ? 0 : 1 ][0]; mutationObserverTrigger( attributeName ? [mock({ attributeName: attributeName })] : [], mock(), ); }; const triggerResizeObserver = (hostOrCell: 'cell' | 'host'): void => { const resizeObserverTrigger = vi.mocked(global.ResizeObserver).mock.calls[ hostOrCell === 'host' ? 0 : 1 ][0]; resizeObserverTrigger([], mock()); }; // @vitest-environment jsdom describe('MediaGridController', () => { beforeEach(() => { vi.clearAllMocks(); vi.stubGlobal('MutationObserver', MutationObserverMock); vi.stubGlobal('ResizeObserver', ResizeObserverMock); masonry.items = []; }); it('should be constructable', () => { const controller = createController(createParent()); expect(controller).toBeTruthy(); expect(masonry.layout).toHaveBeenCalled(); }); it('should set grid contents correctly from regular elements', () => { const children = createChildren(); const parent = createParent({ children: children }); const controller = createController(parent); expect(controller.getGridContents()).toEqual( new Map([ ['0', children[0]], ['1', children[1]], ['2', children[2]], ]), ); expect(controller.getGridSize()).toBe(3); expect(masonry.layout).toHaveBeenCalled(); }); it('should set grid contents correctly from slotted elements', () => { const children = createChildren(); const slot = createSlot(); const host = createSlotHost({ slot: slot, children: children }); const controller = createController(host); expect(controller.getGridContents()).toEqual( new Map([ ['0', children[0]], ['1', children[1]], ['2', children[2]], ]), ); expect(controller.getGridSize()).toBe(3); }); it('should select element', () => { const children = createChildren(); const slot = createSlot(); createSlotHost({ slot: slot, children: children }); const controller = createController(slot); // All children should be unselected. expect(controller.getSelected()).toBeNull(); for (const child of children) { expect(child.getAttribute('selected')).toBeNull(); expect(child.getAttribute('unselected')).toEqual(''); } controller.selectCell('0'); expect(controller.getSelected()).toBe('0'); // 1st child should now be selected. expect(children[0].getAttribute('selected')).toEqual(''); expect(children[0].getAttribute('unselected')).toBeNull(); // 2nd and 3rd should be unselected. for (const child of children.slice(1)) { expect(child.getAttribute('selected')).toBeNull(); expect(child.getAttribute('unselected')).toEqual(''); } }); it('should re-select element', () => { const children = createChildren(); const slot = createSlot(); createSlotHost({ slot: slot, children: children }); const controller = createController(slot); // All children should be unselected. expect(controller.getSelected()).toBeNull(); controller.selectCell('0'); expect(controller.getSelected()).toBe('0'); controller.selectCell('0'); expect(controller.getSelected()).toBe('0'); }); it('should unselect', () => { const children = createChildren(); const slot = createSlot(); const host = createSlotHost({ slot: slot, children: children }); const controller = createController(host); const unselectedHandler = vi.fn(); host.addEventListener( 'advanced-camera-card:media-grid:unselected', unselectedHandler, ); controller.selectCell('0'); expect(controller.getSelected()).toBe('0'); // Unselect all elements. controller.unselectAll(); // Expect selected to now be null. expect(controller.getSelected()).toBeNull(); // Expect styles to have been updated. for (const child of children) { expect(child.getAttribute('selected')).toBeNull(); expect(child.getAttribute('unselected')).toEqual(''); } // The grid signals its own state change via media-grid:unselected. expect(unselectedHandler).toHaveBeenCalledTimes(1); // Unselecting a second time should do nothing. controller.unselectAll(); expect(unselectedHandler).toHaveBeenCalledTimes(1); }); it('should select in constructor', () => { const children = createChildren(); const slot = createSlot(); const host = createSlotHost({ slot: slot, children: children }); const controller = createController(host, { selected: '2' }); expect(controller.getSelected()).toBe('2'); }); it('should respect grid attribute option', () => { const children = createChildren(['one', 'two', 'three'], 'test-id'); const slot = createSlot(); const host = createSlotHost({ slot: slot, children: children }); const controller = createController(host, { idAttribute: 'test-id' }); expect(controller.getGridContents()).toEqual( new Map([ ['one', children[0]], ['two', children[1]], ['three', children[2]], ]), ); }); it('should destroy with regular elements', () => { const children = createChildren(); const parent = createParent({ children: children }); const controller = createController(parent); expect(controller.getGridSize()).toBe(3); controller.destroy(); expect(controller.getGridSize()).toBe(0); }); it('should destroy with slotted elements', () => { const children = createChildren(); const slot = createSlot(); createSlotHost({ slot: slot, children: children }); const controller = createController(slot); expect(controller.getGridSize()).toBe(3); controller.destroy(); expect(controller.getGridSize()).toBe(0); }); it('should replace children when they change', () => { const children = createChildren(); const parent = createParent({ children: children }); const controller = createController(parent, { selected: '1' }); expect(controller.getSelected()).toBe('1'); expect(controller.getGridSize()).toBe(3); children.forEach((child) => parent.removeChild(child)); const newChildren = createChildren(['one', 'two', 'three']); newChildren.forEach((child) => parent.appendChild(child)); triggerMutationObserver('host'); expect(controller.getGridContents()).toEqual( new Map([ ['one', newChildren[0]], ['two', newChildren[1]], ['three', newChildren[2]], ]), ); expect(controller.getSelected()).toBeNull(); }); it('should re-calculate children when id attribute changes', () => { const children = createChildren(['one', 'two', 'three'], 'test-id'); const parent = createParent({ children: children }); const controller = createController(parent, { selected: 'one', idAttribute: 'test-id', }); expect(controller.getSelected()).toBe('one'); expect(controller.getGridSize()).toBe(3); children[0].setAttribute('test-id', 'alpha'); children[1].setAttribute('test-id', 'beta'); children[2].setAttribute('test-id', 'gamma'); triggerMutationObserver('cell', 'test-id'); expect(controller.getGridContents()).toEqual( new Map([ ['alpha', children[0]], ['beta', children[1]], ['gamma', children[2]], ]), ); expect(controller.getSelected()).toBeNull(); }); it('should replace children of a slot when they change', () => { const children = createChildren(); const slot = createSlot(); const host = createSlotHost({ slot: slot, children: children }); const controller = createController(slot, { selected: '1' }); expect(controller.getSelected()).toBe('1'); expect(controller.getGridSize()).toBe(3); children.forEach((child) => host.removeChild(child)); const newChildren = createChildren(['one', 'two', 'three']); newChildren.forEach((child) => host.append(child)); slot.dispatchEvent(new Event('slotchange')); expect(controller.getGridContents()).toEqual( new Map([ ['one', newChildren[0]], ['two', newChildren[1]], ['three', newChildren[2]], ]), ); expect(controller.getSelected()).toBeNull(); }); it('should construct masonry correctly', () => { const children = createChildren(); const parent = createParent({ children: children }); createController(parent); expect(Masonry).toHaveBeenCalledWith( parent, expect.objectContaining({ initLayout: false, percentPosition: true, transitionDuration: 0, }), ); }); it('should set default column size correctly', () => { const parent = createParent({ children: createChildren() }); createController(parent); expect(Masonry).toHaveBeenCalledWith( parent, expect.objectContaining({ columnWidth: 245, }), ); expect( parent.style.getPropertyValue('--advanced-camera-card-grid-column-size'), ).toBe('245px'); }); it('should respect exact columns', () => { const parent = createParent({ children: createChildren(), width: 3000 }); const controller = createController(parent); controller.setDisplayConfig({ mode: 'grid', grid_columns: 2 }); // The cells are unchanged, so the new column width is applied to the // existing Masonry instance rather than by constructing a new one. expect(Masonry).toHaveBeenCalledTimes(1); expect(masonry.option).toHaveBeenCalledWith( expect.objectContaining({ columnWidth: 1499, }), ); expect( parent.style.getPropertyValue('--advanced-camera-card-grid-column-size'), ).toBe('1499px'); }); it('should not rebuild the grid when the cells are unchanged', () => { const slot = createSlot(); createSlotHost({ slot: slot, children: createChildren() }); createController(slot); expect(Masonry).toHaveBeenCalledTimes(1); expect(masonry.destroy).not.toHaveBeenCalled(); slot.dispatchEvent(new Event('slotchange')); expect(Masonry).toHaveBeenCalledTimes(1); expect(masonry.destroy).not.toHaveBeenCalled(); }); it('should rebuild the grid and lay it out when the cells change', () => { const slot = createSlot(); const host = createSlotHost({ slot: slot, children: createChildren() }); createController(slot); expect(Masonry).toHaveBeenCalledTimes(1); host.replaceChildren(...createChildren()); slot.dispatchEvent(new Event('slotchange')); expect(Masonry).toHaveBeenCalledTimes(2); expect(masonry.destroy).toHaveBeenCalledTimes(1); // A rebuild leaves the cells unpositioned, so the layout must not be left // to the throttle. expect(masonry.layout).toHaveBeenCalled(); }); it('should rebuild the grid when the number of cells changes', () => { const slot = createSlot(); const host = createSlotHost({ slot: slot, children: createChildren() }); createController(slot); expect(Masonry).toHaveBeenCalledTimes(1); host.append(...createChildren(['new-cell'])); slot.dispatchEvent(new Event('slotchange')); expect(Masonry).toHaveBeenCalledTimes(2); expect(masonry.destroy).toHaveBeenCalledTimes(1); }); it('should not use more columns than the items ask for', () => { const parent = createParent({ children: createChildren(['0']), width: 3000 }); createController(parent); // The lone item takes the whole grid. Sizing from the width alone would // give it 1 of 5 columns, with the other 4 left empty. expect(Masonry).toHaveBeenCalledWith( parent, expect.objectContaining({ columnWidth: 3000, }), ); expect( parent.style.getPropertyValue('--advanced-camera-card-grid-column-size'), ).toBe('3000px'); }); it('should not use more columns than the items ask for on a narrow host', () => { const parent = createParent({ children: createChildren(['0']) }); createController(parent); // Sizing from the width alone would give the lone item half of a default // width card. expect(Masonry).toHaveBeenCalledWith( parent, expect.objectContaining({ columnWidth: 492, }), ); expect( parent.style.getPropertyValue('--advanced-camera-card-grid-column-size'), ).toBe('492px'); }); it('should use a single column when there are no items', () => { const parent = createParent({ width: 3000 }); createController(parent); expect(Masonry).toHaveBeenCalledWith( parent, expect.objectContaining({ columnWidth: 3000, }), ); expect( parent.style.getPropertyValue('--advanced-camera-card-grid-column-size'), ).toBe('3000px'); }); it('should respect exact columns even with fewer items', () => { const parent = createParent({ children: createChildren(['0']), width: 3000 }); const controller = createController(parent); controller.setDisplayConfig({ mode: 'grid', grid_columns: 4 }); expect(masonry.option).toHaveBeenCalledWith( expect.objectContaining({ columnWidth: 749, }), ); expect( parent.style.getPropertyValue('--advanced-camera-card-grid-column-size'), ).toBe('749px'); }); it('should size columns so a selected item and its siblings fit a single row', () => { const parent = createParent({ children: createChildren(['0', '1', '2']), width: 3000, }); createController(parent, { selected: '1' }); // The items ask for 4 columns: 2 for the selection (the default // `grid_selected_width_factor`) and 1 for each of its siblings. expect(Masonry).toHaveBeenCalledWith( parent, expect.objectContaining({ columnWidth: 749, }), ); const selectedWidth = 2 * 749; expect(selectedWidth + 749 + 749).toBeLessThanOrEqual(3000); }); it('should give a lone selected item the whole grid', () => { const parent = createParent({ children: createChildren(['0']), width: 3000 }); createController(parent, { selected: '0' }); // A selection is normally reserved extra columns, but a lone item cannot be // wider than the grid and so cannot use them. expect(Masonry).toHaveBeenCalledWith( parent, expect.objectContaining({ columnWidth: 3000, }), ); }); it('should count a custom selected width factor towards the columns asked for', () => { const parent = createParent({ children: createChildren(['0', '1', '2']), width: 3000, }); const controller = createController(parent, { selected: '1' }); controller.setDisplayConfig({ mode: 'grid', grid_selected_width_factor: 3 }); // 3 columns for the selection and 1 for each sibling exhausts the 5 // columns the width allows. expect(masonry.option).toHaveBeenCalledWith( expect.objectContaining({ columnWidth: 599, }), ); expect( parent.style.getPropertyValue('--advanced-camera-card-grid-column-size'), ).toBe('599px'); }); it('should count item width factors towards the columns asked for', () => { const children = createChildren(['0', '1', '2']); children[0].setAttribute('grid-width-factor', '2'); const parent = createParent({ children: children, width: 4200 }); createController(parent); // The items span 4 columns, and the widest needs 2 more when selected. // Ignoring the width factor would give 4 columns of 1049px. expect(Masonry).toHaveBeenCalledWith( parent, expect.objectContaining({ columnWidth: 699, }), ); }); it('should give an item that is narrower than a column a column of its own', () => { const children = createChildren(['0', '1', '2']); for (const child of children) { child.setAttribute('grid-width-factor', '0.5'); } const parent = createParent({ children: children, width: 1800 }); createController(parent, { selected: '0' }); // Each item asks for one column: the selection fills exactly one at 0.5 x // 2, and a half-width sibling still occupies a whole one. expect(Masonry).toHaveBeenCalledWith( parent, expect.objectContaining({ columnWidth: 599, }), ); }); it('should not resize columns when a selection is made or removed', () => { const children = createChildren(['0', '1', '2']); const parent = createParent({ children: children, width: 3000 }); const controller = createController(parent); // Room for a selection is reserved whether or not there is one, so the // three items ask for 4 columns either way. expect(Masonry).toHaveBeenCalledWith( parent, expect.objectContaining({ columnWidth: 749, }), ); vi.mocked(masonry.option)?.mockClear(); controller.selectCell('1'); // Selecting an item would otherwise resize the items the user did not // interact with. expect(masonry.option).not.toHaveBeenCalled(); expect( parent.style.getPropertyValue('--advanced-camera-card-grid-column-size'), ).toBe('749px'); controller.unselectAll(); expect(masonry.option).not.toHaveBeenCalled(); expect( parent.style.getPropertyValue('--advanced-camera-card-grid-column-size'), ).toBe('749px'); }); it('should respect selected width factor', () => { const parent = createParent({ children: createChildren(), width: 2000 }); const controller = createController(parent); controller.setDisplayConfig({ mode: 'grid', grid_selected_width_factor: 3 }); expect( parent.style.getPropertyValue('--advanced-camera-card-grid-selected-width-factor'), ).toBe('3'); // Setting the same config again should do nothing. controller.setDisplayConfig({ mode: 'grid', grid_selected_width_factor: 3 }); expect( parent.style.getPropertyValue('--advanced-camera-card-grid-selected-width-factor'), ).toBe('3'); }); it('should dispatch a selection request when interacted with', () => { const children = createChildren(); const parent = createParent({ children: children, width: 2000 }); const controller = createController(parent); expect(controller.getSelected()).toBeNull(); const clickHandler = vi.fn(); parent.addEventListener('click', clickHandler); const selectedHandler = vi.fn(); parent.addEventListener('advanced-camera-card:media-grid:selected', selectedHandler); children[1].click(); // Click is consumed; the controller dispatches the selection request but // does NOT mutate local state. The authoritative selection is applied by // the parent via `selectCell` once it propagates back. expect(clickHandler).not.toHaveBeenCalled(); expect(selectedHandler).toHaveBeenCalledTimes(1); expect(selectedHandler.mock.calls[0][0].detail).toEqual({ selected: '1' }); expect(controller.getSelected()).toBeNull(); }); it('should ignore interaction events on already selected cell', () => { const children = createChildren(); const parent = createParent({ children: children, width: 2000 }); const controller = createController(parent); controller.selectCell('1'); const clickHandler = vi.fn(); parent.addEventListener('click', clickHandler); children[1].click(); // Click will be allowed through. expect(clickHandler).toHaveBeenCalled(); expect(controller.getSelected()).toBe('1'); }); it('should re-layout when child size changes', () => { createController(createParent({ children: createChildren() })); vi.mocked(masonry.layout)?.mockClear(); triggerResizeObserver('cell'); expect(masonry.layout).toHaveBeenCalled(); }); it('should update masonry column width when host size changes', () => { const children = createChildren(); const parent = createParent({ children: children }); createController(parent); expect(Masonry).toHaveBeenCalledWith( parent, expect.objectContaining({ columnWidth: 245, }), ); expect( parent.style.getPropertyValue('--advanced-camera-card-grid-column-size'), ).toBe('245px'); // Clear mock state. vi.mocked(Masonry).mockClear(); vi.mocked(masonry.layout)?.mockClear(); vi.mocked(masonry.option)?.mockClear(); // Resize the host. setElementWidth(parent, 3000); triggerResizeObserver('host'); // Masonry should not be recreated, but column width should be updated // via option() and layout should be called. expect(Masonry).not.toHaveBeenCalled(); expect(masonry.option).toHaveBeenCalledWith({ columnWidth: 749 }); expect( parent.style.getPropertyValue('--advanced-camera-card-grid-column-size'), ).toBe('749px'); expect(masonry.layout).toHaveBeenCalled(); // Clear mock state. vi.mocked(Masonry).mockClear(); vi.mocked(masonry.layout)?.mockClear(); vi.mocked(masonry.option)?.mockClear(); // Trigger with the same sizes. triggerResizeObserver('host'); expect(Masonry).not.toHaveBeenCalled(); expect(masonry.option).not.toHaveBeenCalled(); expect(masonry.layout).not.toHaveBeenCalled(); }); describe('describe should sort grid elements correctly', () => { it('should respect placement when grid_selected_position is default', () => { const children = createChildren(); const parent = createParent({ children: children }); // Simulate wrapped children in masonry object. masonry.items = children.map((child) => ({ element: child })); const controller = createController(parent); controller.setDisplayConfig({ mode: 'grid', grid_selected_position: 'default' }); controller.selectCell('1'); expect(masonry.items).toEqual([ { element: children[0] }, { element: children[1] }, { element: children[2] }, ]); }); it('should respect placement when grid_selected_position is first', () => { const children = createChildren(['0', '1', '2']); const parent = createParent({ children: children }); // Simulate wrapped children in masonry object. masonry.items = children.map((child) => ({ element: child })); const controller = createController(parent); controller.setDisplayConfig({ mode: 'grid', grid_selected_position: 'first' }); controller.selectCell('1'); expect(masonry.items).toEqual([ { element: children[1] }, { element: children[0] }, { element: children[2] }, ]); }); it('should respect placement when grid_selected_position is last', () => { const children = createChildren(['0', '1', '2']); const parent = createParent({ children: children }); // Simulate wrapped children in masonry object. masonry.items = children.map((child) => ({ element: child })); const controller = createController(parent); controller.setDisplayConfig({ mode: 'grid', grid_selected_position: 'last' }); controller.selectCell('1'); expect(masonry.items).toEqual([ { element: children[0] }, { element: children[2] }, { element: children[1] }, ]); }); it('should order from the grid contents rather than the previous sort', () => { const children = createChildren(['0', '1', '2']); const parent = createParent({ children: children }); // Simulate wrapped children in masonry object. masonry.items = children.map((child) => ({ element: child })); const controller = createController(parent); controller.setDisplayConfig({ mode: 'grid', grid_selected_position: 'last' }); controller.selectCell('0'); expect(masonry.items).toEqual([ { element: children[1] }, { element: children[2] }, { element: children[0] }, ]); // The unselected cells return to their configured order rather than // keeping the order the previous sort left them in. controller.selectCell('1'); expect(masonry.items).toEqual([ { element: children[0] }, { element: children[2] }, { element: children[1] }, ]); // Returning to 'default' restores the configured order entirely. controller.setDisplayConfig({ mode: 'grid', grid_selected_position: 'default' }); expect(masonry.items).toEqual([ { element: children[0] }, { element: children[1] }, { element: children[2] }, ]); }); it('should not sort a destroyed grid', () => { const children = createChildren(['0', '1', '2']); const parent = createParent({ children: children }); const controller = createController(parent); controller.destroy(); expect(() => controller.setDisplayConfig({ mode: 'grid', grid_selected_position: 'first' }), ).not.toThrow(); }); }); describe('should set width factor styles correctly', () => { it('should apply width factor CSS variable when attribute is present', () => { const children = createChildren(['0', '1', '2']); children[0].setAttribute('grid-width-factor', '2'); children[1].setAttribute('grid-width-factor', '3'); const parent = createParent({ children: children }); createController(parent); expect( children[0].style.getPropertyValue('--advanced-camera-card-grid-width-factor'), ).toBe('2'); expect( children[1].style.getPropertyValue('--advanced-camera-card-grid-width-factor'), ).toBe('3'); expect( children[2].style.getPropertyValue('--advanced-camera-card-grid-width-factor'), ).toBe(''); }); it('should update width factor styles when attribute changes', () => { const children = createChildren(['0', '1', '2']); const parent = createParent({ children: children }); createController(parent); // Initially no width factor. expect( children[0].style.getPropertyValue('--advanced-camera-card-grid-width-factor'), ).toBe(''); // Set the attribute. children[0].setAttribute('grid-width-factor', '4'); triggerMutationObserver('cell', 'grid-width-factor'); expect( children[0].style.getPropertyValue('--advanced-camera-card-grid-width-factor'), ).toBe('4'); }); it('should remove width factor style when attribute is removed', () => { const children = createChildren(['0', '1', '2']); children[0].setAttribute('grid-width-factor', '2'); const parent = createParent({ children: children }); createController(parent); expect( children[0].style.getPropertyValue('--advanced-camera-card-grid-width-factor'), ).toBe('2'); // Remove the attribute. children[0].removeAttribute('grid-width-factor'); triggerMutationObserver('cell', 'grid-width-factor'); expect( children[0].style.getPropertyValue('--advanced-camera-card-grid-width-factor'), ).toBe(''); }); }); });