Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(<Legends legends={legends} {...overflowProps} overflowText={'OverFlow Items'} />);
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(<Legends legends={legends} />);
expect(document.querySelectorAll('[class^="ms-OverflowSet-overflowButton"]').length).toBe(0);
// The overflow indicator would render as '<count> 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', () => {
Expand Down Expand Up @@ -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<void> {
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(<Legends legends={legends} overflowText="Overflow Items" />);

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(<Legends legends={legends} overflowText="Overflow Items" />);

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(<Legends legends={legends.slice(0, 3)} overflowText="Overflow Items" />);

// 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();
});
});
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -130,16 +130,20 @@ describe('Legends - basic props', () => {
expect(legend).toBeDefined();
});

it('Should mount Overflow Button when not empty', () => {
const wrapper = render(<Legends legends={legends} /* {...overflowProps} */ overflowText={'OverFlow Items'} />);
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(<Legends legends={legends} overflowText={'OverFlow Items'} />);
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(<Legends legends={legends} />);
const overflowBtn = wrapper.container.querySelectorAll('[class^="ms-OverflowSet-overflowButton"]');
expect(overflowBtn!.length).toBe(0);
// The overflow menu button would render as '+<count> 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', () => {
Expand Down Expand Up @@ -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(<Legends legends={legends} overflowText="Overflow Items" />);

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(<Legends legends={legends} overflowText="Overflow Items" />);

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(<Legends legends={legends} overflowText="Overflow Items" />);

expect(screen.queryByText(/Overflow Items/)).toBeNull();
expect(container.querySelectorAll('[data-overflowing]').length).toBe(0);
expect(container.querySelectorAll('button[role="option"]').length).toBe(legends.length);
});
});