diff --git a/change/@fluentui-react-charting-cf0ad301-c5b0-4363-b71a-ed4c74ede886.json b/change/@fluentui-react-charting-cf0ad301-c5b0-4363-b71a-ed4c74ede886.json new file mode 100644 index 0000000000000..bde42f532908b --- /dev/null +++ b/change/@fluentui-react-charting-cf0ad301-c5b0-4363-b71a-ed4c74ede886.json @@ -0,0 +1,7 @@ +{ + "type": "none", + "comment": "test: add real coverage for the Legends overflow scenario (reducers, overflow button count, hover card contents)", + "packageName": "@fluentui/react-charting", + "email": "144495202+AKnassa@users.noreply.github.com", + "dependentChangeType": "none" +} diff --git a/change/@fluentui-react-charts-b589e49b-ecd8-40df-9102-e80f8af0ff6a.json b/change/@fluentui-react-charts-b589e49b-ecd8-40df-9102-e80f8af0ff6a.json new file mode 100644 index 0000000000000..253aba89b5034 --- /dev/null +++ b/change/@fluentui-react-charts-b589e49b-ecd8-40df-9102-e80f8af0ff6a.json @@ -0,0 +1,7 @@ +{ + "type": "none", + "comment": "test: add real coverage for the Legends overflow scenario (overflow menu button count, hidden legends, menu contents)", + "packageName": "@fluentui/react-charts", + "email": "144495202+AKnassa@users.noreply.github.com", + "dependentChangeType": "none" +} diff --git a/packages/charts/react-charting/src/components/Legends/Legends.test.tsx b/packages/charts/react-charting/src/components/Legends/Legends.test.tsx index 74ad9280c8e15..f66be5f3ae5da 100644 --- a/packages/charts/react-charting/src/components/Legends/Legends.test.tsx +++ b/packages/charts/react-charting/src/components/Legends/Legends.test.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { resetIds } from '../../Utilities'; import { Legends } from './index'; import { LegendsBase } from './Legends.base'; -import { render, cleanup } from '@testing-library/react'; +import { render, cleanup, screen, fireEvent, waitFor, within } from '@testing-library/react'; import { DefaultPalette } from '@fluentui/react/lib/Styling'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -99,14 +99,19 @@ describe('Legends - basic props', () => { expect(document.querySelectorAll('[class^="legendContainer"]')).toBeDefined(); }); - it('Should mount Overflow Button when not empty', () => { + it('Should render every legend inline and no overflow button when all legends fit', () => { + // jsdom reports zero widths, so ResizeGroup never overflows here: all legends stay inline. + // (The overflow scenario itself is covered in the 'Legends - overflow rendering' suite below.) render(); - expect(document.querySelectorAll('[class^="ms-OverflowSet-overflowButton"]')).toBeDefined(); + expect(document.querySelectorAll('button[role="option"]').length).toBe(legends.length); + expect(screen.queryByText(/OverFlow Items/)).toBeNull(); }); - it('Should not mount Overflow when empty', () => { + it('Should not mount an overflow button when nothing overflows', () => { render(); - expect(document.querySelectorAll('[class^="ms-OverflowSet-overflowButton"]').length).toBe(0); + // The overflow indicator would render as ' more' ('more' is the default overflow text). + expect(screen.queryByText(/^\d+ more$/)).toBeNull(); + expect(document.querySelectorAll('button[role="option"]').length).toBe(legends.length); }); it('Should be not able to select multiple Legends', () => { @@ -199,3 +204,156 @@ describe('Legends - controlled legend selection', () => { expect(selectedLegends.length).toBe(1); }); }); + +describe('Legends - overflow reducers', () => { + beforeEach(sharedBeforeEach); + afterEach(sharedAfterEach); + + // The reducers are private, pure functions driven by ResizeGroup; they are what decides which + // legends move in and out of the overflow, so they are tested directly on an instance. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const instance: any = new LegendsBase({ legends: [] }); + const makeItems = (count: number) => Array.from({ length: count }, (_, i) => ({ key: i, title: `L${i + 1}` })); + + it('_onReduceData moves the last primary item to the front of the overflow', () => { + const [first, second, third] = makeItems(3); + expect(instance._onReduceData({ primary: [first, second, third], overflow: [] })).toEqual({ + primary: [first, second], + overflow: [third], + }); + expect(instance._onReduceData({ primary: [first, second], overflow: [third] })).toEqual({ + primary: [first], + overflow: [second, third], + }); + }); + + it('_onReduceData returns undefined when there is no primary item left to move', () => { + expect(instance._onReduceData({ primary: [], overflow: makeItems(2) })).toBeUndefined(); + }); + + it('_onGrowData moves the first overflow item back to the end of primary', () => { + const [first, second, third] = makeItems(3); + expect(instance._onGrowData({ primary: [first], overflow: [second, third] })).toEqual({ + primary: [first, second], + overflow: [third], + }); + }); + + it('_onGrowData returns undefined when there is no overflow item to restore', () => { + expect(instance._onGrowData({ primary: makeItems(2), overflow: [] })).toBeUndefined(); + }); + + it('_onGrowData is the inverse of _onReduceData', () => { + const data = { primary: makeItems(3), overflow: [] }; + expect(instance._onGrowData(instance._onReduceData(data))).toEqual(data); + }); +}); + +describe('Legends - overflow rendering', () => { + beforeEach(sharedBeforeEach); + afterEach(sharedAfterEach); + + const originalGetBoundingClientRect = window.HTMLElement.prototype.getBoundingClientRect; + const CONTAINER_WIDTH = 300; + const LEGEND_BUTTON_WIDTH = 60; + const OVERFLOW_INDICATOR_WIDTH = 60; + + beforeEach(() => { + // ResizeGroup decides how many legends fit by measuring a hidden copy of the content + // (initial pass: the div with data-automation-id="visibleContent" while it is hidden; + // update passes: an anonymous div with visibility: hidden) against its own root container. + // jsdom reports 0 for every width, which is exactly why overflow never happened in these + // tests before. Report a fixed container width and a content width proportional to the + // number of legend buttons currently rendered, so the real + // ResizeGroup -> _onReduceData -> OverflowSet pipeline runs and converges: 17 legends at + // 60px each never fit into 300px, and the loop settles at 4 inline legends + the overflow + // indicator (4 * 60 + 60 = 300 <= 300). + window.HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement): DOMRect { + let width = CONTAINER_WIDTH; + const isMeasuredContent = + this.getAttribute('data-automation-id') === 'visibleContent' || this.style.visibility === 'hidden'; + if (isMeasuredContent) { + const legendButtonCount = this.querySelectorAll('button').length; + const hasOverflowIndicator = /\d+ Overflow Items/.test(this.textContent || ''); + width = legendButtonCount * LEGEND_BUTTON_WIDTH + (hasOverflowIndicator ? OVERFLOW_INDICATOR_WIDTH : 0); + } + return { + width, + height: 32, + top: 0, + left: 0, + right: width, + bottom: 32, + x: 0, + y: 0, + toJSON: () => '', + } as DOMRect; + }; + }); + + afterEach(() => { + window.HTMLElement.prototype.getBoundingClientRect = originalGetBoundingClientRect; + }); + + /** Resolves once ResizeGroup has committed its final, visible render. */ + async function waitForResizeGroupToSettle(): Promise { + await waitFor(() => { + const visibleContent = document.querySelector('[data-automation-id="visibleContent"]') as HTMLElement; + expect(visibleContent.style.visibility).not.toBe('hidden'); + }); + } + + it('moves the legends that do not fit into an overflow button with the correct count', async () => { + render(); + + const overflowButton = await screen.findByText('13 Overflow Items'); + await waitForResizeGroupToSettle(); + + const inlineLegends = document.querySelectorAll('button[role="option"]'); + expect(inlineLegends.length).toBe(4); + // Every legend is either inline or accounted for by the overflow button count. + expect(inlineLegends.length + 13).toBe(legends.length); + // The first legends stay inline; overflowed legends leave the DOM entirely + // (OverflowSet only renders them inside the hover card once it is opened). + expect(screen.getByText('Legend 1')).toBeTruthy(); + expect(screen.queryByText('Legend 17')).toBeNull(); + expect(overflowButton.getAttribute('role')).toBe('button'); + expect(overflowButton.getAttribute('aria-expanded')).toBe('false'); + expect(overflowButton.getAttribute('aria-label')).toBe('13 Overflow Items'); + }); + + it('opens a hover card listing the overflowed legends when the overflow button is clicked', async () => { + render(); + + const overflowButton = await screen.findByText('13 Overflow Items'); + await waitForResizeGroupToSettle(); + // Overflowed legends are not in the DOM before the hover card opens. + expect(screen.queryByText('Legend 5')).toBeNull(); + expect(screen.queryByText('Legend 17')).toBeNull(); + + fireEvent.click(overflowButton); + + await waitFor(() => expect(overflowButton.getAttribute('aria-expanded')).toBe('true')); + // The hover card renders every overflowed legend (Legend 5 through Legend 17). The queries are + // scoped to the hover card because opening it re-renders Legends, which makes ResizeGroup + // re-measure all 17 legends in a temporary hidden div. + const hoverCard = await waitFor(() => { + const card = document.querySelector('[class*="hoverCardRoot"]') as HTMLElement; + expect(card).not.toBeNull(); + return card; + }); + expect(hoverCard.querySelectorAll('button').length).toBe(13); + expect(within(hoverCard).getByText('Legend 5')).toBeTruthy(); + expect(within(hoverCard).getByText('Legend 17')).toBeTruthy(); + }); + + it('renders no overflow button when all legends fit', async () => { + render(); + + // 3 legends * 60px = 180px fits into the 300px container without any reduction. + await waitForResizeGroupToSettle(); + + expect(document.querySelectorAll('button[role="option"]').length).toBe(3); + expect(screen.queryByText(/Overflow Items/)).toBeNull(); + }); +}); diff --git a/packages/charts/react-charts/library/src/components/Legends/Legends.test.tsx b/packages/charts/react-charts/library/src/components/Legends/Legends.test.tsx index 0d621eb8966dd..dedc3dd2fb198 100644 --- a/packages/charts/react-charts/library/src/components/Legends/Legends.test.tsx +++ b/packages/charts/react-charts/library/src/components/Legends/Legends.test.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { Legends } from './index'; -import { render, act } from '@testing-library/react'; +import { render, act, fireEvent, screen } from '@testing-library/react'; import { axe, toHaveNoViolations } from 'jest-axe'; expect.extend(toHaveNoViolations); @@ -130,16 +130,20 @@ describe('Legends - basic props', () => { expect(legend).toBeDefined(); }); - it('Should mount Overflow Button when not empty', () => { - const wrapper = render(); - const overflowBtnText = wrapper.container.querySelectorAll('[class^="ms-OverflowSet-overflowButton"]'); - expect(overflowBtnText).toBeDefined(); + it('Should render every legend inline and no overflow menu button when nothing overflows', () => { + // jsdom reports zero widths, so the overflow manager never hides anything here: all legends + // stay inline. (The overflow scenario itself is covered in the 'Legends - overflow' suite below.) + const wrapper = render(); + expect(wrapper.container.querySelectorAll('button[role="option"]').length).toBe(legends.length); + expect(wrapper.container.querySelectorAll('[data-overflowing]').length).toBe(0); + expect(wrapper.queryByText(/OverFlow Items/)).toBeNull(); }); - it('Should not mount Overflow when empty', () => { + it('Should not mount an overflow menu button when nothing overflows', () => { const wrapper = render(); - const overflowBtn = wrapper.container.querySelectorAll('[class^="ms-OverflowSet-overflowButton"]'); - expect(overflowBtn!.length).toBe(0); + // The overflow menu button would render as '+ more' ('more' is the default overflow text). + expect(wrapper.queryByText(/^\+\d+/)).toBeNull(); + expect(wrapper.container.querySelectorAll('[data-overflowing]').length).toBe(0); }); it('Should be not able to select multiple Legends', () => { @@ -239,3 +243,77 @@ describe('Legends - axe-core', () => { expect(axeResults).toHaveNoViolations(); }); }); + +describe('Legends - overflow', () => { + const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth'); + const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth'); + const CONTAINER_WIDTH = 250; + const ITEM_WIDTH = 50; + + beforeEach(() => { + // @fluentui/react-overflow measures the container via clientWidth and each legend button (and + // the overflow menu button) via offsetWidth, then resolves overflow synchronously at mount in + // tests (observe() force-updates when clientWidth > 0 and its debounce is synchronous when + // NODE_ENV === 'test'). jsdom reports 0 for both, which is exactly why overflow never happened + // in these tests before. Mock the widths so the real overflow pipeline runs: 250px container + // - 10px default padding = 240px available; 17 legends at 50px each never fit, and with the + // 50px menu button the manager settles at 3 visible legends (3 * 50 + 50 = 200 <= 240) and + // 14 overflowed ones. + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => CONTAINER_WIDTH }); + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => ITEM_WIDTH }); + }); + + afterEach(() => { + if (originalClientWidth) { + Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth); + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (HTMLElement.prototype as any).clientWidth; + } + if (originalOffsetWidth) { + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', originalOffsetWidth); + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (HTMLElement.prototype as any).offsetWidth; + } + }); + + it('hides the legends that do not fit and renders an overflow menu button with the count', () => { + const { container } = render(); + + expect(screen.getByText('+14 Overflow Items')).toBeTruthy(); + // Overflowed legends stay in the DOM (hidden via CSS) and are marked with data-overflowing. + const hiddenLegends = container.querySelectorAll('[data-overflowing]'); + expect(hiddenLegends.length).toBe(14); + const visibleLegends = container.querySelectorAll('button[role="option"]:not([data-overflowing])'); + expect(visibleLegends.length).toBe(3); + // Every legend is either visible or overflowed. + expect(visibleLegends.length + hiddenLegends.length).toBe(legends.length); + // The first legends remain visible; the trailing ones overflow. + expect(visibleLegends[0].textContent).toBe('Legend 1'); + expect(hiddenLegends[hiddenLegends.length - 1].textContent).toBe('Legend 17'); + }); + + it('opens a menu listing exactly the overflowed legends when the overflow menu button is clicked', () => { + render(); + + fireEvent.click(screen.getByText('+14 Overflow Items')); + + const menuItems = screen.getAllByRole('menuitemcheckbox'); + expect(menuItems.length).toBe(14); + // The menu lists the overflowed legends (Legend 4 through Legend 17) in order. + expect(menuItems[0].textContent).toContain('Legend 4'); + expect(menuItems[menuItems.length - 1].textContent).toContain('Legend 17'); + }); + + it('renders no overflow menu button when all legends fit', () => { + // A container wide enough for all 17 legends (17 * 50 = 850 < 2000 - 10). + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => 2000 }); + + const { container } = render(); + + expect(screen.queryByText(/Overflow Items/)).toBeNull(); + expect(container.querySelectorAll('[data-overflowing]').length).toBe(0); + expect(container.querySelectorAll('button[role="option"]').length).toBe(legends.length); + }); +});