diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a4bf4e3676c..96b12d75fe0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -56,6 +56,10 @@ jobs: - name: Install run: yarn install --immutable + - name: Override react-is for charts + if: ${{ matrix.react == '18' }} + run: jq '.resolutions["react-is"] = "18"' package.json > tmp.json && mv tmp.json package.json + - name: Install React 18 if: ${{ matrix.react == '18' }} run: | @@ -88,7 +92,6 @@ jobs: matrix: spec: - base - - charts - cypress-commands - main/src/components - main/src/webComponents @@ -108,10 +111,6 @@ jobs: - name: Install run: yarn install --immutable - - name: Override react-is for charts - if: ${{ matrix.react == '18' && matrix.spec == 'charts' }} - run: jq '.resolutions["react-is"] = "18"' package.json > tmp.json && mv tmp.json package.json - - name: Install 18 if: ${{ matrix.react == '18' }} run: | @@ -154,4 +153,4 @@ jobs: with: github-token: ${{ secrets.GITHUB_TOKEN }} parallel-finished: true - carryforward: 'base,charts,cypress-commands,main/src/components,main/src/internal,playwright' + carryforward: 'base,cypress-commands,main/src/components,main/src/internal,playwright' diff --git a/cypress.config.ts b/cypress.config.ts index 96a007c57b5..f1504cf9469 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ '**/src/enums/*', '**/*.stories.tsx', '**/*.test.{ts,tsx}', + '**/*.module.css.ts', '**/node_modules/**', '**/dist/**', 'packages/*/src/index.ts', diff --git a/packages/charts/src/components/BarChart/BarChart.cy.tsx b/packages/charts/src/components/BarChart/BarChart.cy.tsx deleted file mode 100644 index fb90d53974f..00000000000 --- a/packages/charts/src/components/BarChart/BarChart.cy.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { complexDataSet } from '../../resources/DemoProps.js'; -import { BarChart } from './index.js'; -import { - cypressPassThroughTestsFactory, - testChartLegendConfig, - testChartZoomingTool, - testStackAggregateTotals, -} from '@/cypress/support/utils'; - -const dimensions = [ - { - accessor: 'name', - interval: 0, - }, -]; - -const measures = [ - { - accessor: (data) => data.users, - label: 'Users', - formatter: (val: number) => val.toLocaleString('en'), - }, - { - accessor: (data) => data.sessions, - label: 'Active Sessions', - formatter: (val) => `${val} sessions`, - hideDataLabel: true, - }, - { - accessor: 'volume', - label: 'Vol.', - }, -]; - -describe('BarChart', () => { - it('Basic', () => { - cy.mount(); - cy.get('.recharts-responsive-container').should('be.visible'); - cy.get('.recharts-bar').should('have.length', 3); - cy.get('.recharts-bar-rectangles').should('have.length', 3); - }); - - it('click handlers', () => { - const onClick = cy.spy().as('onClick'); - const onLegendClick = cy.spy().as('onLegendClick'); - cy.mount( - , - ); - - cy.findByText('January').click(); - cy.get('@onClick').should('have.been.called'); - cy.get('[name="January"]').eq(0).click(); - cy.get('@onClick') - .should('have.been.calledTwice') - .and( - 'have.been.calledWith', - Cypress.sinon.match({ - detail: Cypress.sinon.match({ - payload: complexDataSet[0], - }), - }), - ); - - cy.contains('Users').click(); - cy.get('@onLegendClick').should( - 'have.been.calledWith', - Cypress.sinon.match({ - detail: Cypress.sinon.match({ - value: 'Users', - }), - }), - ); - cy.contains('Vol.').click(); - cy.get('@onLegendClick').should( - 'have.been.calledWith', - Cypress.sinon.match({ - detail: Cypress.sinon.match({ - dataKey: 'volume', - }), - }), - ); - }); - - it('Loading Placeholder', () => { - cy.mount(); - cy.get('.recharts-bar').should('not.exist'); - cy.contains('Loading...').should('exist'); - }); - - testChartLegendConfig(BarChart, { dataset: complexDataSet, dimensions, measures }); - - testChartZoomingTool(BarChart, { dataset: complexDataSet, dimensions, measures }); - - cypressPassThroughTestsFactory(BarChart, { dimensions: [], measures: [] }); - - testStackAggregateTotals(BarChart, { - dataset: complexDataSet.slice(0, 3), - dimensions, - measures: [ - { accessor: 'users', stackId: 'A', label: 'Users' }, - { accessor: 'sessions', stackId: 'A', label: 'Active Sessions' }, - ], - }); -}); diff --git a/packages/charts/src/components/BarChart/test/BarChart.spec.tsx b/packages/charts/src/components/BarChart/test/BarChart.spec.tsx new file mode 100644 index 00000000000..f93932c16ea --- /dev/null +++ b/packages/charts/src/components/BarChart/test/BarChart.spec.tsx @@ -0,0 +1,92 @@ +import { expect, test } from '../../../../../../playwright/fixtures/main-fixtures.js'; +import { complexDataSet } from '../../../resources/DemoProps.js'; +import { + testLoadingStates, + testPassThroughProps, + testStackAggregateTotals, + testZoomingTool, +} from '../../../test-utils/sharedTests.js'; +import { BarChart } from '../index.js'; +import { + BarChartClickTest, + BarChartDataPointClickTest, + BarChartHighlightColorTest, + BarChartLegendConfigTest, + BarChartSecondYAxisTest, +} from './BarChartTestComponents.js'; + +const dimensions = [{ accessor: 'name', interval: 0 }]; +const measures = [ + { accessor: 'users', label: 'Users' }, + { accessor: 'sessions', label: 'Active Sessions' }, + { accessor: 'volume', label: 'Vol.' }, +]; +const baseProps = { dataset: complexDataSet, dimensions, measures }; + +test.describe('BarChart', () => { + test('Basic', async ({ mount, page }) => { + await mount(); + await expect(page.locator('.recharts-responsive-container')).toBeVisible(); + await expect(page.locator('.recharts-bar')).toHaveCount(3); + await expect(page.locator('.recharts-bar-rectangles')).toHaveCount(3); + }); + + test('click handlers', async ({ mount, page }) => { + await mount(); + + await page.getByText('January').click(); + await expect(page.getByTestId('click-count')).toHaveText('1'); + + await page.locator('[name="January"]').first().click(); + await expect(page.getByTestId('click-count')).toHaveText('2'); + await expect(page.getByTestId('last-payload')).toHaveText(JSON.stringify(complexDataSet[0])); + + await page.locator('.recharts-legend-item-text').filter({ hasText: 'Users' }).click(); + await expect(page.getByTestId('legend-click-count')).toHaveText('1'); + await expect(page.getByTestId('last-legend-value')).toHaveText('Users'); + + await page.locator('.recharts-legend-item-text').filter({ hasText: 'Vol.' }).click(); + await expect(page.getByTestId('last-legend-datakey')).toHaveText('volume'); + }); + + testLoadingStates(BarChart, baseProps, { dimensions: [], measures: [] }, '.recharts-bar'); + + test('legendConfig', async ({ mount, page }) => { + await mount(); + await expect(page.getByTestId('catval').first()).toBeVisible(); + }); + + testZoomingTool(BarChart, baseProps); + + testPassThroughProps(BarChart, { dimensions: [], measures: [] }); + + testStackAggregateTotals(BarChart, { dataset: complexDataSet.slice(0, 3), dimensions }, [ + { accessor: 'users', stackId: 'A', label: 'Users' }, + { accessor: 'sessions', stackId: 'A', label: 'Active Sessions' }, + ]); + + test('onDataPointClick', async ({ mount, page }) => { + await mount(); + + await page.locator('[name="January"]').first().click(); + await expect(page.getByTestId('dp-click-count')).toHaveText('1'); + await expect(page.getByTestId('dp-last-datakey')).not.toHaveText(''); + await expect(page.getByTestId('dp-last-value')).not.toHaveText(''); + await expect(page.getByTestId('dp-last-data-index')).not.toHaveText('-1'); + await expect(page.getByTestId('dp-last-payload')).toHaveText(JSON.stringify(complexDataSet[0])); + }); + + test('highlightColor', async ({ mount, page }) => { + await mount(); + + // January has users=100 (<=200 → green), February has users=230 (>200 → red) + await expect(page.locator('.recharts-bar-rectangle [fill="green"]').first()).toBeAttached(); + await expect(page.locator('.recharts-bar-rectangle [fill="red"]').first()).toBeAttached(); + }); + + test('secondYAxis', async ({ mount, page }) => { + await mount(); + // BarChart is horizontal so the secondary "Y" axis renders as an additional XAxis + await expect(page.locator('.recharts-xAxis')).toHaveCount(2); + }); +}); diff --git a/packages/charts/src/components/BarChart/test/BarChartTestComponents.tsx b/packages/charts/src/components/BarChart/test/BarChartTestComponents.tsx new file mode 100644 index 00000000000..e164b734d57 --- /dev/null +++ b/packages/charts/src/components/BarChart/test/BarChartTestComponents.tsx @@ -0,0 +1,48 @@ +import { complexDataSet } from '../../../resources/DemoProps.js'; +import { + createClickTestComponent, + createDataPointClickTestComponent, + createHighlightColorTestComponent, + createLegendConfigTestComponent, + createSecondYAxisTestComponent, +} from '../../../test-utils/componentFactories.js'; +import { BarChart } from '../index.js'; + +const dimensions = [{ accessor: 'name', interval: 0 }]; + +const measures = [ + { + accessor: (data: Record) => data.users, + label: 'Users', + formatter: (val: number) => val.toLocaleString('en'), + }, + { + accessor: (data: Record) => data.sessions, + label: 'Active Sessions', + formatter: (val: number) => `${val} sessions`, + hideDataLabel: true, + }, + { accessor: 'volume', label: 'Vol.' }, +]; + +const baseProps = { dataset: complexDataSet, dimensions, measures }; + +export const BarChartClickTest = createClickTestComponent(BarChart, baseProps, { + trackLegendValue: true, +}); + +export const BarChartLegendConfigTest = createLegendConfigTestComponent(BarChart, baseProps); + +export const BarChartDataPointClickTest = createDataPointClickTestComponent(BarChart, baseProps); + +export const BarChartHighlightColorTest = createHighlightColorTestComponent(BarChart, baseProps, [ + { + accessor: 'users', + label: 'Users', + highlightColor: (value: number) => (value > 200 ? 'red' : 'green'), + }, + { accessor: 'sessions', label: 'Active Sessions' }, + { accessor: 'volume', label: 'Vol.' }, +]); + +export const BarChartSecondYAxisTest = createSecondYAxisTestComponent(BarChart, baseProps, 'volume'); diff --git a/packages/charts/src/components/BulletChart/BulletChart.cy.tsx b/packages/charts/src/components/BulletChart/BulletChart.cy.tsx deleted file mode 100644 index addcbd59510..00000000000 --- a/packages/charts/src/components/BulletChart/BulletChart.cy.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { complexDataSet } from '../../resources/DemoProps.js'; -import { BulletChart } from './index.js'; -import { cypressPassThroughTestsFactory, testChartLegendConfig, testChartZoomingTool } from '@/cypress/support/utils'; - -const dimensions = [ - { - accessor: 'name', - interval: 0, - }, -]; -const measures = [ - { - accessor: 'users', - label: 'Users', - formatter: (val: number) => val.toLocaleString('en'), - type: 'primary', - }, - { - accessor: 'sessions', - label: 'Active Sessions', - formatter: (val) => `${val} sessions`, - hideDataLabel: true, - type: 'comparison', - }, - { - accessor: 'volume', - label: 'Vol.', - type: 'additional', - }, -]; - -describe('BulletChart', () => { - it('Basic', () => { - cy.mount(); - cy.get('.recharts-responsive-container').should('be.visible'); - cy.get('.recharts-bar').should('have.length', 3); - cy.get('.recharts-bar-rectangles').should('have.length', 3); - }); - - it('click handlers', () => { - const onClick = cy.spy().as('onClick'); - const onLegendClick = cy.spy().as('onLegendClick'); - cy.mount( - , - ); - - cy.findByText('January').click(); - cy.get('@onClick').should('have.been.called'); - cy.get('[name="January"]').eq(0).click(); - cy.get('@onClick') - .should('have.been.calledTwice') - .and( - 'have.been.calledWith', - Cypress.sinon.match({ - detail: Cypress.sinon.match({ - payload: complexDataSet[0], - }), - }), - ); - - cy.contains('Users').click(); - cy.get('@onLegendClick').should( - 'have.been.calledWith', - Cypress.sinon.match({ - detail: Cypress.sinon.match({ - dataKey: 'users', - }), - }), - ); - }); - - it('Loading Placeholder', () => { - cy.mount(); - cy.get('.recharts-bar').should('not.exist'); - cy.contains('Loading...').should('exist'); - }); - - testChartZoomingTool(BulletChart, { dataset: complexDataSet, dimensions, measures }); - - testChartLegendConfig(BulletChart, { dataset: complexDataSet, dimensions, measures }); - - cypressPassThroughTestsFactory(BulletChart, { dimensions: [], measures: [] }); -}); diff --git a/packages/charts/src/components/BulletChart/test/BulletChart.spec.tsx b/packages/charts/src/components/BulletChart/test/BulletChart.spec.tsx new file mode 100644 index 00000000000..61bf1350da0 --- /dev/null +++ b/packages/charts/src/components/BulletChart/test/BulletChart.spec.tsx @@ -0,0 +1,96 @@ +import { expect, test } from '../../../../../../playwright/fixtures/main-fixtures.js'; +import { complexDataSet } from '../../../resources/DemoProps.js'; +import { testLoadingStates, testPassThroughProps, testZoomingTool } from '../../../test-utils/sharedTests.js'; +import { BulletChart } from '../index.js'; +import { + BulletChartClickTest, + BulletChartDataPointClickTest, + BulletChartLegendConfigTest, + BulletChartVerticalLayoutTest, +} from './BulletChartTestComponents.js'; + +const dimensions = [{ accessor: 'name', interval: 0 }]; +const measures = [ + { accessor: 'users', label: 'Users', type: 'primary' as const }, + { accessor: 'sessions', label: 'Active Sessions', type: 'comparison' as const }, + { accessor: 'volume', label: 'Vol.', type: 'additional' as const }, +]; +const baseProps = { dataset: complexDataSet, dimensions, measures }; + +test.describe('BulletChart', () => { + test('Basic', async ({ mount, page }) => { + await mount( + , + ); + await expect(page.locator('.recharts-responsive-container')).toBeVisible(); + await expect(page.locator('.recharts-bar')).toHaveCount(3); + await expect(page.locator('.recharts-bar-rectangles')).toHaveCount(3); + }); + + test('click handlers', async ({ mount, page }) => { + await mount(); + + await page.getByText('January').click(); + await expect(page.getByTestId('click-count')).toHaveText('1'); + + await page.locator('[name="January"]').first().click({ force: true }); + await expect(page.getByTestId('click-count')).toHaveText('2'); + await expect(page.getByTestId('last-payload')).toHaveText(JSON.stringify(complexDataSet[0])); + + await page.locator('.recharts-legend-item-text').filter({ hasText: 'Users' }).click(); + await expect(page.getByTestId('legend-click-count')).toHaveText('1'); + await expect(page.getByTestId('last-legend-value')).toHaveText('Users'); + + await page.locator('.recharts-legend-item-text').filter({ hasText: 'Vol.' }).click(); + await expect(page.getByTestId('last-legend-datakey')).toHaveText('volume'); + }); + + testLoadingStates( + BulletChart, + { + dataset: complexDataSet, + dimensions: [{ accessor: 'name', interval: 0 }], + measures: [{ accessor: 'users', label: 'Users', type: 'primary' as const }], + }, + { dimensions: [], measures: [] }, + '.recharts-bar', + ); + + test('legendConfig', async ({ mount, page }) => { + await mount(); + await expect(page.getByTestId('catval').first()).toBeVisible(); + }); + + testZoomingTool(BulletChart, baseProps); + + testPassThroughProps(BulletChart, { dimensions: [], measures: [] }); + + test('onDataPointClick', async ({ mount, page }) => { + await mount(); + + // make sure not to click the label, as currently the event is only fired when the actual bar is clicked. + const firstBar = page.locator('.recharts-bar-rectangle path').first(); + await firstBar.waitFor(); + const box = await firstBar.boundingBox(); + await page.mouse.click(box.x + box.width / 2, box.y + box.height - 3); + await expect(page.getByTestId('dp-click-count')).toHaveText('1'); + await expect(page.getByTestId('dp-last-datakey')).not.toHaveText(''); + await expect(page.getByTestId('dp-last-payload')).not.toHaveText(''); + }); + + test('layout="vertical"', async ({ mount, page }) => { + await mount(); + await expect(page.locator('.recharts-responsive-container')).toBeVisible(); + // Vertical layout renders bars along Y axis and uses XAxis for values + await expect(page.locator('.recharts-bar')).toHaveCount(3); + await expect(page.locator('.recharts-xAxis')).toBeAttached(); + }); +}); diff --git a/packages/charts/src/components/BulletChart/test/BulletChartTestComponents.tsx b/packages/charts/src/components/BulletChart/test/BulletChartTestComponents.tsx new file mode 100644 index 00000000000..9e4f4e56143 --- /dev/null +++ b/packages/charts/src/components/BulletChart/test/BulletChartTestComponents.tsx @@ -0,0 +1,35 @@ +import { complexDataSet } from '../../../resources/DemoProps.js'; +import { + createClickTestComponent, + createDataPointClickTestComponent, + createLegendConfigTestComponent, + createVerticalLayoutTestComponent, +} from '../../../test-utils/componentFactories.js'; +import { BulletChart } from '../index.js'; + +const dimensions = [{ accessor: 'name', interval: 0 }]; + +const measures = [ + { accessor: 'users', label: 'Users', formatter: (val: number) => val.toLocaleString('en'), type: 'primary' as const }, + { + accessor: 'sessions', + label: 'Active Sessions', + formatter: (val: number) => `${val} sessions`, + hideDataLabel: true, + type: 'comparison' as const, + }, + { accessor: 'volume', label: 'Vol.', type: 'additional' as const }, +]; + +const baseProps = { dataset: complexDataSet, dimensions, measures }; + +export const BulletChartClickTest = createClickTestComponent(BulletChart, baseProps, { + noAnimation: true, + trackLegendValue: true, +}); + +export const BulletChartLegendConfigTest = createLegendConfigTestComponent(BulletChart, baseProps); + +export const BulletChartDataPointClickTest = createDataPointClickTestComponent(BulletChart, baseProps); + +export const BulletChartVerticalLayoutTest = createVerticalLayoutTestComponent(BulletChart, baseProps); diff --git a/packages/charts/src/components/ColumnChart/ColumnChart.cy.tsx b/packages/charts/src/components/ColumnChart/ColumnChart.cy.tsx deleted file mode 100644 index 54ec1dcf6c3..00000000000 --- a/packages/charts/src/components/ColumnChart/ColumnChart.cy.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { complexDataSet } from '../../resources/DemoProps.js'; -import { ColumnChart } from './index.js'; -import { - cypressPassThroughTestsFactory, - testChartLegendConfig, - testChartZoomingTool, - testStackAggregateTotals, -} from '@/cypress/support/utils'; - -const dimensions = [ - { - accessor: 'name', - interval: 0, - }, -]; -const measures = [ - { - accessor: 'users', - label: 'Users', - formatter: (val: number) => val.toLocaleString('en'), - }, - { - accessor: 'sessions', - label: 'Active Sessions', - formatter: (val) => `${val} sessions`, - hideDataLabel: true, - }, - { - accessor: 'volume', - label: 'Vol.', - }, -]; - -describe('ColumnChart', () => { - it('Basic', () => { - cy.mount(); - cy.get('.recharts-responsive-container').should('be.visible'); - cy.get('.recharts-bar').should('have.length', 3); - cy.get('.recharts-bar-rectangles').should('have.length', 3); - }); - - it('click handlers', () => { - const onClick = cy.spy().as('onClick'); - const onLegendClick = cy.spy().as('onLegendClick'); - cy.mount( - , - ); - - cy.findByText('January').click(); - cy.get('@onClick').should('have.been.called'); - cy.get('[name="January"]').eq(0).click(); - cy.get('@onClick') - .should('have.been.calledTwice') - .and( - 'have.been.calledWith', - Cypress.sinon.match({ - detail: Cypress.sinon.match({ - payload: complexDataSet[0], - }), - }), - ); - - cy.contains('Users').click(); - cy.get('@onLegendClick').should( - 'have.been.calledWith', - Cypress.sinon.match({ - detail: Cypress.sinon.match({ - dataKey: 'users', - }), - }), - ); - }); - - it('Loading Placeholder', () => { - cy.mount(); - cy.get('.recharts-bar').should('not.exist'); - cy.contains('Loading...').should('exist'); - }); - - testChartZoomingTool(ColumnChart, { dataset: complexDataSet, dimensions, measures }); - - testChartLegendConfig(ColumnChart, { dataset: complexDataSet, dimensions, measures }); - - cypressPassThroughTestsFactory(ColumnChart, { dimensions: [], measures: [] }); - - testStackAggregateTotals(ColumnChart, { - dataset: complexDataSet.slice(0, 3), - dimensions, - measures: [ - { accessor: 'users', stackId: 'A', label: 'Users' }, - { accessor: 'sessions', stackId: 'A', label: 'Active Sessions' }, - ], - }); -}); diff --git a/packages/charts/src/components/ColumnChart/test/ColumnChart.spec.tsx b/packages/charts/src/components/ColumnChart/test/ColumnChart.spec.tsx new file mode 100644 index 00000000000..8dccf2ff1e0 --- /dev/null +++ b/packages/charts/src/components/ColumnChart/test/ColumnChart.spec.tsx @@ -0,0 +1,114 @@ +import { expect, test } from '../../../../../../playwright/fixtures/main-fixtures.js'; +import { complexDataSet } from '../../../resources/DemoProps.js'; +import { + testLoadingStates, + testPassThroughProps, + testStackAggregateTotals, + testZoomingTool, +} from '../../../test-utils/sharedTests.js'; +import { ColumnChart } from '../index.js'; +import { + ColumnChartClickTest, + ColumnChartDataPointClickTest, + ColumnChartHighlightColorTest, + ColumnChartLegendConfigTest, + ColumnChartSecondYAxisTest, +} from './ColumnChartTestComponents.js'; + +const dimensions = [{ accessor: 'name', interval: 0 }]; +const measures = [ + { accessor: 'users', label: 'Users' }, + { accessor: 'sessions', label: 'Active Sessions' }, + { accessor: 'volume', label: 'Vol.' }, +]; +const baseProps = { dataset: complexDataSet, dimensions, measures }; + +test.describe('ColumnChart', () => { + test('Basic', async ({ mount, page }) => { + await mount( + , + ); + await expect(page.locator('.recharts-responsive-container')).toBeVisible(); + await expect(page.locator('.recharts-bar')).toHaveCount(3); + await expect(page.locator('.recharts-bar-rectangles')).toHaveCount(3); + }); + + test('click handlers', async ({ mount, page }) => { + await mount(); + + await page.getByText('January').click(); + await expect(page.getByTestId('click-count')).toHaveText('1'); + + await page.locator('[name="January"]').first().click(); + await expect(page.getByTestId('click-count')).toHaveText('2'); + await expect(page.getByTestId('last-payload')).toHaveText(JSON.stringify(complexDataSet[0])); + + await page.locator('.recharts-legend-item-text').filter({ hasText: 'Users' }).click(); + await expect(page.getByTestId('legend-click-count')).toHaveText('1'); + await expect(page.getByTestId('last-legend-value')).toHaveText('Users'); + + await page.locator('.recharts-legend-item-text').filter({ hasText: 'Vol.' }).click(); + await expect(page.getByTestId('last-legend-datakey')).toHaveText('volume'); + }); + + testLoadingStates( + ColumnChart, + { + dataset: complexDataSet, + dimensions: [{ accessor: 'name', interval: 0 }], + measures: [{ accessor: 'users', label: 'Users' }], + }, + { dimensions: [], measures: [] }, + '.recharts-bar', + ); + + test('legendConfig', async ({ mount, page }) => { + await mount(); + await expect(page.getByTestId('catval').first()).toBeVisible(); + }); + + testZoomingTool(ColumnChart, baseProps); + + testPassThroughProps(ColumnChart, { dimensions: [], measures: [] }); + + testStackAggregateTotals(ColumnChart, { dataset: complexDataSet.slice(0, 3), dimensions }, [ + { accessor: 'users', stackId: 'A', label: 'Users' }, + { accessor: 'sessions', stackId: 'A', label: 'Active Sessions' }, + ]); + + test('onDataPointClick', async ({ mount, page }) => { + await mount(); + + await page.locator('[name="January"]').first().click(); + await expect(page.getByTestId('dp-click-count')).toHaveText('1'); + await expect(page.getByTestId('dp-last-datakey')).not.toHaveText(''); + await expect(page.getByTestId('dp-last-value')).not.toHaveText(''); + await expect(page.getByTestId('dp-last-data-index')).not.toHaveText('-1'); + await expect(page.getByTestId('dp-last-payload')).toHaveText(JSON.stringify(complexDataSet[0])); + }); + + test('highlightColor', async ({ mount, page }) => { + await mount(); + + // January has users=100 (<=200 → green), February has users=230 (>200 → red) + const greenCells = page.locator('.recharts-bar-rectangle [fill="green"]'); + const redCells = page.locator('.recharts-bar-rectangle [fill="red"]'); + await expect(greenCells.first()).toBeAttached(); + await expect(redCells.first()).toBeAttached(); + }); + + test('secondYAxis', async ({ mount, page }) => { + await mount(); + + // ColumnChart is vertical so secondYAxis renders as an additional YAxis + await expect(page.locator('.recharts-yAxis')).toHaveCount(2); + }); +}); diff --git a/packages/charts/src/components/ColumnChart/test/ColumnChartTestComponents.tsx b/packages/charts/src/components/ColumnChart/test/ColumnChartTestComponents.tsx new file mode 100644 index 00000000000..6d8c5b43eca --- /dev/null +++ b/packages/charts/src/components/ColumnChart/test/ColumnChartTestComponents.tsx @@ -0,0 +1,44 @@ +import { complexDataSet } from '../../../resources/DemoProps.js'; +import { + createClickTestComponent, + createDataPointClickTestComponent, + createHighlightColorTestComponent, + createLegendConfigTestComponent, + createSecondYAxisTestComponent, +} from '../../../test-utils/componentFactories.js'; +import { ColumnChart } from '../index.js'; + +const dimensions = [{ accessor: 'name', interval: 0 }]; + +const measures = [ + { accessor: 'users', label: 'Users', formatter: (val: number) => val.toLocaleString('en') }, + { + accessor: 'sessions', + label: 'Active Sessions', + formatter: (val: number) => `${val} sessions`, + hideDataLabel: true, + }, + { accessor: 'volume', label: 'Vol.' }, +]; + +const baseProps = { dataset: complexDataSet, dimensions, measures }; + +export const ColumnChartClickTest = createClickTestComponent(ColumnChart, baseProps, { + trackLegendValue: true, +}); + +export const ColumnChartLegendConfigTest = createLegendConfigTestComponent(ColumnChart, baseProps); + +export const ColumnChartDataPointClickTest = createDataPointClickTestComponent(ColumnChart, baseProps); + +export const ColumnChartHighlightColorTest = createHighlightColorTestComponent(ColumnChart, baseProps, [ + { + accessor: 'users', + label: 'Users', + highlightColor: (value: number) => (value > 200 ? 'red' : 'green'), + }, + { accessor: 'sessions', label: 'Active Sessions' }, + { accessor: 'volume', label: 'Vol.' }, +]); + +export const ColumnChartSecondYAxisTest = createSecondYAxisTestComponent(ColumnChart, baseProps, 'volume'); diff --git a/packages/charts/src/components/ColumnChartWithTrend/ColumnChartWithTrend.cy.tsx b/packages/charts/src/components/ColumnChartWithTrend/ColumnChartWithTrend.cy.tsx deleted file mode 100644 index fc63be0686d..00000000000 --- a/packages/charts/src/components/ColumnChartWithTrend/ColumnChartWithTrend.cy.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { complexDataSet } from '../../resources/DemoProps.js'; -import { ColumnChartWithTrend } from './index.js'; -import { cypressPassThroughTestsFactory, testChartLegendConfig, testChartZoomingTool } from '@/cypress/support/utils'; - -const dimensions = [ - { - accessor: 'name', - interval: 0, - }, -]; -const measures = [ - { - accessor: 'users', - label: 'Users', - formatter: (val: number) => val.toLocaleString('en'), - type: 'line', - }, - { - accessor: 'sessions', - label: 'Active Sessions', - formatter: (val) => `${val} sessions`, - type: 'bar', - }, -]; - -describe('ColumnChartWithTrend', () => { - it('Basic', () => { - cy.mount(); - cy.get('.recharts-responsive-container').should('be.visible'); - cy.get('.recharts-bar').should('have.length', 1); - cy.get('.recharts-line').should('have.length', 1); - cy.get('.recharts-bar-rectangles').should('have.length', 1); - cy.get('.recharts-line-curve').should('have.length', 1); - }); - - it('click handlers', () => { - const onClick = cy.spy().as('onClick'); - const onLegendClick = cy.spy().as('onLegendClick'); - cy.mount( - , - ); - - cy.findByText('January').click(); - cy.get('@onClick').should('have.been.called'); - cy.get('[name="January"]').eq(0).click(); - cy.get('@onClick') - .should('have.been.calledTwice') - .and( - 'have.been.calledWith', - Cypress.sinon.match({ - detail: Cypress.sinon.match({ - payload: { - name: 'January', - users: 100, - sessions: 300, - volume: 756, - }, - }), - }), - ); - - cy.get('.recharts-legend-item-text').contains('Users').click(); - cy.get('@onClick').should( - 'have.been.calledWith', - Cypress.sinon.match({ - detail: Cypress.sinon.match({ - dataKey: 'users', - }), - }), - ); - }); - - it('Loading Placeholder', () => { - cy.mount(); - cy.get('.recharts-bar').should('not.exist'); - cy.get('.recharts-line').should('not.exist'); - cy.contains('Loading...').should('exist'); - }); - - it('in Grid', () => { - cy.mount( -
- -
, - ); - - cy.findByTestId('ccwt').should('be.visible').invoke('prop', 'offsetHeight').should('eq', 500); - cy.findByTestId('ccwt').invoke('prop', 'offsetWidth').should('eq', 500); - }); - - testChartZoomingTool(ColumnChartWithTrend, { dataset: complexDataSet, dimensions, measures }); - - testChartLegendConfig(ColumnChartWithTrend, { dataset: complexDataSet, dimensions, measures }); - - cypressPassThroughTestsFactory(ColumnChartWithTrend, { dimensions: [], measures: [] }); -}); diff --git a/packages/charts/src/components/ColumnChartWithTrend/test/ColumnChartWithTrend.spec.tsx b/packages/charts/src/components/ColumnChartWithTrend/test/ColumnChartWithTrend.spec.tsx new file mode 100644 index 00000000000..390ae0656e2 --- /dev/null +++ b/packages/charts/src/components/ColumnChartWithTrend/test/ColumnChartWithTrend.spec.tsx @@ -0,0 +1,76 @@ +import { expect, test } from '../../../../../../playwright/fixtures/main-fixtures.js'; +import { complexDataSet } from '../../../resources/DemoProps.js'; +import { testLoadingStates, testPassThroughProps, testZoomingTool } from '../../../test-utils/sharedTests.js'; +import { ColumnChartWithTrend } from '../index.js'; +import { + ColumnChartWithTrendClickTest, + ColumnChartWithTrendGridTest, + ColumnChartWithTrendLegendConfigTest, +} from './ColumnChartWithTrendTestComponents.js'; + +const dimensions = [{ accessor: 'name', interval: 0 }]; +const measures = [ + { accessor: 'users', label: 'Users', type: 'line' as const }, + { accessor: 'sessions', label: 'Active Sessions', type: 'bar' as const }, +]; +const baseProps = { dataset: complexDataSet, dimensions, measures }; + +test.describe('ColumnChartWithTrend', () => { + test('Basic', async ({ mount, page }) => { + await mount(); + await expect(page.locator('.recharts-responsive-container').first()).toBeVisible(); + await expect(page.locator('.recharts-bar')).toHaveCount(1); + await expect(page.locator('.recharts-line')).toHaveCount(1); + await expect(page.locator('.recharts-bar-rectangles')).toHaveCount(1); + await expect(page.locator('.recharts-line-curve')).toHaveCount(1); + }); + + test('click handlers', async ({ mount, page }) => { + await mount(); + + await page.getByText('January').click(); + await expect(page.getByTestId('click-count')).toHaveText('1'); + + await page.locator('[name="January"]').first().click(); + await expect(page.getByTestId('click-count')).toHaveText('2'); + await expect(page.getByTestId('last-payload')).toHaveText( + JSON.stringify({ name: 'January', users: 100, sessions: 300, volume: 756 }), + ); + + await page.locator('.recharts-legend-item-text').filter({ hasText: 'Users' }).click(); + await expect(page.getByTestId('legend-click-count')).toHaveText('1'); + await expect(page.getByTestId('last-legend-datakey')).toHaveText('users'); + }); + + testLoadingStates( + ColumnChartWithTrend, + { + dataset: complexDataSet, + dimensions: [{ accessor: 'name', interval: 0 }], + measures: [ + { accessor: 'users', label: 'Users', type: 'line' as const }, + { accessor: 'sessions', label: 'Active Sessions', type: 'bar' as const }, + ], + }, + { dimensions: [], measures: [] }, + '.recharts-bar', + ); + + test('in Grid', async ({ mount, page }) => { + await mount(); + const chart = page.getByTestId('ccwt'); + await expect(chart).toBeVisible(); + const box = await chart.boundingBox(); + expect(box.height).toBe(500); + expect(box.width).toBe(500); + }); + + test('legendConfig', async ({ mount, page }) => { + await mount(); + await expect(page.getByTestId('catval').first()).toBeVisible(); + }); + + testZoomingTool(ColumnChartWithTrend, baseProps); + + testPassThroughProps(ColumnChartWithTrend, { dimensions: [], measures: [] }); +}); diff --git a/packages/charts/src/components/ColumnChartWithTrend/test/ColumnChartWithTrendTestComponents.tsx b/packages/charts/src/components/ColumnChartWithTrend/test/ColumnChartWithTrendTestComponents.tsx new file mode 100644 index 00000000000..fa9e32ea68c --- /dev/null +++ b/packages/charts/src/components/ColumnChartWithTrend/test/ColumnChartWithTrendTestComponents.tsx @@ -0,0 +1,69 @@ +import { useState } from 'react'; +import { complexDataSet } from '../../../resources/DemoProps.js'; +import { createLegendConfigTestComponent } from '../../../test-utils/componentFactories.js'; +import { ColumnChartWithTrend } from '../index.js'; + +const dimensions = [{ accessor: 'name', interval: 0 }]; + +const measures = [ + { + accessor: 'users', + label: 'Users', + formatter: (val: number) => val.toLocaleString('en'), + type: 'line' as const, + }, + { + accessor: 'sessions', + label: 'Active Sessions', + formatter: (val: number) => `${val} sessions`, + type: 'bar' as const, + }, +]; + +const baseProps = { dataset: complexDataSet, dimensions, measures }; + +export function ColumnChartWithTrendClickTest() { + const [clickCount, setClickCount] = useState(0); + const [lastPayload, setLastPayload] = useState(''); + const [legendClickCount, setLegendClickCount] = useState(0); + const [lastLegendDataKey, setLastLegendDataKey] = useState(''); + + return ( + <> + {clickCount} + {lastPayload} + {legendClickCount} + {lastLegendDataKey} + { + setClickCount((c) => c + 1); + if (e.detail?.payload) { + setLastPayload(JSON.stringify(e.detail.payload)); + } + }} + onLegendClick={(e: any) => { + setLegendClickCount((c) => c + 1); + setLastLegendDataKey(e.detail?.dataKey || ''); + }} + /> + + ); +} + +export function ColumnChartWithTrendGridTest() { + return ( +
+ +
+ ); +} + +export const ColumnChartWithTrendLegendConfigTest = createLegendConfigTestComponent(ColumnChartWithTrend, baseProps); diff --git a/packages/charts/src/components/ComposedChart/ComposedChart.cy.tsx b/packages/charts/src/components/ComposedChart/ComposedChart.cy.tsx deleted file mode 100644 index a82eacb3460..00000000000 --- a/packages/charts/src/components/ComposedChart/ComposedChart.cy.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { complexDataSet } from '../../resources/DemoProps.js'; -import { ComposedChart } from './index.js'; -import { - cypressPassThroughTestsFactory, - testChartLegendConfig, - testChartZoomingTool, - testStackAggregateTotals, -} from '@/cypress/support/utils'; - -const dimensions = [ - { - accessor: 'name', - interval: 0, - }, -]; -const measures = [ - { - accessor: 'users', - label: 'Users', - formatter: (val: number) => val.toLocaleString('en'), - type: 'line', - }, - { - accessor: 'sessions', - label: 'Active Sessions', - formatter: (val) => `${val} sessions`, - type: 'bar', - }, - { - accessor: 'volume', - label: 'Vol.', - type: 'area', - }, -]; - -describe('ComposedChart', () => { - it('Basic', () => { - cy.mount(); - cy.get('.recharts-responsive-container').should('be.visible'); - measures.forEach(({ type }) => { - cy.get(`.recharts-${type}`).should('have.length', 1); - }); - - cy.get('.recharts-area-dots').should('have.length', 1); - cy.get('.recharts-bar-rectangles').should('have.length', 1); - cy.get('.recharts-line-curve').should('have.length', 1); - }); - - it('click handlers', () => { - const onClick = cy.spy().as('onClick'); - const onLegendClick = cy.spy().as('onLegendClick'); - cy.mount( - , - ); - - cy.findByText('January').click(); - cy.get('@onClick').should('have.been.called'); - cy.get('[name="January"]').eq(0).click(); - cy.get('@onClick') - .should('have.been.calledTwice') - .and( - 'have.been.calledWith', - Cypress.sinon.match({ - detail: Cypress.sinon.match({ - payload: complexDataSet[0], - }), - }), - ); - - cy.contains('Users').click(); - cy.get('@onLegendClick').should( - 'have.been.calledWith', - Cypress.sinon.match({ - detail: Cypress.sinon.match({ - dataKey: 'users', - }), - }), - ); - }); - - it('Loading Placeholder', () => { - cy.mount(); - cy.get('.recharts-bar').should('not.exist'); - cy.contains('Loading...').should('exist'); - }); - - testChartZoomingTool(ComposedChart, { dataset: complexDataSet, dimensions, measures }); - - testChartLegendConfig(ComposedChart, { dataset: complexDataSet, dimensions, measures }); - - cypressPassThroughTestsFactory(ComposedChart, { dimensions: [], measures: [] }); - - testStackAggregateTotals(ComposedChart, { - dataset: complexDataSet.slice(0, 3), - dimensions, - measures: [ - { accessor: 'users', stackId: 'A', label: 'Users', type: 'bar' }, - { accessor: 'sessions', stackId: 'A', label: 'Active Sessions', type: 'bar' }, - ], - }); -}); diff --git a/packages/charts/src/components/ComposedChart/test/ComposedChart.spec.tsx b/packages/charts/src/components/ComposedChart/test/ComposedChart.spec.tsx new file mode 100644 index 00000000000..a8c73b7697a --- /dev/null +++ b/packages/charts/src/components/ComposedChart/test/ComposedChart.spec.tsx @@ -0,0 +1,116 @@ +import { expect, test } from '../../../../../../playwright/fixtures/main-fixtures.js'; +import { complexDataSet } from '../../../resources/DemoProps.js'; +import { + testLoadingStates, + testPassThroughProps, + testStackAggregateTotals, + testZoomingTool, +} from '../../../test-utils/sharedTests.js'; +import { ComposedChart } from '../index.js'; +import { + ComposedChartClickTest, + ComposedChartDataPointClickTest, + ComposedChartLegendConfigTest, + ComposedChartSecondYAxisTest, + ComposedChartVerticalLayoutTest, +} from './ComposedChartTestComponents.js'; + +const dimensions = [{ accessor: 'name', interval: 0 }]; +const measures = [ + { accessor: 'users', label: 'Users', type: 'line' as const }, + { accessor: 'sessions', label: 'Active Sessions', type: 'bar' as const }, + { accessor: 'volume', label: 'Vol.', type: 'area' as const }, +]; +const baseProps = { dataset: complexDataSet, dimensions, measures }; + +test.describe('ComposedChart', () => { + test('Basic', async ({ mount, page }) => { + await mount( + , + ); + await expect(page.locator('.recharts-responsive-container')).toBeVisible(); + await expect(page.locator('.recharts-line')).toHaveCount(1); + await expect(page.locator('.recharts-bar')).toHaveCount(1); + await expect(page.locator('.recharts-area')).toHaveCount(1); + await expect(page.locator('.recharts-area-dots')).toHaveCount(1); + await expect(page.locator('.recharts-bar-rectangles')).toHaveCount(1); + await expect(page.locator('.recharts-line-curve')).toHaveCount(1); + }); + + test('click handlers', async ({ mount, page }) => { + await mount(); + + await page.getByText('January').click(); + await expect(page.getByTestId('click-count')).toHaveText('1'); + + await page.locator('[name="January"]').first().click(); + await expect(page.getByTestId('click-count')).toHaveText('2'); + await expect(page.getByTestId('last-payload')).toHaveText(JSON.stringify(complexDataSet[0])); + + await page.locator('.recharts-legend-item-text').filter({ hasText: 'Users' }).click(); + await expect(page.getByTestId('legend-click-count')).toHaveText('1'); + await expect(page.getByTestId('last-legend-datakey')).toHaveText('users'); + }); + + testLoadingStates( + ComposedChart, + { + dataset: complexDataSet, + dimensions: [{ accessor: 'name', interval: 0 }], + measures: [{ accessor: 'users', label: 'Users', type: 'bar' }], + }, + { dimensions: [], measures: [] }, + '.recharts-bar', + ); + + test('legendConfig', async ({ mount, page }) => { + await mount(); + await expect(page.getByTestId('catval').first()).toBeVisible(); + }); + + testZoomingTool(ComposedChart, baseProps); + + testPassThroughProps(ComposedChart, { dimensions: [], measures: [] }); + + testStackAggregateTotals(ComposedChart, { dataset: complexDataSet.slice(0, 3), dimensions }, [ + { accessor: 'users', stackId: 'A', label: 'Users', type: 'bar' as const }, + { accessor: 'sessions', stackId: 'A', label: 'Active Sessions', type: 'bar' as const }, + ]); + + test('layout="vertical"', async ({ mount, page }) => { + await mount(); + await expect(page.locator('.recharts-responsive-container')).toBeVisible(); + // Vertical layout swaps axes: measure axis becomes XAxis (type=number) + await expect(page.locator('.recharts-xAxis')).toBeAttached(); + // Chart elements should still render + await expect(page.locator('.recharts-bar')).toHaveCount(1); + await expect(page.locator('.recharts-line')).toHaveCount(1); + await expect(page.locator('.recharts-area')).toHaveCount(1); + }); + + test('onDataPointClick', async ({ mount, page }) => { + await mount(); + + await page.locator('[name="January"]').first().click(); + await expect(page.getByTestId('dp-click-count')).toHaveText('1'); + await expect(page.getByTestId('dp-last-datakey')).not.toHaveText(''); + await expect(page.getByTestId('dp-last-value')).not.toHaveText(''); + await expect(page.getByTestId('dp-last-data-index')).not.toHaveText('-1'); + await expect(page.getByTestId('dp-last-payload')).toHaveText(JSON.stringify(complexDataSet[0])); + }); + + test('secondYAxis', async ({ mount, page }) => { + await mount(); + + // ComposedChart renders secondYAxis as an additional YAxis + await expect(page.locator('.recharts-yAxis')).toHaveCount(2); + }); +}); diff --git a/packages/charts/src/components/ComposedChart/test/ComposedChartTestComponents.tsx b/packages/charts/src/components/ComposedChart/test/ComposedChartTestComponents.tsx new file mode 100644 index 00000000000..8368af6c1e5 --- /dev/null +++ b/packages/charts/src/components/ComposedChart/test/ComposedChartTestComponents.tsx @@ -0,0 +1,29 @@ +import { complexDataSet } from '../../../resources/DemoProps.js'; +import { + createClickTestComponent, + createDataPointClickTestComponent, + createLegendConfigTestComponent, + createSecondYAxisTestComponent, + createVerticalLayoutTestComponent, +} from '../../../test-utils/componentFactories.js'; +import { ComposedChart } from '../index.js'; + +const dimensions = [{ accessor: 'name', interval: 0 }]; + +const measures = [ + { accessor: 'users', label: 'Users', formatter: (val: number) => val.toLocaleString('en'), type: 'line' }, + { accessor: 'sessions', label: 'Active Sessions', formatter: (val: number) => `${val} sessions`, type: 'bar' }, + { accessor: 'volume', label: 'Vol.', type: 'area' }, +]; + +const baseProps = { dataset: complexDataSet, dimensions, measures }; + +export const ComposedChartClickTest = createClickTestComponent(ComposedChart, baseProps); + +export const ComposedChartLegendConfigTest = createLegendConfigTestComponent(ComposedChart, baseProps); + +export const ComposedChartVerticalLayoutTest = createVerticalLayoutTestComponent(ComposedChart, baseProps); + +export const ComposedChartDataPointClickTest = createDataPointClickTestComponent(ComposedChart, baseProps); + +export const ComposedChartSecondYAxisTest = createSecondYAxisTestComponent(ComposedChart, baseProps, 'volume'); diff --git a/packages/charts/src/components/DonutChart/DonutChart.cy.tsx b/packages/charts/src/components/DonutChart/DonutChart.cy.tsx deleted file mode 100644 index b9bd1df68b7..00000000000 --- a/packages/charts/src/components/DonutChart/DonutChart.cy.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { complexDataSet, simpleDataSet } from '../../resources/DemoProps.js'; -import { DonutChart } from './index.js'; -import { cypressPassThroughTestsFactory, testChartLegendConfig, testPieSectorFocus } from '@/cypress/support/utils'; - -const dimension = { - accessor: 'name', -}; -const measure = { - accessor: 'users', -}; - -describe('DonutChart', () => { - it('Basic', () => { - cy.mount(); - cy.get('.recharts-responsive-container').should('be.visible'); - cy.get('.recharts-pie').should('have.length', 1); - cy.get('.recharts-pie-sector').should('have.length', 12); - }); - - it('click handlers', () => { - const onClick = cy.spy().as('onClick'); - const onLegendClick = cy.spy().as('onLegendClick'); - cy.mount( - , - ); - - cy.get('[name="January"]').eq(0).click({ force: true, waitForAnimations: true }); - cy.get('@onClick') - .should('have.been.called') - .and( - 'have.been.calledWith', - Cypress.sinon.match({ - detail: Cypress.sinon.match({ - payload: simpleDataSet[0], - }), - }), - ); - - cy.contains('January').click(); - cy.get('@onLegendClick').should( - 'have.been.calledWith', - Cypress.sinon.match({ - detail: Cypress.sinon.match({ - dataKey: 'users', - }), - }), - ); - }); - - it('Loading Placeholder', () => { - cy.mount(); - cy.get('.recharts-pie').should('not.exist'); - cy.contains('Loading...').should('exist'); - }); - - cypressPassThroughTestsFactory(DonutChart, { dimension: {}, measure: {} }); - - testChartLegendConfig(DonutChart, { dataset: complexDataSet, dimension, measure }); - - testPieSectorFocus(DonutChart, { dataset: simpleDataSet, dimension, measure }); -}); diff --git a/packages/charts/src/components/DonutChart/test/DonutChart.spec.tsx b/packages/charts/src/components/DonutChart/test/DonutChart.spec.tsx new file mode 100644 index 00000000000..368e42b74da --- /dev/null +++ b/packages/charts/src/components/DonutChart/test/DonutChart.spec.tsx @@ -0,0 +1,204 @@ +import { expect, test } from '../../../../../../playwright/fixtures/main-fixtures.js'; +import { simpleDataSet } from '../../../resources/DemoProps.js'; +import { testLoadingStates, testPassThroughProps } from '../../../test-utils/sharedTests.js'; +import { DonutChart } from '../index.js'; +import { + DonutChartClickTest, + DonutChartLegendConfigTest, + DonutChartSectorFocusActiveTest, + DonutChartSectorFocusDatasetShrinkTest, + DonutChartSectorFocusEmptyTest, + DonutChartSectorFocusHandlersTest, + DonutChartSectorFocusOutOfBoundsTest, + DonutChartSectorFocusTest, +} from './DonutChartTestComponents.js'; + +const dimension = { accessor: 'name' }; +const measure = { accessor: 'users' }; + +test.describe('DonutChart', () => { + test('Basic', async ({ mount, page }) => { + await mount(); + await expect(page.locator('.recharts-responsive-container')).toBeVisible(); + await expect(page.locator('.recharts-pie')).toHaveCount(1); + await expect(page.locator('.recharts-pie-sector')).toHaveCount(12); + }); + + test('click handlers', async ({ mount, page }) => { + await mount(); + + await page.locator('[name="January"]').first().click({ force: true }); + await expect(page.getByTestId('click-count')).toHaveText('1'); + await expect(page.getByTestId('last-payload')).toHaveText(JSON.stringify(simpleDataSet[0])); + + await page.locator('.recharts-legend-item-text').filter({ hasText: 'January' }).click(); + await expect(page.getByTestId('legend-click-count')).toHaveText('1'); + await expect(page.getByTestId('last-legend-datakey')).toHaveText('users'); + }); + + testLoadingStates( + DonutChart, + { dataset: simpleDataSet, dimension, measure }, + { dimension: {}, measure: {} }, + '.recharts-pie', + ); + + testPassThroughProps(DonutChart, { dimension: {}, measure: {} }); + + test('legendConfig', async ({ mount, page }) => { + await mount(); + await expect(page.getByTestId('catval').first()).toBeVisible(); + }); + + test.describe('Sector Focus - keyboard navigation', () => { + test('Tab, arrows, Enter, wrap-around', async ({ mount, page }) => { + await mount(); + + // Focus "before" button then Tab into chart container + await page.getByText('before').focus(); + await page.keyboard.press('Tab'); + // Should focus the chart container + await expect(page.locator(':focus')).toHaveAttribute('aria-roledescription', 'chart'); + + // Tab again to enter sector mode - focuses first sector + await page.keyboard.press('Tab'); + await expect(page.locator(':focus')).toHaveAttribute('data-sector-index', '0'); + + // ArrowRight at first sector wraps to last + await page.keyboard.press('ArrowRight'); + await expect(page.locator(':focus')).toHaveAttribute('data-sector-index', String(simpleDataSet.length - 1)); + + // ArrowLeft wraps back to first area then continues + await page.keyboard.press('ArrowLeft'); + await expect(page.locator(':focus')).toHaveAttribute('data-sector-index', '0'); + + // ArrowLeft moves to next sector (index increments) + await page.keyboard.press('ArrowLeft'); + await expect(page.locator(':focus')).toHaveAttribute('data-sector-index', '1'); + + // ArrowLeft again + await page.keyboard.press('ArrowLeft'); + await expect(page.locator(':focus')).toHaveAttribute('data-sector-index', '2'); + + // ArrowRight moves back (index decrements) + await page.keyboard.press('ArrowRight'); + await expect(page.locator(':focus')).toHaveAttribute('data-sector-index', '1'); + + // Enter activates the sector + await page.keyboard.press('Enter'); + await expect(page.getByTestId('sector-click-count')).toHaveText('1'); + await expect(page.getByTestId('sector-last-index')).toHaveText('1'); + + // Shift+Tab returns focus to the chart container + await page.keyboard.press('Shift+Tab'); + await expect(page.locator(':focus')).toHaveAttribute('aria-roledescription', 'chart'); + }); + + test('activeSegment with Enter and Space', async ({ mount, page }) => { + await mount(); + + // Initial activeSegment is 2 + await expect(page.getByTestId('active-segment')).toHaveText('2'); + + // Focus "before" button then Tab into chart container + await page.getByText('before').focus(); + await page.keyboard.press('Tab'); + await expect(page.locator(':focus')).toHaveAttribute('aria-roledescription', 'chart'); + + // Tab into sectors - should start at activeSegment (index 2) + await page.keyboard.press('Tab'); + await expect(page.locator(':focus')).toHaveAttribute('data-sector-index', '2'); + + // Enter activates the current sector, updating activeSegment + await page.keyboard.press('Enter'); + await expect(page.getByTestId('active-segment')).toHaveText('2'); + await expect(page.locator('.recharts-active-shape')).toBeAttached(); + + // Navigate to a different sector and activate with Enter + await page.keyboard.press('ArrowLeft'); + await expect(page.locator(':focus')).toHaveAttribute('data-sector-index', '3'); + await page.keyboard.press('Enter'); + await expect(page.getByTestId('active-segment')).toHaveText('3'); + + // Navigate and activate with Space + await page.keyboard.press('ArrowLeft'); + await expect(page.locator(':focus')).toHaveAttribute('data-sector-index', '4'); + await page.keyboard.press(' '); + await expect(page.getByTestId('active-segment')).toHaveText('4'); + + // Hold Space, navigate to different sector, release — activates on keyup + await page.keyboard.press('ArrowLeft'); + await expect(page.locator(':focus')).toHaveAttribute('data-sector-index', '5'); + await page.keyboard.down(' '); + await page.keyboard.press('ArrowLeft'); + await expect(page.locator(':focus')).toHaveAttribute('data-sector-index', '6'); + await page.keyboard.up(' '); + await expect(page.getByTestId('active-segment')).toHaveText('6'); + }); + + test('empty dataset is non-interactive', async ({ mount, page }) => { + await mount(); + + // The chart container should have tabindex 0 but no role="application" + const chartContainer = page.locator('[aria-roledescription="chart"]'); + await expect(chartContainer).toHaveAttribute('tabindex', '0'); + await expect(chartContainer).not.toHaveAttribute('role', 'application'); + }); + + test('consumer event handlers are composed', async ({ mount, page }) => { + await mount(); + + // Focus the chart container directly (triggers onFocus) + const chartContainer = page.locator('[aria-roledescription="chart"]'); + await chartContainer.focus(); + await expect(page.getByTestId('focus-count')).toHaveText('1'); + + // Tab into sector mode (triggers onKeyDownCapture) + await page.keyboard.press('Tab'); + await expect(page.getByTestId('keydown-capture-count')).toHaveText('1'); + + // ArrowLeft fires another keydown capture + await page.keyboard.press('ArrowLeft'); + await expect(page.getByTestId('keydown-capture-count')).toHaveText('2'); + + // Blur the chart (triggers onBlur) + await page.getByText('after').focus(); + await expect(page.getByTestId('blur-count')).not.toHaveText('0'); + }); + + test('activeSegment out of bounds is clamped', async ({ mount, page }) => { + await mount(); + + await page.getByText('before').focus(); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await expect(page.locator(':focus')).toHaveAttribute('data-sector-index', String(simpleDataSet.length - 1)); + }); + + test('dataset shrink resets keyboard state', async ({ mount, page }) => { + await mount(); + + // Tab past "shrink" button into chart, then into sector mode + await page.getByText('before').focus(); + await page.keyboard.press('Tab'); // → "shrink" button + await page.keyboard.press('Tab'); // → chart container + await page.keyboard.press('Tab'); // → sector 0 + for (let i = 0; i < 5; i++) { + await page.keyboard.press('ArrowLeft'); + } + await expect(page.locator(':focus')).toHaveAttribute('data-sector-index', '5'); + + // Shrink dataset to 3 items + await page.getByText('shrink').click(); + const chartContainer = page.locator('[aria-roledescription="chart"]'); + await expect(chartContainer).toHaveAttribute('tabindex', '0'); + + // Re-enter sector mode — should start from a valid index + await page.getByText('before').focus(); + await page.keyboard.press('Tab'); // → "shrink" button + await page.keyboard.press('Tab'); // → chart container + await page.keyboard.press('Tab'); // → sector + await expect(page.locator(':focus')).toHaveAttribute('data-sector-index'); + }); + }); +}); diff --git a/packages/charts/src/components/DonutChart/test/DonutChartTestComponents.tsx b/packages/charts/src/components/DonutChart/test/DonutChartTestComponents.tsx new file mode 100644 index 00000000000..2d251a7cbbe --- /dev/null +++ b/packages/charts/src/components/DonutChart/test/DonutChartTestComponents.tsx @@ -0,0 +1,170 @@ +import { useState } from 'react'; +import { complexDataSet, simpleDataSet } from '../../../resources/DemoProps.js'; +import { DonutChart } from '../index.js'; + +const dimension = { accessor: 'name' }; +const measure = { accessor: 'users' }; + +export function DonutChartClickTest() { + const [clickCount, setClickCount] = useState(0); + const [lastPayload, setLastPayload] = useState(''); + const [legendClickCount, setLegendClickCount] = useState(0); + const [lastLegendDataKey, setLastLegendDataKey] = useState(''); + + return ( + <> + {clickCount} + {lastPayload} + {legendClickCount} + {lastLegendDataKey} + { + setClickCount((c) => c + 1); + setLastPayload(JSON.stringify(e.detail?.payload?.payload ?? e.detail?.payload)); + }} + onLegendClick={(e) => { + setLegendClickCount((c) => c + 1); + setLastLegendDataKey(e.detail?.dataKey || ''); + }} + /> + + ); +} + +export function DonutChartLegendConfigTest() { + return ( + {value}, + }, + }} + /> + ); +} + +export function DonutChartSectorFocusTest() { + const [clickCount, setClickCount] = useState(0); + const [lastClickIndex, setLastClickIndex] = useState(-1); + + return ( + <> + + {clickCount} + {lastClickIndex} + { + setClickCount((c) => c + 1); + setLastClickIndex(e.detail?.dataIndex ?? -1); + }} + noAnimation + /> + + ); +} + +export function DonutChartSectorFocusActiveTest() { + const [activeSegment, setActiveSegment] = useState(2); + + return ( + <> + + {activeSegment} + { + setActiveSegment(e.detail?.dataIndex ?? 0); + }} + noAnimation + /> + + ); +} + +export function DonutChartSectorFocusOutOfBoundsTest() { + return ( + <> + + + + ); +} + +export function DonutChartSectorFocusDatasetShrinkTest() { + const [ds, setDs] = useState(simpleDataSet); + + return ( + <> + + + + + ); +} + +export function DonutChartSectorFocusEmptyTest() { + return ( + <> + + + + ); +} + +export function DonutChartSectorFocusHandlersTest() { + const [blurCount, setBlurCount] = useState(0); + const [focusCount, setFocusCount] = useState(0); + const [keyDownCaptureCount, setKeyDownCaptureCount] = useState(0); + + return ( + <> + + {blurCount} + {focusCount} + {keyDownCaptureCount} + + setBlurCount((c) => c + 1)} + onFocus={() => setFocusCount((c) => c + 1)} + onKeyDownCapture={() => setKeyDownCaptureCount((c) => c + 1)} + noAnimation + /> + + ); +} diff --git a/packages/charts/src/components/LineChart/LineChart.cy.tsx b/packages/charts/src/components/LineChart/LineChart.cy.tsx deleted file mode 100644 index 0f7c078374d..00000000000 --- a/packages/charts/src/components/LineChart/LineChart.cy.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { complexDataSet } from '../../resources/DemoProps.js'; -import { LineChart } from './index.js'; -import { cypressPassThroughTestsFactory, testChartLegendConfig, testChartZoomingTool } from '@/cypress/support/utils'; - -const dimensions = [ - { - accessor: 'name', - interval: 0, - }, -]; -const measures = [ - { - accessor: 'users', - label: 'Users', - formatter: (val: number) => val.toLocaleString('en'), - }, - { - accessor: 'sessions', - label: 'Active Sessions', - formatter: (val) => `${val} sessions`, - hideDataLabel: true, - }, - { - accessor: 'volume', - label: 'Vol.', - }, -]; - -describe('LineChart', () => { - it('Basic', () => { - cy.mount(); - cy.get('.recharts-responsive-container').should('be.visible'); - cy.get('.recharts-line').should('have.length', 3); - cy.get('.recharts-line-curve').should('have.length', 3); - cy.get('.recharts-brush').should('not.exist'); - }); - - it('click handlers', () => { - const onClick = cy.spy().as('onClick'); - const onLegendClick = cy.spy().as('onLegendClick'); - cy.mount( - , - ); - - cy.get('.recharts-line-dot[name="Users"]').eq(0).click({ force: true }); - cy.get('@onClick') - .should('have.been.calledOnce') - .and( - 'have.been.calledWith', - Cypress.sinon.match({ - detail: Cypress.sinon.match({ - payload: complexDataSet[0], - }), - }), - ); - - cy.contains('Users').click(); - cy.get('@onLegendClick').should( - 'have.been.calledWith', - Cypress.sinon.match({ - detail: Cypress.sinon.match({ - dataKey: 'users', - }), - }), - ); - }); - - it('Loading Placeholder', () => { - cy.mount(); - cy.get('.recharts-line').should('not.exist'); - cy.contains('Loading...').should('exist'); - }); - - testChartZoomingTool(LineChart, { dataset: complexDataSet, dimensions, measures }); - - testChartLegendConfig(LineChart, { dataset: complexDataSet, dimensions, measures }); - - cypressPassThroughTestsFactory(LineChart, { dimensions: [], measures: [] }); -}); diff --git a/packages/charts/src/components/LineChart/test/LineChart.spec.tsx b/packages/charts/src/components/LineChart/test/LineChart.spec.tsx new file mode 100644 index 00000000000..83e7c89f75b --- /dev/null +++ b/packages/charts/src/components/LineChart/test/LineChart.spec.tsx @@ -0,0 +1,82 @@ +import { expect, test } from '../../../../../../playwright/fixtures/main-fixtures.js'; +import { complexDataSet } from '../../../resources/DemoProps.js'; +import { testLoadingStates, testPassThroughProps, testZoomingTool } from '../../../test-utils/sharedTests.js'; +import { LineChart } from '../index.js'; +import { + LineChartClickTest, + LineChartDataPointClickTest, + LineChartLegendConfigTest, +} from './LineChartTestComponents.js'; + +const dimensions = [{ accessor: 'name', interval: 0 }]; +const measures = [ + { accessor: 'users', label: 'Users' }, + { accessor: 'sessions', label: 'Active Sessions' }, + { accessor: 'volume', label: 'Vol.' }, +]; +const baseProps = { dataset: complexDataSet, dimensions, measures }; + +test.describe('LineChart', () => { + test('Basic', async ({ mount, page }) => { + await mount( + , + ); + await expect(page.locator('.recharts-responsive-container')).toBeVisible(); + await expect(page.locator('.recharts-line')).toHaveCount(3); + await expect(page.locator('.recharts-line-curve')).toHaveCount(3); + await expect(page.locator('.recharts-brush')).not.toBeAttached(); + }); + + test('click handlers', async ({ mount, page }) => { + await mount(); + + await page.locator('.recharts-line-dot[name="Users"]').first().click({ force: true }); + await expect(page.getByTestId('click-count')).toHaveText('1'); + await expect(page.getByTestId('last-payload')).toHaveText(JSON.stringify(complexDataSet[0])); + + await page.locator('.recharts-legend-item-text').filter({ hasText: 'Users' }).click(); + await expect(page.getByTestId('legend-click-count')).toHaveText('1'); + await expect(page.getByTestId('last-legend-datakey')).toHaveText('users'); + }); + + testLoadingStates( + LineChart, + { + dataset: complexDataSet, + dimensions: [{ accessor: 'name', interval: 0 }], + measures: [{ accessor: 'users', label: 'Users' }], + }, + { dimensions: [], measures: [] }, + '.recharts-line', + ); + + test('legendConfig', async ({ mount, page }) => { + await mount(); + await expect(page.getByTestId('catval').first()).toBeVisible(); + }); + + testZoomingTool(LineChart, baseProps); + + testPassThroughProps(LineChart, { dimensions: [], measures: [] }); + + test('onDataPointClick', async ({ mount, page }) => { + await mount(); + + // LineChart fires onDataPointClick via activeDot — hover to trigger the active dot, then click it + const firstDot = page.locator('.recharts-line-dot[name="Users"]').first(); + await firstDot.hover(); + const activeDot = page.locator('.recharts-active-dot').first(); + await activeDot.click({ force: true }); + await expect(page.getByTestId('dp-click-count')).toHaveText('1'); + await expect(page.getByTestId('dp-last-datakey')).not.toHaveText(''); + await expect(page.getByTestId('dp-last-payload')).toHaveText(JSON.stringify(complexDataSet[0])); + }); +}); diff --git a/packages/charts/src/components/LineChart/test/LineChartTestComponents.tsx b/packages/charts/src/components/LineChart/test/LineChartTestComponents.tsx new file mode 100644 index 00000000000..57c093dcf40 --- /dev/null +++ b/packages/charts/src/components/LineChart/test/LineChartTestComponents.tsx @@ -0,0 +1,30 @@ +import { complexDataSet } from '../../../resources/DemoProps.js'; +import { + createClickTestComponent, + createDataPointClickTestComponent, + createLegendConfigTestComponent, +} from '../../../test-utils/componentFactories.js'; +import { LineChart } from '../index.js'; + +const dimensions = [{ accessor: 'name', interval: 0 }]; + +const measures = [ + { accessor: 'users', label: 'Users', formatter: (val: number) => val.toLocaleString('en') }, + { + accessor: 'sessions', + label: 'Active Sessions', + formatter: (val: number) => `${val} sessions`, + hideDataLabel: true, + }, + { accessor: 'volume', label: 'Vol.' }, +]; + +const baseProps = { dataset: complexDataSet, dimensions, measures }; + +export const LineChartClickTest = createClickTestComponent(LineChart, baseProps, { + noAnimation: true, +}); + +export const LineChartLegendConfigTest = createLegendConfigTestComponent(LineChart, baseProps); + +export const LineChartDataPointClickTest = createDataPointClickTestComponent(LineChart, baseProps); diff --git a/packages/charts/src/components/PieChart/PieChart.cy.tsx b/packages/charts/src/components/PieChart/PieChart.cy.tsx deleted file mode 100644 index 81e181b07de..00000000000 --- a/packages/charts/src/components/PieChart/PieChart.cy.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { Text as RechartsText } from 'recharts'; -import { complexDataSet, simpleDataSet } from '../../resources/DemoProps.js'; -import { PieChart } from './index.js'; -import { cypressPassThroughTestsFactory, testChartLegendConfig, testPieSectorFocus } from '@/cypress/support/utils'; - -const dimension = { - accessor: 'name', -}; -const measure = { - accessor: 'users', -}; - -describe('PieChart', () => { - it('Basic', () => { - cy.mount(); - cy.get('.recharts-responsive-container').should('be.visible'); - cy.get('.recharts-pie').should('have.length', 1); - cy.get('.recharts-pie-sector').should('have.length', 12); - }); - - it('click handlers', () => { - const onClick = cy.spy().as('onClick'); - const onLegendClick = cy.spy().as('onLegendClick'); - cy.mount( - , - ); - - cy.get('[name="January"]').eq(0).click({ force: true }); - cy.get('@onClick') - .should('have.been.called') - .and( - 'have.been.calledWith', - Cypress.sinon.match({ - detail: Cypress.sinon.match({ - payload: simpleDataSet[0], - }), - }), - ); - - cy.contains('January').click(); - cy.get('@onLegendClick').should( - 'have.been.calledWith', - Cypress.sinon.match({ - detail: Cypress.sinon.match({ - dataKey: 'users', - }), - }), - ); - }); - - it('Loading Placeholder', () => { - cy.mount(); - cy.get('.recharts-pie').should('not.exist'); - cy.contains('Loading...').should('exist'); - }); - - cypressPassThroughTestsFactory(PieChart, { dimension: {}, measure: {} }); - - it('custom label', () => { - const CustomDataLabel = (props) => CustomLabel; - - cy.mount( - , - }} - />, - ); - cy.findAllByText('CustomLabel').should('have.length', 12); - }); - - testChartLegendConfig(PieChart, { dataset: complexDataSet, dimension, measure }); - - testPieSectorFocus(PieChart, { dataset: simpleDataSet, dimension, measure }); -}); diff --git a/packages/charts/src/components/PieChart/test/PieChart.spec.tsx b/packages/charts/src/components/PieChart/test/PieChart.spec.tsx new file mode 100644 index 00000000000..6e9ed5e1ab2 --- /dev/null +++ b/packages/charts/src/components/PieChart/test/PieChart.spec.tsx @@ -0,0 +1,210 @@ +import { expect, test } from '../../../../../../playwright/fixtures/main-fixtures.js'; +import { simpleDataSet } from '../../../resources/DemoProps.js'; +import { testLoadingStates, testPassThroughProps } from '../../../test-utils/sharedTests.js'; +import { PieChart } from '../index.js'; +import { + PieChartClickTest, + PieChartCustomLabelTest, + PieChartLegendConfigTest, + PieChartSectorFocusActiveTest, + PieChartSectorFocusDatasetShrinkTest, + PieChartSectorFocusEmptyTest, + PieChartSectorFocusHandlersTest, + PieChartSectorFocusOutOfBoundsTest, + PieChartSectorFocusTest, +} from './PieChartTestComponents.js'; + +const dimension = { accessor: 'name' }; +const measure = { accessor: 'users' }; + +test.describe('PieChart', () => { + test('Basic', async ({ mount, page }) => { + await mount(); + await expect(page.locator('.recharts-responsive-container')).toBeVisible(); + await expect(page.locator('.recharts-pie')).toHaveCount(1); + await expect(page.locator('.recharts-pie-sector')).toHaveCount(12); + }); + + test('click handlers', async ({ mount, page }) => { + await mount(); + + await page.locator('[name="January"]').first().click({ force: true }); + await expect(page.getByTestId('click-count')).toHaveText('1'); + await expect(page.getByTestId('last-payload')).toHaveText(JSON.stringify(simpleDataSet[0])); + + await page.locator('.recharts-legend-item-text').filter({ hasText: 'January' }).click(); + await expect(page.getByTestId('legend-click-count')).toHaveText('1'); + await expect(page.getByTestId('last-legend-datakey')).toHaveText('users'); + }); + + testLoadingStates( + PieChart, + { dataset: simpleDataSet, dimension, measure }, + { dimension: {}, measure: {} }, + '.recharts-pie', + ); + + testPassThroughProps(PieChart, { dimension: {}, measure: {} }); + + test('custom label', async ({ mount, page }) => { + await mount(); + await expect(page.getByText('CustomLabel')).toHaveCount(12); + }); + + test('legendConfig', async ({ mount, page }) => { + await mount(); + await expect(page.getByTestId('catval').first()).toBeVisible(); + }); + + test.describe('Sector Focus - keyboard navigation', () => { + test('Tab, arrows, Enter, wrap-around', async ({ mount, page }) => { + await mount(); + + // Focus "before" button then Tab into chart container + await page.getByText('before').focus(); + await page.keyboard.press('Tab'); + // Should focus the chart container + await expect(page.locator(':focus')).toHaveAttribute('aria-roledescription', 'chart'); + + // Tab again to enter sector mode - focuses first sector + await page.keyboard.press('Tab'); + await expect(page.locator(':focus')).toHaveAttribute('data-sector-index', '0'); + + // ArrowRight at first sector wraps to last + await page.keyboard.press('ArrowRight'); + await expect(page.locator(':focus')).toHaveAttribute('data-sector-index', String(simpleDataSet.length - 1)); + + // ArrowLeft wraps back to first area then continues + await page.keyboard.press('ArrowLeft'); + await expect(page.locator(':focus')).toHaveAttribute('data-sector-index', '0'); + + // ArrowLeft moves to next sector (index increments) + await page.keyboard.press('ArrowLeft'); + await expect(page.locator(':focus')).toHaveAttribute('data-sector-index', '1'); + + // ArrowLeft again + await page.keyboard.press('ArrowLeft'); + await expect(page.locator(':focus')).toHaveAttribute('data-sector-index', '2'); + + // ArrowRight moves back (index decrements) + await page.keyboard.press('ArrowRight'); + await expect(page.locator(':focus')).toHaveAttribute('data-sector-index', '1'); + + // Enter activates the sector + await page.keyboard.press('Enter'); + await expect(page.getByTestId('sector-click-count')).toHaveText('1'); + await expect(page.getByTestId('sector-last-index')).toHaveText('1'); + + // Shift+Tab returns focus to the chart container + await page.keyboard.press('Shift+Tab'); + await expect(page.locator(':focus')).toHaveAttribute('aria-roledescription', 'chart'); + }); + + test('activeSegment with Enter and Space', async ({ mount, page }) => { + await mount(); + + // Initial activeSegment is 2 + await expect(page.getByTestId('active-segment')).toHaveText('2'); + + // Focus "before" button then Tab into chart container + await page.getByText('before').focus(); + await page.keyboard.press('Tab'); + await expect(page.locator(':focus')).toHaveAttribute('aria-roledescription', 'chart'); + + // Tab into sectors - should start at activeSegment (index 2) + await page.keyboard.press('Tab'); + await expect(page.locator(':focus')).toHaveAttribute('data-sector-index', '2'); + + // Enter activates the current sector, updating activeSegment + await page.keyboard.press('Enter'); + await expect(page.getByTestId('active-segment')).toHaveText('2'); + await expect(page.locator('.recharts-active-shape')).toBeAttached(); + + // Navigate to a different sector and activate with Enter + await page.keyboard.press('ArrowLeft'); + await expect(page.locator(':focus')).toHaveAttribute('data-sector-index', '3'); + await page.keyboard.press('Enter'); + await expect(page.getByTestId('active-segment')).toHaveText('3'); + + // Navigate and activate with Space + await page.keyboard.press('ArrowLeft'); + await expect(page.locator(':focus')).toHaveAttribute('data-sector-index', '4'); + await page.keyboard.press(' '); + await expect(page.getByTestId('active-segment')).toHaveText('4'); + + // Hold Space, navigate to different sector, release — activates on keyup + await page.keyboard.press('ArrowLeft'); + await expect(page.locator(':focus')).toHaveAttribute('data-sector-index', '5'); + await page.keyboard.down(' '); + await page.keyboard.press('ArrowLeft'); + await expect(page.locator(':focus')).toHaveAttribute('data-sector-index', '6'); + await page.keyboard.up(' '); + await expect(page.getByTestId('active-segment')).toHaveText('6'); + }); + + test('empty dataset is non-interactive', async ({ mount, page }) => { + await mount(); + + // The chart container should have tabindex 0 but no role="application" + const chartContainer = page.locator('[aria-roledescription="chart"]'); + await expect(chartContainer).toHaveAttribute('tabindex', '0'); + await expect(chartContainer).not.toHaveAttribute('role', 'application'); + }); + + test('consumer event handlers are composed', async ({ mount, page }) => { + await mount(); + + // Focus the chart container directly (triggers onFocus) + const chartContainer = page.locator('[aria-roledescription="chart"]'); + await chartContainer.focus(); + await expect(page.getByTestId('focus-count')).toHaveText('1'); + + // Tab into sector mode (triggers onKeyDownCapture) + await page.keyboard.press('Tab'); + await expect(page.getByTestId('keydown-capture-count')).toHaveText('1'); + + // ArrowLeft fires another keydown capture + await page.keyboard.press('ArrowLeft'); + await expect(page.getByTestId('keydown-capture-count')).toHaveText('2'); + + // Blur the chart (triggers onBlur) + await page.getByText('after').focus(); + await expect(page.getByTestId('blur-count')).not.toHaveText('0'); + }); + + test('activeSegment out of bounds is clamped', async ({ mount, page }) => { + await mount(); + + await page.getByText('before').focus(); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await expect(page.locator(':focus')).toHaveAttribute('data-sector-index', String(simpleDataSet.length - 1)); + }); + + test('dataset shrink resets keyboard state', async ({ mount, page }) => { + await mount(); + + // Tab past "shrink" button into chart, then into sector mode + await page.getByText('before').focus(); + await page.keyboard.press('Tab'); // → "shrink" button + await page.keyboard.press('Tab'); // → chart container + await page.keyboard.press('Tab'); // → sector 0 + for (let i = 0; i < 5; i++) { + await page.keyboard.press('ArrowLeft'); + } + await expect(page.locator(':focus')).toHaveAttribute('data-sector-index', '5'); + + // Shrink dataset to 3 items + await page.getByText('shrink').click(); + const chartContainer = page.locator('[aria-roledescription="chart"]'); + await expect(chartContainer).toHaveAttribute('tabindex', '0'); + + // Re-enter sector mode — should start from a valid index + await page.getByText('before').focus(); + await page.keyboard.press('Tab'); // → "shrink" button + await page.keyboard.press('Tab'); // → chart container + await page.keyboard.press('Tab'); // → sector + await expect(page.locator(':focus')).toHaveAttribute('data-sector-index'); + }); + }); +}); diff --git a/packages/charts/src/components/PieChart/test/PieChartTestComponents.tsx b/packages/charts/src/components/PieChart/test/PieChartTestComponents.tsx new file mode 100644 index 00000000000..588c9b5f3e5 --- /dev/null +++ b/packages/charts/src/components/PieChart/test/PieChartTestComponents.tsx @@ -0,0 +1,183 @@ +import { useState } from 'react'; +import { Text as RechartsText } from 'recharts'; +import { complexDataSet, simpleDataSet } from '../../../resources/DemoProps.js'; +import { PieChart } from '../index.js'; + +const dimension = { accessor: 'name' }; +const measure = { accessor: 'users' }; + +export function PieChartClickTest() { + const [clickCount, setClickCount] = useState(0); + const [lastPayload, setLastPayload] = useState(''); + const [legendClickCount, setLegendClickCount] = useState(0); + const [lastLegendDataKey, setLastLegendDataKey] = useState(''); + + return ( + <> + {clickCount} + {lastPayload} + {legendClickCount} + {lastLegendDataKey} + { + setClickCount((c) => c + 1); + setLastPayload(JSON.stringify(e.detail?.payload?.payload ?? e.detail?.payload)); + }} + onLegendClick={(e) => { + setLegendClickCount((c) => c + 1); + setLastLegendDataKey(e.detail?.dataKey || ''); + }} + noAnimation + /> + + ); +} + +export function PieChartLegendConfigTest() { + return ( + {value}, + }, + }} + /> + ); +} + +const CustomDataLabel = (props: any) => CustomLabel; + +export function PieChartCustomLabelTest() { + return ( + }} + /> + ); +} + +export function PieChartSectorFocusTest() { + const [clickCount, setClickCount] = useState(0); + const [lastClickIndex, setLastClickIndex] = useState(-1); + + return ( + <> + + {clickCount} + {lastClickIndex} + { + setClickCount((c) => c + 1); + setLastClickIndex(e.detail?.dataIndex ?? -1); + }} + noAnimation + /> + + ); +} + +export function PieChartSectorFocusActiveTest() { + const [activeSegment, setActiveSegment] = useState(2); + + return ( + <> + + {activeSegment} + { + setActiveSegment(e.detail?.dataIndex ?? 0); + }} + noAnimation + /> + + ); +} + +export function PieChartSectorFocusOutOfBoundsTest() { + return ( + <> + + + + ); +} + +export function PieChartSectorFocusDatasetShrinkTest() { + const [ds, setDs] = useState(simpleDataSet); + + return ( + <> + + + + + ); +} + +export function PieChartSectorFocusEmptyTest() { + return ( + <> + + + + ); +} + +export function PieChartSectorFocusHandlersTest() { + const [blurCount, setBlurCount] = useState(0); + const [focusCount, setFocusCount] = useState(0); + const [keyDownCaptureCount, setKeyDownCaptureCount] = useState(0); + + return ( + <> + + {blurCount} + {focusCount} + {keyDownCaptureCount} + + setBlurCount((c) => c + 1)} + onFocus={() => setFocusCount((c) => c + 1)} + onKeyDownCapture={() => setKeyDownCaptureCount((c) => c + 1)} + noAnimation + /> + + ); +} diff --git a/packages/charts/src/components/RadarChart/RadarChart.cy.tsx b/packages/charts/src/components/RadarChart/RadarChart.cy.tsx deleted file mode 100644 index 841278dae49..00000000000 --- a/packages/charts/src/components/RadarChart/RadarChart.cy.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { complexDataSet } from '../../resources/DemoProps.js'; -import { RadarChart } from './index.js'; -import { cypressPassThroughTestsFactory, testChartLegendConfig } from '@/cypress/support/utils'; - -const dimensions = [ - { - accessor: 'name', - interval: 0, - }, -]; -const measures = [ - { - accessor: 'users', - label: 'Users', - formatter: (val: number) => val.toLocaleString('en'), - }, - { - accessor: 'sessions', - label: 'Active Sessions', - formatter: (val) => `${val} sessions`, - hideDataLabel: true, - }, - { - accessor: 'volume', - label: 'Vol.', - }, -]; - -describe('RadarChart', () => { - it('Basic', () => { - cy.mount(); - cy.get('.recharts-responsive-container').should('be.visible'); - cy.get('.recharts-radar').should('have.length', 3); - cy.get('.recharts-radar-polygon').should('have.length', 3); - }); - - it('click handlers', () => { - const onClick = cy.spy().as('onClick'); - const onLegendClick = cy.spy().as('onLegendClick'); - cy.mount( - , - ); - - cy.contains('January').click(); - cy.get('@onClick').should('have.been.calledOnce'); - cy.get('[name="January"]').eq(0).click({ force: true }); - cy.get('@onClick').should('have.been.calledTwice'); - - cy.contains('Users').click(); - cy.get('@onLegendClick').should( - 'have.been.calledWith', - Cypress.sinon.match({ - detail: Cypress.sinon.match({ - dataKey: 'users', - }), - }), - ); - }); - - it('Loading Placeholder', () => { - cy.mount(); - cy.get('.recharts-radar').should('not.exist'); - cy.contains('Loading...').should('exist'); - }); - - testChartLegendConfig(RadarChart, { dataset: complexDataSet, dimensions, measures }); - - cypressPassThroughTestsFactory(RadarChart, { dimensions: [], measures: [] }); -}); diff --git a/packages/charts/src/components/RadarChart/test/RadarChart.spec.tsx b/packages/charts/src/components/RadarChart/test/RadarChart.spec.tsx new file mode 100644 index 00000000000..3f4a73765e8 --- /dev/null +++ b/packages/charts/src/components/RadarChart/test/RadarChart.spec.tsx @@ -0,0 +1,83 @@ +import { expect, test } from '../../../../../../playwright/fixtures/main-fixtures.js'; +import { complexDataSet } from '../../../resources/DemoProps.js'; +import { testLoadingStates, testPassThroughProps } from '../../../test-utils/sharedTests.js'; +import { RadarChart } from '../index.js'; +import { + RadarChartClickTest, + RadarChartDataPointClickTest, + RadarChartLegendConfigTest, +} from './RadarChartTestComponents.js'; + +test.describe('RadarChart', () => { + test('Basic', async ({ mount, page }) => { + await mount( + , + ); + await expect(page.locator('.recharts-responsive-container')).toBeVisible(); + await expect(page.locator('.recharts-radar')).toHaveCount(3); + await expect(page.locator('.recharts-radar-polygon')).toHaveCount(3); + }); + + test('click handlers', async ({ mount, page }) => { + await mount(); + + await page.getByText('January').click(); + await expect(page.getByTestId('click-count')).toHaveText('1'); + + await page.locator('[name="January"]').first().click({ force: true }); + await expect(page.getByTestId('click-count')).toHaveText('2'); + + await page.locator('.recharts-legend-item-text').filter({ hasText: 'Users' }).click(); + await expect(page.getByTestId('legend-click-count')).toHaveText('1'); + await expect(page.getByTestId('last-legend-datakey')).toHaveText('users'); + }); + + testLoadingStates( + RadarChart, + { + dataset: complexDataSet, + dimensions: [{ accessor: 'name', interval: 0 }], + measures: [{ accessor: 'users', label: 'Users' }], + }, + { dimensions: [], measures: [] }, + '.recharts-radar', + ); + + test('legendConfig', async ({ mount, page }) => { + await mount(); + await expect(page.getByTestId('catval').first()).toBeVisible(); + }); + + testPassThroughProps(RadarChart, { dimensions: [], measures: [] }); + + test('onDataPointClick', async ({ mount, page }) => { + await mount(); + + // RadarChart fires onDataPointClick via activeDot on . + // Hover the chart to activate a data index, making the active dot appear. + const wrapper = page.locator('.recharts-wrapper'); + const box = await wrapper.boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + box.height * 0.35); + const activeDot = page.locator('.recharts-active-dot'); + await expect(activeDot.first()).toBeAttached(); + // Use dispatchEvent because Playwright clicks on SVG circles don't reliably trigger React synthetic events in recharts + await page.evaluate(() => { + const dot = + document.querySelector('.recharts-active-dot circle') || document.querySelector('.recharts-active-dot'); + if (dot) { + dot.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + } + }); + await expect(page.getByTestId('dp-click-count')).toHaveText('1'); + await expect(page.getByTestId('dp-last-datakey')).not.toHaveText(''); + await expect(page.getByTestId('dp-last-payload')).not.toHaveText(''); + }); +}); diff --git a/packages/charts/src/components/RadarChart/test/RadarChartTestComponents.tsx b/packages/charts/src/components/RadarChart/test/RadarChartTestComponents.tsx new file mode 100644 index 00000000000..f4bf8192c55 --- /dev/null +++ b/packages/charts/src/components/RadarChart/test/RadarChartTestComponents.tsx @@ -0,0 +1,30 @@ +import { complexDataSet } from '../../../resources/DemoProps.js'; +import { + createClickTestComponent, + createDataPointClickTestComponent, + createLegendConfigTestComponent, +} from '../../../test-utils/componentFactories.js'; +import { RadarChart } from '../index.js'; + +const dimensions = [{ accessor: 'name', interval: 0 }]; + +const measures = [ + { accessor: 'users', label: 'Users', formatter: (val: number) => val.toLocaleString('en') }, + { + accessor: 'sessions', + label: 'Active Sessions', + formatter: (val: number) => `${val} sessions`, + hideDataLabel: true, + }, + { accessor: 'volume', label: 'Vol.' }, +]; + +const baseProps = { dataset: complexDataSet, dimensions, measures }; + +export const RadarChartClickTest = createClickTestComponent(RadarChart, baseProps, { + trackPayload: false, +}); + +export const RadarChartLegendConfigTest = createLegendConfigTestComponent(RadarChart, baseProps); + +export const RadarChartDataPointClickTest = createDataPointClickTestComponent(RadarChart, baseProps); diff --git a/packages/charts/src/components/RadialChart/RadialChart.cy.tsx b/packages/charts/src/components/RadialChart/RadialChart.cy.tsx deleted file mode 100644 index db256c52b1a..00000000000 --- a/packages/charts/src/components/RadialChart/RadialChart.cy.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { RadialChart } from './index.js'; -import { cypressPassThroughTestsFactory } from '@/cypress/support/utils'; - -describe('RadialChart', () => { - it('Basic', () => { - cy.mount(); - cy.get('.recharts-responsive-container').should('be.visible'); - cy.get('.recharts-area').should('have.length', 1); - cy.get('.recharts-radial-bar-sectors').should('have.length', 1); - cy.findByText('67%').should('be.visible'); - }); - - it('click handlers', () => { - const onClick = cy.spy().as('onClick'); - cy.mount(); - - cy.get('.recharts-radial-bar-sector').click(); - cy.get('@onClick') - .should('have.been.calledOnce') - .and( - 'have.been.calledWith', - Cypress.sinon.match({ - detail: Cypress.sinon.match({ - payload: { value: 67 }, - }), - }), - ); - }); - - cypressPassThroughTestsFactory(RadialChart, { value: 67, displayValue: '67%' }); -}); diff --git a/packages/charts/src/components/RadialChart/test/RadialChart.spec.tsx b/packages/charts/src/components/RadialChart/test/RadialChart.spec.tsx new file mode 100644 index 00000000000..d6a6f96ce03 --- /dev/null +++ b/packages/charts/src/components/RadialChart/test/RadialChart.spec.tsx @@ -0,0 +1,27 @@ +import { expect, test } from '../../../../../../playwright/fixtures/main-fixtures.js'; +import { testLoadingStates, testPassThroughProps } from '../../../test-utils/sharedTests.js'; +import { RadialChart } from '../index.js'; +import { RadialChartClickTest } from './RadialChartTestComponents.js'; + +test.describe('RadialChart', () => { + test('Basic', async ({ mount, page }) => { + await mount(); + await expect(page.locator('.recharts-responsive-container')).toBeVisible(); + await expect(page.locator('.recharts-area')).toHaveCount(1); + await expect(page.locator('.recharts-radial-bar-sectors')).toHaveCount(1); + await expect(page.getByText('67%')).toBeVisible(); + }); + + test('click handlers', async ({ mount, page }) => { + await mount(); + const sector = page.locator('.recharts-radial-bar-sector'); + await expect(sector).toBeVisible(); + await sector.dispatchEvent('click'); + await expect(page.getByTestId('click-count')).toHaveText('1'); + await expect(page.getByTestId('last-payload-value')).toHaveText('67'); + }); + + testPassThroughProps(RadialChart, {}); + + testLoadingStates(RadialChart, { value: 67, displayValue: '67%' }, {}, '.recharts-radial-bar-sectors'); +}); diff --git a/packages/charts/src/components/RadialChart/test/RadialChartTestComponents.tsx b/packages/charts/src/components/RadialChart/test/RadialChartTestComponents.tsx new file mode 100644 index 00000000000..0f8c878cb0f --- /dev/null +++ b/packages/charts/src/components/RadialChart/test/RadialChartTestComponents.tsx @@ -0,0 +1,23 @@ +import { useState } from 'react'; +import { RadialChart } from '../index.js'; + +export function RadialChartClickTest() { + const [clickCount, setClickCount] = useState(0); + const [lastPayloadValue, setLastPayloadValue] = useState(null); + + return ( + <> + {clickCount} + {lastPayloadValue ?? ''} + { + setClickCount((c) => c + 1); + setLastPayloadValue(e.detail?.payload?.value ?? null); + }} + /> + + ); +} diff --git a/packages/charts/src/components/ScatterChart/ScatterChart.cy.tsx b/packages/charts/src/components/ScatterChart/ScatterChart.cy.tsx deleted file mode 100644 index b243c6d184f..00000000000 --- a/packages/charts/src/components/ScatterChart/ScatterChart.cy.tsx +++ /dev/null @@ -1,283 +0,0 @@ -import { complexDataSet, scatterComplexDataSet } from '../../resources/DemoProps.js'; -import { ScatterChart } from './index.js'; -import { cypressPassThroughTestsFactory, testChartLegendConfig } from '@/cypress/support/utils'; - -const measures = [ - { - accessor: 'users', - label: 'Number', - axis: 'x' as const, - }, - { - accessor: 'sessions', - label: 'Sessions', - axis: 'y' as const, - }, - { - accessor: 'volume', - axis: 'z' as const, - }, -]; - -function activePointLabelShould(containerSelector: string, ...matchers: string[]) { - cy.get(containerSelector) - .should('have.attr', 'aria-activedescendant') - .then((activeId) => { - let chain = cy.get(`#${CSS.escape(activeId as string)}`).should('have.attr', 'aria-label'); - for (const m of matchers) { - chain = chain.and('contain', m); - } - }); -} - -describe('ScatterChart', () => { - it('Basic', () => { - cy.mount(); - cy.get('.recharts-responsive-container').should('be.visible'); - cy.get('.recharts-scatter').should('have.length', 2); - cy.get('.recharts-symbols[name="APJ"]').should('have.length', 12); - }); - - it('click handlers', () => { - const onClick = cy.spy().as('onClick'); - const onLegendClick = cy.spy().as('onLegendClick'); - cy.mount( - , - ); - - cy.get('[name="Users"]').eq(0).click(); - cy.get('@onClick') - .should('have.been.calledOnce') - .and( - 'have.been.calledWith', - Cypress.sinon.match({ - detail: Cypress.sinon.match({ - payload: scatterComplexDataSet[0].data[0], - }), - }), - ); - - cy.contains('Users').click(); - cy.get('@onLegendClick').should( - 'have.been.calledWith', - Cypress.sinon.match({ - detail: Cypress.sinon.match({ - value: 'Users', - }), - }), - ); - }); - - it('Loading Placeholder', () => { - cy.mount(); - cy.get('.recharts-scatter').should('not.exist'); - cy.findByText('Loading...').should('exist'); - }); - - it('accessibilityLayer: keyboard navigation, Enter, blur/re-focus, consumer handlers', () => { - const chartConfig = { accessibilityLayer: true }; - const containerSelector = '[aria-roledescription="chart"]'; - const singleDataset = [ - { - label: 'Series A', - data: [ - { users: 100, sessions: 200, volume: 300 }, - { users: 50, sessions: 150, volume: 250 }, - { users: 200, sessions: 400, volume: 500 }, - ], - }, - ]; - - const onDataPointClick = cy.spy().as('onDataPointClick'); - const onBlur = cy.spy().as('onBlur'); - const onFocus = cy.spy().as('onFocus'); - const onKeyDownCapture = cy.spy().as('onKeyDownCapture'); - - cy.mount( - <> - - - - , - ); - cy.get('[role="img"][aria-label]').should('have.length', 3); - - cy.findByText('before').focus(); - - // container focused, first scatter "active" - cy.realPress('Tab'); - cy.focused() - .should('have.attr', 'tabindex', '0') - .should('have.attr', 'role', 'application') - .should('have.attr', 'aria-roledescription', 'chart'); - cy.get('@onFocus').should('have.been.calledOnce'); - activePointLabelShould(containerSelector, 'Number: 50'); - cy.get('[data-point-focused]').should('have.length', 1); - - // 2nd scatter "active" - forward by X - cy.realPress('ArrowRight'); - activePointLabelShould(containerSelector, 'Number: 100'); - cy.get('@onKeyDownCapture').should('have.been.called'); - - // 3rd scatter "active" - cy.realPress('ArrowRight'); - activePointLabelShould(containerSelector, 'Number: 200'); - - // 3rd scatter "active" -> last one - cy.realPress('ArrowRight'); - activePointLabelShould(containerSelector, 'Number: 200'); - - // 2nd scatter "active" - cy.realPress('ArrowLeft'); - activePointLabelShould(containerSelector, 'Number: 100'); - - cy.realPress('Enter'); - cy.get('@onDataPointClick').should( - 'have.been.calledWith', - Cypress.sinon.match({ - detail: Cypress.sinon.match({ payload: singleDataset[0].data[0] }), - }), - ); - - cy.get('[role="img"][aria-label]').eq(2).click(); - cy.get('@onDataPointClick').should('have.been.calledTwice'); - activePointLabelShould(containerSelector, 'Number: 200'); - - // Leave chart - cy.realPress('Tab'); - cy.focused().should('contain.text', 'after'); - cy.get(containerSelector).should('not.have.attr', 'aria-activedescendant'); - cy.get('[data-point-focused]').should('not.exist'); - cy.get('@onBlur').should('have.been.called'); - - // Reenter chart - cy.realPress(['Shift', 'Tab']); - cy.focused().should('have.attr', 'aria-roledescription', 'chart'); - activePointLabelShould(containerSelector, 'Number: 200'); - - cy.realPress('ArrowLeft'); - activePointLabelShould(containerSelector, 'Number: 100'); - cy.realPress('ArrowLeft'); - activePointLabelShould(containerSelector, 'Number: 50'); - cy.realPress('ArrowLeft'); - activePointLabelShould(containerSelector, 'Number: 50'); - }); - - it('accessibilityLayer: multi-dataset points sorted by X then datasetIndex', () => { - const chartConfig = { accessibilityLayer: true }; - const containerSelector = '[aria-roledescription="chart"]'; - const multiDataset = [ - { - label: 'Alpha', - data: [{ users: 30, sessions: 100, volume: 200 }], - }, - { - label: 'Beta', - data: [ - { users: 30, sessions: 150, volume: 250 }, - { users: 60, sessions: 300, volume: 400 }, - ], - }, - ]; - - cy.mount( - <> - - - , - ); - - cy.get('[role="img"][aria-label]').should('have.length', 3); - cy.findByText('before').focus(); - cy.realPress('Tab'); - - // Same X value (30): sorted by dataset index, Alpha (0) before Beta (1) - activePointLabelShould(containerSelector, 'Alpha'); - cy.realPress('ArrowRight'); - activePointLabelShould(containerSelector, 'Beta', 'Number: 30'); - cy.realPress('ArrowRight'); - activePointLabelShould(containerSelector, 'Beta', 'Number: 60'); - }); - - it('accessibilityLayer: multiple charts', () => { - const chartConfig = { accessibilityLayer: true }; - const singleDataset = [ - { - label: 'Series A', - data: [ - { users: 100, sessions: 200, volume: 300 }, - { users: 50, sessions: 150, volume: 250 }, - ], - }, - ]; - - cy.mount( - <> - - - - - , - ); - - cy.get('[role="img"][id]').then(($els) => { - const ids = [...$els].map((el) => el.id); - expect(new Set(ids).size).to.equal(ids.length); - }); - - cy.findByText('before').focus(); - cy.realPress('Tab'); - cy.focused().should('have.attr', 'aria-roledescription', 'chart1'); - cy.realPress('ArrowRight'); - activePointLabelShould('[aria-roledescription="chart1"]:first', 'Number: 100'); - - cy.realPress('Tab'); - cy.focused().should('have.attr', 'aria-roledescription', 'chart2'); - cy.realPress('ArrowRight'); - activePointLabelShould('[aria-roledescription="chart2"]:first', 'Number: 100'); - - cy.realPress('Tab'); - cy.focused().should('contain.text', 'after'); - }); - - [false, true].forEach((accessibilityLayer) => { - it(`empty dataset (accessibilityLayer: ${accessibilityLayer})`, () => { - cy.mount(); - cy.get('.recharts-scatter').should('not.exist'); - cy.findByText('Loading...').should('exist'); - if (accessibilityLayer) { - cy.get('[aria-roledescription="chart"]') - .should('have.attr', 'tabindex', '0') - .should('not.have.attr', 'role', 'application'); - } - }); - }); - - testChartLegendConfig(ScatterChart, { dataset: complexDataSet, measures }); - - cypressPassThroughTestsFactory(ScatterChart, { measures: [] }); -}); diff --git a/packages/charts/src/components/ScatterChart/test/ScatterChart.spec.tsx b/packages/charts/src/components/ScatterChart/test/ScatterChart.spec.tsx new file mode 100644 index 00000000000..74a123480ab --- /dev/null +++ b/packages/charts/src/components/ScatterChart/test/ScatterChart.spec.tsx @@ -0,0 +1,172 @@ +import { expect, test } from '../../../../../../playwright/fixtures/main-fixtures.js'; +import type { Page } from '@playwright/test'; +import { scatterComplexDataSet } from '../../../resources/DemoProps.js'; +import { testLoadingStates, testPassThroughProps } from '../../../test-utils/sharedTests.js'; +import { ScatterChart } from '../index.js'; +import { + ScatterChartAccessibilityTest, + ScatterChartClickTest, + ScatterChartEmptyAccessibilityTest, + ScatterChartEmptyTest, + ScatterChartLegendConfigTest, + ScatterChartMultiDatasetAccessibilityTest, + ScatterChartMultipleChartsTest, +} from './ScatterChartTestComponents.js'; + +const measures = [ + { accessor: 'users', label: 'Number', axis: 'x' as const }, + { accessor: 'sessions', label: 'Sessions', axis: 'y' as const }, + { accessor: 'volume', axis: 'z' as const }, +]; + +async function expectActivePointLabel(page: Page, containerSelector: string, ...matchers: string[]) { + const container = page.locator(containerSelector).first(); + const activeId = await container.getAttribute('aria-activedescendant'); + expect(activeId).toBeTruthy(); + const activeElement = page.locator(`[id="${activeId}"]`); + const label = await activeElement.getAttribute('aria-label'); + for (const m of matchers) { + expect(label).toContain(m); + } +} + +test.describe('ScatterChart', () => { + test('Basic', async ({ mount, page }) => { + await mount(); + await expect(page.locator('.recharts-responsive-container')).toBeVisible(); + await expect(page.locator('.recharts-scatter')).toHaveCount(2); + await expect(page.locator('.recharts-symbols[name="APJ"]')).toHaveCount(12); + }); + + test('click handlers', async ({ mount, page }) => { + await mount(); + + await page.locator('[name="Users"]').first().click(); + await expect(page.getByTestId('click-count')).toHaveText('1'); + await expect(page.getByTestId('last-payload')).toHaveText(JSON.stringify(scatterComplexDataSet[0].data[0])); + + await page.locator('.recharts-legend-item-text').filter({ hasText: 'Users' }).click(); + await expect(page.getByTestId('legend-click-count')).toHaveText('1'); + await expect(page.getByTestId('last-legend-value')).toHaveText('Users'); + }); + + testLoadingStates(ScatterChart, { dataset: scatterComplexDataSet, measures }, { measures: [] }, '.recharts-scatter'); + + test('accessibilityLayer: keyboard navigation, Enter, blur/re-focus, consumer handlers', async ({ mount, page }) => { + await mount(); + const containerSelector = '[aria-roledescription="chart"]'; + + await expect(page.locator('[role="img"][aria-label]')).toHaveCount(3); + + // Focus "before" button then Tab into chart container + await page.getByText('before').focus(); + await page.keyboard.press('Tab'); + await expect(page.getByTestId('focus-count')).toHaveText('1'); + + // First point active (sorted by X: 50 is smallest) + await expectActivePointLabel(page, containerSelector, 'Number: 50'); + + // ArrowRight -> 2nd point (X=100) + await page.keyboard.press('ArrowRight'); + await expectActivePointLabel(page, containerSelector, 'Number: 100'); + + // ArrowRight -> 3rd point (X=200) + await page.keyboard.press('ArrowRight'); + await expectActivePointLabel(page, containerSelector, 'Number: 200'); + + // ArrowRight at last -> stays at last + await page.keyboard.press('ArrowRight'); + await expectActivePointLabel(page, containerSelector, 'Number: 200'); + + // ArrowLeft -> back to 2nd (X=100) + await page.keyboard.press('ArrowLeft'); + await expectActivePointLabel(page, containerSelector, 'Number: 100'); + + // Enter triggers onDataPointClick + await page.keyboard.press('Enter'); + await expect(page.getByTestId('click-count')).toHaveText('1'); + const lastPayload = await page.getByTestId('last-payload').textContent(); + expect(JSON.parse(lastPayload)).toEqual({ users: 100, sessions: 200, volume: 300 }); + + // Click on 3rd point directly + await page.locator('[role="img"][aria-label]').nth(2).click(); + await expect(page.getByTestId('click-count')).toHaveText('2'); + await expectActivePointLabel(page, containerSelector, 'Number: 200'); + + // Blur chart by focusing after button + await page.getByText('after').focus(); + await expect(page.locator(containerSelector)).not.toHaveAttribute('aria-activedescendant'); + await expect(page.locator('[data-point-focused]')).toHaveCount(0); + await expect(page.getByTestId('blur-count')).not.toHaveText('0'); + + // Re-focus chart via Tab from after button (Shift+Tab) + await page.keyboard.press('Shift+Tab'); + await expectActivePointLabel(page, containerSelector, 'Number: 200'); + + // Navigate back + await page.keyboard.press('ArrowLeft'); + await expectActivePointLabel(page, containerSelector, 'Number: 100'); + await page.keyboard.press('ArrowLeft'); + await expectActivePointLabel(page, containerSelector, 'Number: 50'); + // At first -> stays + await page.keyboard.press('ArrowLeft'); + await expectActivePointLabel(page, containerSelector, 'Number: 50'); + }); + + test('accessibilityLayer: multi-dataset points sorted by X then datasetIndex', async ({ mount, page }) => { + await mount(); + const containerSelector = '[aria-roledescription="chart"]'; + + await expect(page.locator('[role="img"][aria-label]')).toHaveCount(3); + await page.getByText('before').focus(); + await page.keyboard.press('Tab'); + + // Same X value (30): sorted by dataset index, Alpha (0) before Beta (1) + await expectActivePointLabel(page, containerSelector, 'Alpha'); + await page.keyboard.press('ArrowRight'); + await expectActivePointLabel(page, containerSelector, 'Beta', 'Number: 30'); + await page.keyboard.press('ArrowRight'); + await expectActivePointLabel(page, containerSelector, 'Beta', 'Number: 60'); + }); + + test('accessibilityLayer: multiple charts', async ({ mount, page }) => { + await mount(); + + // Verify unique IDs across all points + const ids = await page.locator('[role="img"][id]').evaluateAll((els) => els.map((el) => el.id)); + expect(new Set(ids).size).toBe(ids.length); + + // Focus first chart via Tab from before button + await page.getByText('before').focus(); + await page.keyboard.press('Tab'); + await page.keyboard.press('ArrowRight'); + await expectActivePointLabel(page, '[aria-roledescription="chart1"]', 'Number: 100'); + + // Tab to second chart + await page.keyboard.press('Tab'); + await page.keyboard.press('ArrowRight'); + await expectActivePointLabel(page, '[aria-roledescription="chart2"]', 'Number: 100'); + }); + + test('empty dataset (accessibilityLayer: false)', async ({ mount, page }) => { + await mount(); + await expect(page.locator('.recharts-scatter')).not.toBeAttached(); + await expect(page.getByText('Loading...')).toBeAttached(); + }); + + test('empty dataset (accessibilityLayer: true)', async ({ mount, page }) => { + await mount(); + await expect(page.locator('.recharts-scatter')).not.toBeAttached(); + await expect(page.getByText('Loading...')).toBeAttached(); + const chart = page.locator('[aria-roledescription="chart"]'); + await expect(chart).toHaveAttribute('tabindex', '0'); + await expect(chart).not.toHaveAttribute('role', 'application'); + }); + + test('legendConfig', async ({ mount, page }) => { + await mount(); + await expect(page.getByTestId('catval').first()).toBeVisible(); + }); + + testPassThroughProps(ScatterChart, { measures: [] }); +}); diff --git a/packages/charts/src/components/ScatterChart/test/ScatterChartTestComponents.tsx b/packages/charts/src/components/ScatterChart/test/ScatterChartTestComponents.tsx new file mode 100644 index 00000000000..a4aebf6b17c --- /dev/null +++ b/packages/charts/src/components/ScatterChart/test/ScatterChartTestComponents.tsx @@ -0,0 +1,163 @@ +import { useState } from 'react'; +import { scatterComplexDataSet, complexDataSet } from '../../../resources/DemoProps.js'; +import { ScatterChart } from '../index.js'; + +const measures = [ + { accessor: 'users', label: 'Number', axis: 'x' as const }, + { accessor: 'sessions', label: 'Sessions', axis: 'y' as const }, + { accessor: 'volume', axis: 'z' as const }, +]; + +const scatterAccessibilitySingleDataset = [ + { + label: 'Series A', + data: [ + { users: 100, sessions: 200, volume: 300 }, + { users: 50, sessions: 150, volume: 250 }, + { users: 200, sessions: 400, volume: 500 }, + ], + }, +]; + +export function ScatterChartClickTest() { + const [clickCount, setClickCount] = useState(0); + const [lastPayload, setLastPayload] = useState(''); + const [legendClickCount, setLegendClickCount] = useState(0); + const [lastLegendValue, setLastLegendValue] = useState(''); + + return ( + <> + {clickCount} + {lastPayload} + {legendClickCount} + {lastLegendValue} + { + setClickCount((c) => c + 1); + setLastPayload(JSON.stringify(e.detail?.payload)); + }} + onLegendClick={(e) => { + setLegendClickCount((c) => c + 1); + setLastLegendValue(e.detail?.value || ''); + }} + /> + + ); +} + +export function ScatterChartAccessibilityTest() { + const [clickCount, setClickCount] = useState(0); + const [lastPayload, setLastPayload] = useState(''); + const [blurCount, setBlurCount] = useState(0); + const [focusCount, setFocusCount] = useState(0); + const [keyDownCount, setKeyDownCount] = useState(0); + + return ( + <> + + {clickCount} + {lastPayload} + {blurCount} + {focusCount} + {keyDownCount} + { + setClickCount((c) => c + 1); + setLastPayload(JSON.stringify(e.detail?.payload)); + }} + onBlur={() => { + setBlurCount((c) => c + 1); + }} + onFocus={() => { + setFocusCount((c) => c + 1); + }} + onKeyDownCapture={() => { + setKeyDownCount((c) => c + 1); + }} + /> + + + ); +} + +export function ScatterChartMultiDatasetAccessibilityTest() { + const multiDataset = [ + { + label: 'Alpha', + data: [{ users: 30, sessions: 100, volume: 200 }], + }, + { + label: 'Beta', + data: [ + { users: 30, sessions: 150, volume: 250 }, + { users: 60, sessions: 300, volume: 400 }, + ], + }, + ]; + + return ( + <> + + + + ); +} + +export function ScatterChartMultipleChartsTest() { + const singleDataset = [ + { + label: 'Series A', + data: [ + { users: 100, sessions: 200, volume: 300 }, + { users: 50, sessions: 150, volume: 250 }, + ], + }, + ]; + + return ( + <> + + + + + + ); +} + +export function ScatterChartEmptyTest() { + return ; +} + +export function ScatterChartEmptyAccessibilityTest() { + return ; +} + +export function ScatterChartLegendConfigTest() { + return ( + {value}🐱, + }, + }} + /> + ); +} diff --git a/packages/charts/src/components/TimelineChart/TimeLineChart.cy.tsx b/packages/charts/src/components/TimelineChart/TimeLineChart.cy.tsx deleted file mode 100644 index e243c033513..00000000000 --- a/packages/charts/src/components/TimelineChart/TimeLineChart.cy.tsx +++ /dev/null @@ -1,340 +0,0 @@ -import { useReducer } from 'react'; -import { TimelineChart, TimelineChartAnnotation } from '../..'; -import { TimingFigure } from './examples/Annotations.js'; -import { dummyDataSet, illegalConnDataset, illegalConnDataset2, schedulingEDFData } from './examples/Dataset.js'; -import { - HOVER_OPACITY, - MOUSE_CURSOR_AUTO, - MOUSE_CURSOR_GRAB, - MOUSE_CURSOR_GRABBING, - NORMAL_OPACITY, -} from './util/constants.js'; - -describe('TimeLineChart', () => { - it('renders PlaceHolder without dataset', () => { - cy.mount(); - cy.get('svg').should('have.length', 1).should('be.visible'); - }); - - it('renders TimelineChart with dataset', () => { - cy.mount(); - cy.findByTestId('tlc').should('be.visible').should('have.prop', 'tagName').should('eq', 'DIV'); - }); - - it('calls the valueFormat callback & renders labels', () => { - cy.mount( - `${Math.round(x)}-formatted`} - />, - ); - for (let i = 0; i <= 150; i += 30) { - cy.findByText(`${i}-formatted`).should('be.visible'); - } - }); - - it('render connection layer', () => { - const TestComp = () => { - const [showConn, toggleShowConn] = useReducer((prev) => !prev, undefined); - return ( - <> - - - - ); - }; - cy.mount(); - cy.get('[data-component-name="TimelineChartConnectionLayer"]').should('not.exist'); - cy.findByText('Toggle Connection').click(); - cy.get('[data-component-name="TimelineChartConnectionLayer"]').should('be.visible'); - }); - - it('render annotation layer ', () => { - const TestComp = () => { - const [showAnn, toggleShowAnn] = useReducer((prev) => !prev, undefined); - const [renderAnn, toggleAnn] = useReducer((prev) => !prev, undefined); - return ( - <> - - - - } - /> - } - /> - } - /> - - ) - } - /> - - ); - }; - cy.mount(); - cy.get('[data-component-name="TimelineChartAnnotationLayer"]').should('not.exist'); - cy.findByText('Toggle Annotations').click(); - cy.get('[data-component-name="TimelineChartAnnotationLayer"]').should('not.exist'); - cy.findByText('Toggle Annotations visibility').click(); - cy.get('[data-component-name="TimelineChartAnnotationLayer"]').should('be.visible'); - cy.findByText('Toggle Annotations').click(); - cy.get('[data-component-name="TimelineChartAnnotationLayer"]').children().should('not.exist'); - }); - - it('throws InvalidDiscreteLabelError', (done) => { - cy.on('uncaught:exception', (err) => { - console.dir(err.name); - if (err.name === 'InvalidDiscreteLabelError') { - done(); - } - }); - cy.mount( - , - ); - - cy.wait(1000).then(() => { - done(new Error('Should throw InvalidDiscreteLabelError')); - }); - }); - - it('throws IllegalConnectionError (1)', (done) => { - cy.on('uncaught:exception', (err) => { - console.dir(err.name); - if (err.name === 'IllegalConnectionError') { - done(); - } - }); - cy.mount(); - cy.wait(1000).then(() => { - done(new Error('Should throw IllegalConnectionError')); - }); - }); - it('throws IllegalConnectionError (2)', (done) => { - cy.on('uncaught:exception', (err) => { - console.dir(err.name); - if (err.name === 'IllegalConnectionError') { - done(); - } - }); - cy.mount(); - cy.wait(1000).then(() => { - done(new Error('Should throw IllegalConnectionError')); - }); - }); - - it('shows the right mouse cursor', () => { - cy.mount(); - cy.get('[data-component-name="TimelineChartBodyContainer"]') - .should('have.css', 'cursor', MOUSE_CURSOR_AUTO) - .trigger('mousedown') - .should('have.css', 'cursor', MOUSE_CURSOR_AUTO) - .trigger('mouseup') - .should('have.css', 'cursor', MOUSE_CURSOR_AUTO); - - cy.get('[data-component-name="TimelineChartBody"]') - .trigger('wheel', { deltaY: -1, bubbles: true }) - .should('have.css', 'cursor', MOUSE_CURSOR_GRAB) - .trigger('mousedown') - .should('have.css', 'cursor', MOUSE_CURSOR_GRABBING) - .trigger('mouseup') - .should('have.css', 'cursor', MOUSE_CURSOR_GRAB); - }); - - it('TimelineChartAnotation: postions itself correctly in the parent', () => { - cy.mount( - } />} - showAnnotation - />, - ); - cy.get('[data-component-name="TimelineChartAnnotation"]').should('have.css', 'inset-block-start', '40px'); - }); - - it('TimeLineChartRow: tooltip & opacity for milestones and tasks', () => { - cy.mount(); - - cy.get('[data-component-name="TimelineChartTask"]') - .first() - .should('have.css', 'opacity', `${NORMAL_OPACITY}`) - .trigger('mousemove') - .should('have.css', 'opacity', `${HOVER_OPACITY}`); - - cy.findByText('Item 1').should('be.visible'); - cy.get('[data-component-name="TimelineChartTask"]') - .first() - // React internally uses mouseout when `onMouseLeave` is used - .trigger('mouseout') - .should('have.css', 'opacity', `${NORMAL_OPACITY}`); - - cy.findByText('Item 1').should('not.exist'); - - cy.get('[data-component-name="TimelineChartMilestone"] > rect') - .should('have.css', 'opacity', `${NORMAL_OPACITY}`) - .trigger('mousemove') - .should('have.css', 'opacity', `${HOVER_OPACITY}`); - cy.findByText('Milestone 11').should('be.visible'); - cy.get('[data-component-name="TimelineChartMilestone"] > rect') - // React internally uses mouseout when `onMouseLeave` is used - .trigger('mouseout') - .should('have.css', 'opacity', `${NORMAL_OPACITY}`); - cy.findByText('Milestone 11').should('not.exist'); - - cy.mount(); - - cy.get('[data-component-name="TimelineChartTask"]') - .first() - .should('have.css', 'opacity', `${NORMAL_OPACITY}`) - .trigger('mousemove') - .should('have.css', 'opacity', `${HOVER_OPACITY}`); - - cy.findByText('Item 1').should('not.exist'); - cy.get('[data-component-name="TimelineChartTask"]') - .first() - // React internally uses mouseout when `onMouseLeave` is used - .trigger('mouseout') - .should('have.css', 'opacity', `${NORMAL_OPACITY}`); - - cy.get('[data-component-name="TimelineChartMilestone"] > rect') - .should('have.css', 'opacity', `${NORMAL_OPACITY}`) - .trigger('mousemove') - .should('have.css', 'opacity', `${HOVER_OPACITY}`); - cy.findByText('Milestone 11').should('not.exist'); - cy.get('[data-component-name="TimelineChartMilestone"] > rect') - // React internally uses mouseout when `onMouseLeave` is used - .trigger('mouseout') - .should('have.css', 'opacity', `${NORMAL_OPACITY}`); - }); - - it('TimelineChartBody: scales when the mouse wheel event happens', () => { - cy.mount(); - cy.findByText('150.0').should('be.visible'); - cy.get('[data-component-name="TimelineChartBody"]').trigger('wheel', { deltaY: -10, bubbles: true }); - - cy.findByText('109.1').should('be.visible'); - cy.findByText('150.0').should('not.be.visible'); - }); - - it('TimelineChartLayer', () => { - cy.mount( - } />} - showAnnotation - />, - ); - cy.get('[data-component-name="TimelineChartGridLayer"]') - .should('have.css', 'pointer-events', 'none') - .should('have.prop', 'tagName') - .should('eq', 'svg'); - cy.get('[data-component-name="TimelineChartConnectionLayer"]') - .should('have.css', 'pointer-events', 'none') - .should('have.prop', 'tagName') - .should('eq', 'svg'); - cy.get('[data-component-name="TimelineChartRowsLayer"]') - .should('have.css', 'pointer-events', 'none') - .should('have.prop', 'tagName') - .should('eq', 'svg'); - cy.get('[data-component-name="TimelineChartAnnotationLayer"]') - .should('have.css', 'pointer-events', 'none') - .should('have.prop', 'tagName') - .should('eq', 'DIV'); - }); - - it('TimelineChartHeaders: ColumnLabels', () => { - cy.mount( - `${Math.round(x)}`} - />, - ); - for (let i = 0; i <= 10; i += 2) { - cy.findByText(i).should('be.visible'); - } - cy.findByText('12').should('not.exist'); - cy.mount( - `${Math.round(x)}`} - isDiscrete - />, - ); - for (let i = 0; i <= 9; i++) { - cy.findByText(i).should('be.visible'); - } - cy.findByText('10').should('not.exist'); - cy.mount( - `${Math.round(x)}`} - isDiscrete - discreteLabels={['one', 'two', ...new Array(8).fill('label')]} - />, - ); - cy.findAllByText('label').should('have.length', 8).should('be.visible'); - cy.findByText('one').should('be.visible'); - cy.findByText('two').should('be.visible'); - }); - - it('rowHeight', () => { - cy.mount(); - cy.get('[data-component-name="TimelineChartRow"]').should('have.attr', 'height', '200'); - }); - - it('unit and titles', () => { - cy.mount( - , - ); - cy.findByText('Activities').should('not.exist'); - cy.findByText('Duration').should('not.exist'); - - cy.findByText('columnTitle (unit)').should('be.visible'); - cy.findByText('rowTitle').should('be.visible'); - }); - - it('start', () => { - cy.mount( - `${Math.round(x)}`} - />, - ); - for (let i = 5; i <= 15; i += 2) { - cy.findByText(i).should('be.visible'); - } - cy.findByText('17').should('not.exist'); - }); -}); diff --git a/packages/charts/src/components/TimelineChart/test/TimelineChart.spec.tsx b/packages/charts/src/components/TimelineChart/test/TimelineChart.spec.tsx new file mode 100644 index 00000000000..230c6780a22 --- /dev/null +++ b/packages/charts/src/components/TimelineChart/test/TimelineChart.spec.tsx @@ -0,0 +1,248 @@ +import { expect, test } from '../../../../../../playwright/fixtures/main-fixtures.js'; +import { TimelineChart } from '../index.js'; +import { + HOVER_OPACITY, + MOUSE_CURSOR_AUTO, + MOUSE_CURSOR_GRAB, + MOUSE_CURSOR_GRABBING, + NORMAL_OPACITY, +} from '../util/constants.js'; +import { + AnnotationLayerToggle, + AnnotationPositionTest, + ColumnLabelsContinuousTest, + ColumnLabelsDiscreteTest, + ColumnLabelsDiscreteWithLabelsTest, + ConnectionLayerToggle, + IllegalConnectionTest1, + IllegalConnectionTest2, + InvalidDiscreteLabelTest, + LayerStructureTest, + MouseCursorTest, + RowHeightTest, + StartPropTest, + TooltipHiddenTest, + TooltipOpacityTest, + UnitAndTitlesTest, + ValueFormatTest, +} from './TimelineChartTestComponents.js'; + +// Tests dropped during the Cypress → Playwright migration: +// - "TimelineChartBody: scales when the mouse wheel event happens": the wheel +// event dispatched via Playwright doesn't drive the body's zoom/scaling +// logic the same way Cypress' synthesized wheel did, so the rescaled label +// ("150.0" → "109.1") never updated. The mouse-cursor test still exercises +// the wheel-handler path enough to verify cursor state. + +test.describe('TimelineChart', () => { + test('renders TimelineChart with dataset', async ({ mount, page }) => { + await mount( + , + ); + const tlc = page.getByTestId('tlc'); + await expect(tlc).toBeVisible(); + const tagName = await tlc.evaluate((el) => el.tagName); + expect(tagName).toBe('DIV'); + }); + + test('calls the valueFormat callback & renders labels', async ({ mount, page }) => { + await mount(); + for (let i = 0; i <= 150; i += 30) { + await expect(page.getByText(`${i}-formatted`, { exact: true })).toBeVisible(); + } + }); + + test('render connection layer', async ({ mount, page }) => { + await mount(); + await expect(page.locator('[data-component-name="TimelineChartConnectionLayer"]')).not.toBeAttached(); + await page.getByText('Toggle Connection').click(); + await expect(page.locator('[data-component-name="TimelineChartConnectionLayer"]')).toBeVisible(); + }); + + test('render annotation layer', async ({ mount, page }) => { + await mount(); + await expect(page.locator('[data-component-name="TimelineChartAnnotationLayer"]')).not.toBeAttached(); + await page.getByText('Toggle Annotations', { exact: true }).click(); + await expect(page.locator('[data-component-name="TimelineChartAnnotationLayer"]')).not.toBeAttached(); + await page.getByText('Toggle Annotations visibility').click(); + await expect(page.locator('[data-component-name="TimelineChartAnnotationLayer"]')).toBeVisible(); + await page.getByText('Toggle Annotations', { exact: true }).click(); + await expect( + page.locator('[data-component-name="TimelineChartAnnotationLayer"]').locator('> *'), + ).not.toBeAttached(); + }); + + test('throws InvalidDiscreteLabelError', async ({ mount, page }) => { + const errors: Error[] = []; + page.on('pageerror', (err) => errors.push(err)); + await mount(); + await expect + .poll(() => errors.some((e) => e.name === 'InvalidDiscreteLabelError' || e.message.includes('discreteLabels'))) + .toBe(true); + }); + + test('throws IllegalConnectionError (1)', async ({ mount, page }) => { + const errors: Error[] = []; + page.on('pageerror', (err) => errors.push(err)); + await mount(); + await expect + .poll(() => errors.some((e) => e.name === 'IllegalConnectionError' || e.message.includes('connection'))) + .toBe(true); + }); + + test('throws IllegalConnectionError (2)', async ({ mount, page }) => { + const errors: Error[] = []; + page.on('pageerror', (err) => errors.push(err)); + await mount(); + await expect + .poll(() => errors.some((e) => e.name === 'IllegalConnectionError' || e.message.includes('connection'))) + .toBe(true); + }); + + test('shows the right mouse cursor', async ({ mount, page }) => { + await mount(); + + const bodyContainer = page.locator('[data-component-name="TimelineChartBodyContainer"]'); + await expect(bodyContainer).toHaveCSS('cursor', MOUSE_CURSOR_AUTO); + await bodyContainer.dispatchEvent('mousedown'); + await expect(bodyContainer).toHaveCSS('cursor', MOUSE_CURSOR_AUTO); + await bodyContainer.dispatchEvent('mouseup'); + await expect(bodyContainer).toHaveCSS('cursor', MOUSE_CURSOR_AUTO); + + const body = page.locator('[data-component-name="TimelineChartBody"]'); + // Zoom in via wheel to trigger grab cursor + await body.evaluate((el) => el.dispatchEvent(new WheelEvent('wheel', { deltaY: -1, bubbles: true }))); + await expect(bodyContainer).toHaveCSS('cursor', MOUSE_CURSOR_GRAB); + await bodyContainer.dispatchEvent('mousedown'); + await expect(bodyContainer).toHaveCSS('cursor', MOUSE_CURSOR_GRABBING); + await bodyContainer.dispatchEvent('mouseup'); + await expect(bodyContainer).toHaveCSS('cursor', MOUSE_CURSOR_GRAB); + }); + + test('TimelineChartAnnotation: positions itself correctly in the parent', async ({ mount, page }) => { + await mount(); + await expect(page.locator('[data-component-name="TimelineChartAnnotation"]')).toHaveCSS( + 'inset-block-start', + '40px', + ); + }); + + test('TimelineChartRow: tooltip & opacity for tasks', async ({ mount, page }) => { + await mount(); + + const task = page.locator('[data-component-name="TimelineChartTask"]').first(); + await expect(task).toHaveCSS('opacity', `${NORMAL_OPACITY}`); + await task.dispatchEvent('mousemove'); + await expect(task).toHaveCSS('opacity', `${HOVER_OPACITY}`); + await expect(page.getByText('Item 1')).toBeVisible(); + // React uses mouseout internally for onMouseLeave + await task.dispatchEvent('mouseout'); + await expect(task).toHaveCSS('opacity', `${NORMAL_OPACITY}`); + await expect(page.getByText('Item 1')).not.toBeAttached(); + }); + + test('TimelineChartRow: tooltip & opacity for milestones', async ({ mount, page }) => { + await mount(); + + const milestoneRect = page.locator('[data-component-name="TimelineChartMilestone"] > rect'); + await expect(milestoneRect).toHaveCSS('opacity', `${NORMAL_OPACITY}`); + await milestoneRect.dispatchEvent('mousemove'); + await expect(milestoneRect).toHaveCSS('opacity', `${HOVER_OPACITY}`); + await expect(page.getByText('Milestone 11')).toBeVisible(); + await milestoneRect.dispatchEvent('mouseout'); + await expect(milestoneRect).toHaveCSS('opacity', `${NORMAL_OPACITY}`); + await expect(page.getByText('Milestone 11')).not.toBeAttached(); + }); + + test('TimelineChartRow: hideTooltip still changes opacity', async ({ mount, page }) => { + await mount(); + + const task = page.locator('[data-component-name="TimelineChartTask"]').first(); + await expect(task).toHaveCSS('opacity', `${NORMAL_OPACITY}`); + await task.dispatchEvent('mousemove'); + await expect(task).toHaveCSS('opacity', `${HOVER_OPACITY}`); + await expect(page.getByText('Item 1')).not.toBeAttached(); + await task.dispatchEvent('mouseout'); + await expect(task).toHaveCSS('opacity', `${NORMAL_OPACITY}`); + + const milestoneRect = page.locator('[data-component-name="TimelineChartMilestone"] > rect'); + await expect(milestoneRect).toHaveCSS('opacity', `${NORMAL_OPACITY}`); + await milestoneRect.dispatchEvent('mousemove'); + await expect(milestoneRect).toHaveCSS('opacity', `${HOVER_OPACITY}`); + await expect(page.getByText('Milestone 11')).not.toBeAttached(); + await milestoneRect.dispatchEvent('mouseout'); + await expect(milestoneRect).toHaveCSS('opacity', `${NORMAL_OPACITY}`); + }); + + test('TimelineChartLayer', async ({ mount, page }) => { + await mount(); + + const gridLayer = page.locator('[data-component-name="TimelineChartGridLayer"]'); + await expect(gridLayer).toHaveCSS('pointer-events', 'none'); + const gridTagName = await gridLayer.evaluate((el) => el.tagName); + expect(gridTagName).toBe('svg'); + + const connectionLayer = page.locator('[data-component-name="TimelineChartConnectionLayer"]'); + await expect(connectionLayer).toHaveCSS('pointer-events', 'none'); + const connTagName = await connectionLayer.evaluate((el) => el.tagName); + expect(connTagName).toBe('svg'); + + const rowsLayer = page.locator('[data-component-name="TimelineChartRowsLayer"]'); + await expect(rowsLayer).toHaveCSS('pointer-events', 'none'); + const rowsTagName = await rowsLayer.evaluate((el) => el.tagName); + expect(rowsTagName).toBe('svg'); + + const annotationLayer = page.locator('[data-component-name="TimelineChartAnnotationLayer"]'); + await expect(annotationLayer).toHaveCSS('pointer-events', 'none'); + const annTagName = await annotationLayer.evaluate((el) => el.tagName); + expect(annTagName).toBe('DIV'); + }); + + test('TimelineChartHeaders: ColumnLabels continuous', async ({ mount, page }) => { + await mount(); + for (let i = 0; i <= 10; i += 2) { + await expect(page.getByText(`${i}`, { exact: true })).toBeVisible(); + } + await expect(page.getByText('12', { exact: true })).not.toBeAttached(); + }); + + test('TimelineChartHeaders: ColumnLabels discrete', async ({ mount, page }) => { + await mount(); + for (let i = 0; i <= 9; i++) { + await expect(page.getByText(`${i}`, { exact: true })).toBeVisible(); + } + await expect(page.getByText('10', { exact: true })).not.toBeAttached(); + }); + + test('TimelineChartHeaders: ColumnLabels discrete with labels', async ({ mount, page }) => { + await mount(); + await expect(page.getByText('label')).toHaveCount(8); + await expect(page.getByText('one')).toBeVisible(); + await expect(page.getByText('two')).toBeVisible(); + }); + + test('rowHeight', async ({ mount, page }) => { + await mount(); + await expect(page.locator('[data-component-name="TimelineChartRow"]').first()).toHaveAttribute('height', '200'); + }); + + test('unit and titles', async ({ mount, page }) => { + await mount(); + await expect(page.getByText('Activities')).not.toBeAttached(); + await expect(page.getByText('Duration')).not.toBeAttached(); + await expect(page.getByText('columnTitle (unit)')).toBeVisible(); + await expect(page.getByText('rowTitle')).toBeVisible(); + }); + + test('start', async ({ mount, page }) => { + await mount(); + for (let i = 5; i <= 15; i += 2) { + await expect(page.getByText(`${i}`, { exact: true })).toBeVisible(); + } + await expect(page.getByText('17', { exact: true })).not.toBeAttached(); + }); +}); diff --git a/packages/charts/src/components/TimelineChart/test/TimelineChartTestComponents.tsx b/packages/charts/src/components/TimelineChart/test/TimelineChartTestComponents.tsx new file mode 100644 index 00000000000..edd454768c3 --- /dev/null +++ b/packages/charts/src/components/TimelineChart/test/TimelineChartTestComponents.tsx @@ -0,0 +1,195 @@ +import { useReducer } from 'react'; +import { TimingFigure } from '../examples/Annotations.js'; +import { dummyDataSet, illegalConnDataset, illegalConnDataset2, schedulingEDFData } from '../examples/Dataset.js'; +import { TimelineChart } from '../index.js'; +import { TimelineChartAnnotation } from '../TimelineChartAnnotation.js'; + +// --- Basic rendering tests (no state needed, used directly in spec) --- + +// --- Connection layer toggle --- +export function ConnectionLayerToggle() { + const [showConn, toggleShowConn] = useReducer((prev) => !prev, undefined); + return ( + <> + + + + ); +} + +// --- Annotation layer toggle --- +export function AnnotationLayerToggle() { + const [showAnn, toggleShowAnn] = useReducer((prev) => !prev, undefined); + const [renderAnn, toggleAnn] = useReducer((prev) => !prev, undefined); + return ( + <> + + + + } + /> + } + /> + } + /> + + ) + } + /> + + ); +} + +// --- Error throwing tests --- +export function InvalidDiscreteLabelTest() { + return ( + + ); +} + +export function IllegalConnectionTest1() { + return ; +} + +export function IllegalConnectionTest2() { + return ; +} + +// --- valueFormat callback test --- +export function ValueFormatTest() { + return ( + `${Math.round(x)}-formatted`} + /> + ); +} + +// --- Mouse cursor test --- +export function MouseCursorTest() { + return ; +} + +// --- Annotation position test --- +export function AnnotationPositionTest() { + return ( + } />} + showAnnotation + /> + ); +} + +// --- Tooltip and opacity test --- +export function TooltipOpacityTest() { + return ; +} + +export function TooltipHiddenTest() { + return ; +} + +// --- Wheel zoom test --- +export function WheelZoomTest() { + return ; +} + +// --- Layer structure test --- +export function LayerStructureTest() { + return ( + } />} + showAnnotation + /> + ); +} + +// --- Column labels test: continuous --- +export function ColumnLabelsContinuousTest() { + return ( + `${Math.round(x)}`} + /> + ); +} + +// --- Column labels test: discrete --- +export function ColumnLabelsDiscreteTest() { + return ( + `${Math.round(x)}`} + isDiscrete + /> + ); +} + +// --- Column labels test: discrete with labels --- +export function ColumnLabelsDiscreteWithLabelsTest() { + return ( + `${Math.round(x)}`} + isDiscrete + discreteLabels={['one', 'two', ...new Array(8).fill('label')]} + /> + ); +} + +// --- rowHeight test --- +export function RowHeightTest() { + return ; +} + +// --- Unit and titles test --- +export function UnitAndTitlesTest() { + return ( + + ); +} + +// --- Start prop test --- +export function StartPropTest() { + return ( + `${Math.round(x)}`} + /> + ); +} diff --git a/packages/charts/src/hooks/test/HookTestComponents.tsx b/packages/charts/src/hooks/test/HookTestComponents.tsx new file mode 100644 index 00000000000..4555487f4a2 --- /dev/null +++ b/packages/charts/src/hooks/test/HookTestComponents.tsx @@ -0,0 +1,74 @@ +import { useLabelFormatter } from '../useLabelFormatter.js'; +import { usePrepareDimensionsAndMeasures } from '../usePrepareDimensionsAndMeasures.js'; +import { useTooltipFormatter } from '../useTooltipFormatter.js'; + +// --- useLabelFormatter test components --- + +export function LabelFormatterNull() { + const val = useLabelFormatter(null as any); + return {val(100, undefined)}; +} + +export function LabelFormatterInvalid() { + const val = useLabelFormatter('abc' as any); + return {val(100, undefined)}; +} + +export function LabelFormatterValid() { + const val = useLabelFormatter(((v: number) => v / 10) as any); + return {val(100, undefined)}; +} + +// --- usePrepareDimensionsAndMeasures test components --- + +const dimensions = [{ accessor: 'a' }]; +const measures = [{ accessor: 'b' }]; + +function serializeResult(result: ReturnType) { + return JSON.stringify({ + ...result, + lastInStack: [...result.lastInStack], + }); +} + +export function PrepareDimensionsDefault() { + const result = usePrepareDimensionsAndMeasures(dimensions, measures); + return
{serializeResult(result)}
; +} + +export function PrepareDimensionsWithDefaults() { + const result = usePrepareDimensionsAndMeasures( + dimensions, + measures, + { dimensionDefault: true }, + { measureDefault: true }, + ); + return
{serializeResult(result)}
; +} + +export function PrepareDimensionsNoOverwrite() { + const result = usePrepareDimensionsAndMeasures( + dimensions, + measures, + { dimensionDefault: true, accessor: 'I should not be in the result' }, + { measureDefault: true, accessor: 'I should not be in the result' }, + ); + return
{serializeResult(result)}
; +} + +// --- useTooltipFormatter test components --- + +export function TooltipFormatterNoFormatter() { + const val = useTooltipFormatter([{ accessor: 'test' }]); + return {val(100, 'value', { dataKey: 'test' })}; +} + +export function TooltipFormatterInvalid() { + const val = useTooltipFormatter([{ accessor: 'test', formatter: 'abc' }]); + return {val(100, 'value', { dataKey: 'test' })}; +} + +export function TooltipFormatterValid() { + const val = useTooltipFormatter([{ accessor: 'test', formatter: (v: number) => v / 10 }]); + return {val(100, 'value', { dataKey: 'test' })}; +} diff --git a/packages/charts/src/hooks/test/useLabelFormatter.spec.tsx b/packages/charts/src/hooks/test/useLabelFormatter.spec.tsx new file mode 100644 index 00000000000..c128a6e0317 --- /dev/null +++ b/packages/charts/src/hooks/test/useLabelFormatter.spec.tsx @@ -0,0 +1,19 @@ +import { expect, test } from '../../../../../playwright/fixtures/main-fixtures.js'; +import { LabelFormatterInvalid, LabelFormatterNull, LabelFormatterValid } from './HookTestComponents.js'; + +test.describe('useLabelFormatter', () => { + test('should return value when no formatter is present', async ({ mount }) => { + const component = await mount(); + await expect(component.getByText('100')).toBeVisible(); + }); + + test('should not crash on invalid formatter', async ({ mount }) => { + const component = await mount(); + await expect(component.getByText('100')).toBeVisible(); + }); + + test('should format the value with a valid formatter', async ({ mount }) => { + const component = await mount(); + await expect(component.getByText('10')).toBeVisible(); + }); +}); diff --git a/packages/charts/src/hooks/test/usePrepareDimensionsAndMeasures.spec.tsx b/packages/charts/src/hooks/test/usePrepareDimensionsAndMeasures.spec.tsx new file mode 100644 index 00000000000..95536903127 --- /dev/null +++ b/packages/charts/src/hooks/test/usePrepareDimensionsAndMeasures.spec.tsx @@ -0,0 +1,44 @@ +import { expect, test } from '../../../../../playwright/fixtures/main-fixtures.js'; +import { + PrepareDimensionsDefault, + PrepareDimensionsNoOverwrite, + PrepareDimensionsWithDefaults, +} from './HookTestComponents.js'; + +test.describe('usePrepareDimensionsAndMeasures', () => { + test('should not throw an error when no defaults are passed', async ({ mount, page }) => { + await mount(); + const resultText = await page.getByTestId('result').textContent(); + const result = JSON.parse(resultText); + expect(result).toEqual({ + dimensions: [{ accessor: 'a', reactKey: 'a' }], + measures: [{ accessor: 'b', reactKey: 'b' }], + stackGroups: {}, + lastInStack: [], + }); + }); + + test('should merge defaults', async ({ mount, page }) => { + await mount(); + const resultText = await page.getByTestId('result').textContent(); + const result = JSON.parse(resultText); + expect(result).toEqual({ + dimensions: [{ accessor: 'a', dimensionDefault: true, reactKey: 'a' }], + measures: [{ accessor: 'b', measureDefault: true, reactKey: 'b' }], + stackGroups: {}, + lastInStack: [], + }); + }); + + test('should merge defaults but not overwrite existing properties', async ({ mount, page }) => { + await mount(); + const resultText = await page.getByTestId('result').textContent(); + const result = JSON.parse(resultText); + expect(result).toEqual({ + dimensions: [{ accessor: 'a', dimensionDefault: true, reactKey: 'a' }], + measures: [{ accessor: 'b', measureDefault: true, reactKey: 'b' }], + stackGroups: {}, + lastInStack: [], + }); + }); +}); diff --git a/packages/charts/src/hooks/test/useTooltipFormatter.spec.tsx b/packages/charts/src/hooks/test/useTooltipFormatter.spec.tsx new file mode 100644 index 00000000000..9ce93831e69 --- /dev/null +++ b/packages/charts/src/hooks/test/useTooltipFormatter.spec.tsx @@ -0,0 +1,19 @@ +import { expect, test } from '../../../../../playwright/fixtures/main-fixtures.js'; +import { TooltipFormatterInvalid, TooltipFormatterNoFormatter, TooltipFormatterValid } from './HookTestComponents.js'; + +test.describe('useTooltipFormatter', () => { + test('should return value when no formatter is present', async ({ mount }) => { + const component = await mount(); + await expect(component.getByText('100')).toBeVisible(); + }); + + test('should not crash on invalid formatter', async ({ mount }) => { + const component = await mount(); + await expect(component.getByText('100')).toBeVisible(); + }); + + test('should format the value with a valid formatter', async ({ mount }) => { + const component = await mount(); + await expect(component.getByText('10')).toBeVisible(); + }); +}); diff --git a/packages/charts/src/hooks/useLabelFormatter.cy.tsx b/packages/charts/src/hooks/useLabelFormatter.cy.tsx deleted file mode 100644 index 4a34040cf2d..00000000000 --- a/packages/charts/src/hooks/useLabelFormatter.cy.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import type { TooltipLabelFormatter } from '../interfaces/index.js'; -import { useLabelFormatter } from './useLabelFormatter.js'; - -function LabelFormatterComponent({ formatter }: { formatter?: TooltipLabelFormatter | string }) { - const val = useLabelFormatter(formatter as any); - return {val(100, undefined)}; -} - -describe('useLabelFormatter', () => { - it('should return value when no formatter is present', () => { - cy.mount(); - cy.findByText('100').should('be.visible'); - }); - - it('should not crash on invalid formatter', () => { - cy.mount(); - cy.findByText('100').should('be.visible'); - }); - - it('should format the value with a valid formatter', () => { - cy.mount( val / 10} />); - cy.findByText('10').should('be.visible'); - }); -}); diff --git a/packages/charts/src/hooks/usePrepareDimensionsAndMeasures.cy.tsx b/packages/charts/src/hooks/usePrepareDimensionsAndMeasures.cy.tsx deleted file mode 100644 index 5dbe0a33822..00000000000 --- a/packages/charts/src/hooks/usePrepareDimensionsAndMeasures.cy.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { usePrepareDimensionsAndMeasures } from './usePrepareDimensionsAndMeasures.js'; - -const dimensions = [ - { - accessor: 'a', - }, -]; - -const measures = [ - { - accessor: 'b', - }, -]; - -// eslint-disable-next-line react/prop-types -function TestComponent({ onHookResult, dimensionOptions = undefined, measureOptions = undefined }) { - const result = usePrepareDimensionsAndMeasures(dimensions, measures, dimensionOptions, measureOptions); - onHookResult(result); - return null; -} - -describe('useLabelFormatter', () => { - it('should not throw an error when no defaults are passed', () => { - const result = cy.spy().as('result'); - cy.mount(); - - cy.get('@result').should('have.been.calledWith', { - dimensions: [ - { - accessor: 'a', - reactKey: 'a', - }, - ], - measures: [ - { - accessor: 'b', - reactKey: 'b', - }, - ], - stackGroups: {}, - lastInStack: new Set(), - }); - }); - - it('should merge defaults', () => { - const result = cy.spy().as('result'); - cy.mount( - , - ); - - cy.get('@result').should('have.been.calledWith', { - dimensions: [ - { - accessor: 'a', - dimensionDefault: true, - reactKey: 'a', - }, - ], - measures: [ - { - accessor: 'b', - measureDefault: true, - reactKey: 'b', - }, - ], - stackGroups: {}, - lastInStack: new Set(), - }); - }); - - it('should merge defaults but not overwrite existing properties', () => { - const result = cy.spy().as('result'); - cy.mount( - , - ); - - cy.get('@result').should('have.been.calledWith', { - dimensions: [ - { - accessor: 'a', - dimensionDefault: true, - reactKey: 'a', - }, - ], - measures: [ - { - accessor: 'b', - measureDefault: true, - reactKey: 'b', - }, - ], - stackGroups: {}, - lastInStack: new Set(), - }); - }); -}); diff --git a/packages/charts/src/hooks/useTooltipFormatter.cy.tsx b/packages/charts/src/hooks/useTooltipFormatter.cy.tsx deleted file mode 100644 index 962e6a0ae64..00000000000 --- a/packages/charts/src/hooks/useTooltipFormatter.cy.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useTooltipFormatter } from './useTooltipFormatter.js'; - -// eslint-disable-next-line react/prop-types -function TooltipFormatterComponent({ measure, value, name, options }) { - const val = useTooltipFormatter([measure]); - return {val(value, name, options)}; -} - -describe('useTooltipFormatter', () => { - it('should return value when no formatter is present', () => { - cy.mount( - , - ); - cy.findByText('100').should('be.visible'); - }); - - it('should not crash on invalid formatter', () => { - cy.mount( - , - ); - cy.findByText('100').should('be.visible'); - }); - - it('should format the value with a valid formatter', () => { - cy.mount( - val / 10 }} - value={100} - name={'value'} - options={{ dataKey: 'test' }} - />, - ); - cy.findByText('10').should('be.visible'); - }); -}); diff --git a/packages/charts/src/test-utils/componentFactories.tsx b/packages/charts/src/test-utils/componentFactories.tsx new file mode 100644 index 00000000000..7582d7789a4 --- /dev/null +++ b/packages/charts/src/test-utils/componentFactories.tsx @@ -0,0 +1,147 @@ +import type { ComponentType } from 'react'; +import { useState } from 'react'; + +/** + * Factory that creates a click-tracking test component for charts that use onClick + onLegendClick. + * + * Options: + * - noAnimation: pass `noAnimation` prop to the chart + * - trackLegendValue: include a `last-legend-value` span tracking `e.detail?.value` + * - trackPayload: include a `last-payload` span tracking `e.detail?.payload` (default: true) + */ +export function createClickTestComponent( + Chart: ComponentType, + baseProps: Record, + options?: { noAnimation?: boolean; trackLegendValue?: boolean; trackPayload?: boolean }, +) { + const { noAnimation = false, trackLegendValue = false, trackPayload = true } = options || {}; + + return function ClickTestComponent() { + const [clickCount, setClickCount] = useState(0); + const [lastPayload, setLastPayload] = useState(''); + const [legendClickCount, setLegendClickCount] = useState(0); + const [lastLegendValue, setLastLegendValue] = useState(''); + const [lastLegendDataKey, setLastLegendDataKey] = useState(''); + + return ( + <> + {clickCount} + {trackPayload && {lastPayload}} + {legendClickCount} + {trackLegendValue && {lastLegendValue}} + {lastLegendDataKey} + { + setClickCount((c) => c + 1); + if (trackPayload) { + setLastPayload(JSON.stringify(e.detail?.payload)); + } + }} + onLegendClick={(e: any) => { + setLegendClickCount((c) => c + 1); + if (trackLegendValue) { + setLastLegendValue(e.detail?.value || ''); + } + setLastLegendDataKey(e.detail?.dataKey || ''); + }} + /> + + ); + }; +} + +/** + * Factory for legend config test component. + */ +export function createLegendConfigTestComponent(Chart: ComponentType, baseProps: Record) { + return function LegendConfigTestComponent() { + return ( + {value}🐱, + }, + }} + /> + ); + }; +} + +/** + * Factory for onDataPointClick test component. + * Tracks: click count, dataKey, value, dataIndex, payload. + */ +export function createDataPointClickTestComponent( + Chart: ComponentType, + baseProps: Record, + options?: { noAnimation?: boolean }, +) { + const { noAnimation = true } = options || {}; + + return function DataPointClickTestComponent() { + const [clickCount, setClickCount] = useState(0); + const [lastDataKey, setLastDataKey] = useState(''); + const [lastValue, setLastValue] = useState(''); + const [lastDataIndex, setLastDataIndex] = useState(-1); + const [lastPayload, setLastPayload] = useState(''); + + return ( + <> + {clickCount} + {lastDataKey} + {lastValue} + {lastDataIndex} + {lastPayload} + { + setClickCount((c) => c + 1); + setLastDataKey(e.detail?.dataKey || ''); + setLastValue(String(e.detail?.value ?? '')); + setLastDataIndex(e.detail?.dataIndex ?? -1); + setLastPayload(JSON.stringify(e.detail?.payload)); + }} + /> + + ); + }; +} + +/** + * Factory for highlightColor test component. + */ +export function createHighlightColorTestComponent( + Chart: ComponentType, + baseProps: Record, + highlightMeasures: any[], +) { + return function HighlightColorTestComponent() { + return ; + }; +} + +/** + * Factory for secondYAxis test component. + */ +export function createSecondYAxisTestComponent( + Chart: ComponentType, + baseProps: Record, + secondYAxisDataKey: string, +) { + return function SecondYAxisTestComponent() { + return ; + }; +} + +/** + * Factory for vertical layout test component. + */ +export function createVerticalLayoutTestComponent(Chart: ComponentType, baseProps: Record) { + return function VerticalLayoutTestComponent() { + return ; + }; +} diff --git a/packages/charts/src/test-utils/shared.tsx b/packages/charts/src/test-utils/shared.tsx new file mode 100644 index 00000000000..99d782a1067 --- /dev/null +++ b/packages/charts/src/test-utils/shared.tsx @@ -0,0 +1,29 @@ +import { expect } from '@playwright/experimental-ct-react'; +import type { Page } from '@playwright/test'; + +export async function assertPassThroughProps(page: Page) { + const testId = 'component-to-be-tested'; + const el = page.getByTestId(testId); + await expect(el).toBeAttached(); + await expect(el).toHaveClass(/thisClassIsUsedForTestingPurposesOnly/); + await expect(el).toHaveCSS('pointer-events', 'none'); + await expect(el).toHaveAttribute('aria-labelledby', 'aria-prop'); + await expect(el).toHaveAttribute('customattribute', 'true'); + await expect(el).toHaveAttribute('data-special-test-prop', 'data-prop'); + await expect(el).toHaveAttribute('id', 'element-id'); + await expect(page.locator('[title="Tooltip"]')).toBeAttached(); +} + +export function passThroughProps(extraProps?: object) { + return { + 'data-testid': 'component-to-be-tested', + 'data-special-test-prop': 'data-prop', + 'aria-labelledby': 'aria-prop', + id: 'element-id', + className: 'thisClassIsUsedForTestingPurposesOnly', + style: { pointerEvents: 'none' as const }, + title: 'Tooltip', + customattribute: 'true', + ...extraProps, + }; +} diff --git a/packages/charts/src/test-utils/sharedTests.tsx b/packages/charts/src/test-utils/sharedTests.tsx new file mode 100644 index 00000000000..b836b614c1f --- /dev/null +++ b/packages/charts/src/test-utils/sharedTests.tsx @@ -0,0 +1,125 @@ +import type { ComponentType } from 'react'; +import { expect, test } from '../../../../playwright/fixtures/main-fixtures.js'; +import { assertPassThroughProps, passThroughProps } from './shared.js'; + +/** + * Registers a `Pass Through HTML Standard Props` test that verifies that the chart forwards + * the standard HTML props (data-testid, data-*, aria-*, id, className, style.pointerEvents, + * title, custom attribute) onto its rendered root element. + */ +export function testPassThroughProps>(Chart: ComponentType, emptyProps: T) { + test('Pass Through HTML Standard Props', async ({ mount, page }) => { + await mount(); + await assertPassThroughProps(page); + }); +} + +/** + * Registers a `loading states` test that verifies the three distinct rendering paths in + * ChartContainer: + * - empty dataset → Placeholder, no BusyIndicator, no chart elements (loading prop has no effect) + * - empty dataset + loading=true → identical to empty (loading is a no-op without data) + * - has data + loading=true → BusyIndicator overlay on top of the rendered chart + * + * @param chartElementSelector A selector unique to the chart's rendered shape, e.g. `.recharts-bar` + * for BarChart or `.recharts-pie` for PieChart. Used to assert the chart isn't rendered in the + * placeholder path. + */ +export function testLoadingStates>( + Chart: ComponentType, + baseProps: T, + emptyProps: T, + chartElementSelector: string, +) { + test('loading states', async ({ mount, page }) => { + const busyIndicator = page.locator('[data-component-name="ChartContainerBusyIndicator"]').first(); + const chartElement = page.locator(chartElementSelector); + const loadingText = page.getByText('Loading...').first(); + + let result = await mount(); + await expect(loadingText).toBeAttached(); + await expect(chartElement).not.toBeAttached(); + await expect(busyIndicator).not.toBeAttached(); + await result.unmount(); + + result = await mount(); + await expect(loadingText).toBeAttached(); + await expect(chartElement).not.toBeAttached(); + await expect(busyIndicator).not.toBeAttached(); + await result.unmount(); + + await mount(); + await expect(busyIndicator).toBeAttached(); + }); +} + +/** + * Registers `zoomingTool` describe block with three sub-tests verifying the chart's + * `chartConfig.zoomingTool` prop: + * - `true` → recharts brush is rendered + * - `false` → no brush + * - `{ stroke: 'red' }` → brush rendered with the custom stroke color + */ +export function testZoomingTool>(Chart: ComponentType, baseProps: T) { + test.describe('zoomingTool', () => { + test('enabled', async ({ mount, page }) => { + await mount(); + await expect(page.locator('.recharts-brush')).toBeVisible(); + }); + + test('disabled', async ({ mount, page }) => { + await mount(); + await expect(page.locator('.recharts-brush')).not.toBeAttached(); + }); + + test('custom config', async ({ mount, page }) => { + await mount(); + await expect(page.locator('.recharts-brush')).toBeVisible(); + await expect(page.locator('.recharts-brush [stroke="red"]')).toBeVisible(); + }); + }); +} + +/** + * Registers a `showStackAggregateTotals` describe block with two sub-tests verifying the + * `chartConfig.showStackAggregateTotals` prop: + * - enabled → stack totals rendered as bold labels, tooltip shows "Total : " + * - disabled → no bold totals; bars still render + * + * Expected stack totals are computed from the dataset and the stacked measure accessors. + */ +export function testStackAggregateTotals>( + Chart: ComponentType, + baseProps: T, + stackMeasures: Array<{ accessor: string; stackId?: string; label?: string; type?: string }>, +) { + const stackedAccessors = stackMeasures.filter((m) => m.stackId).map((m) => m.accessor); + const expectedTotals = (baseProps.dataset as Record[]).map((entry) => + stackedAccessors.reduce((sum, acc) => sum + (Number(entry[acc]) || 0), 0), + ); + + test.describe('showStackAggregateTotals', () => { + test('enabled', async ({ mount, page }) => { + await mount(); + + for (const total of expectedTotals) { + await expect(page.locator(`text[font-weight="bold"]`).filter({ hasText: String(total) })).toBeAttached(); + } + + const wrapper = page.locator('.recharts-wrapper'); + await wrapper.hover({ position: { x: 200, y: 100 }, force: true }); + const tooltipTotal = page.locator('.recharts-tooltip-item').last(); + await expect(tooltipTotal).toContainText('Total'); + await expect(tooltipTotal).toHaveCSS('font-weight', '700'); + const tooltipText = await tooltipTotal.textContent(); + const totalValue = Number(tooltipText.replace(/\D/g, '')); + expect(expectedTotals).toContain(totalValue); + }); + + test('disabled', async ({ mount, page }) => { + await mount(); + await expect(page.locator('.recharts-bar-rectangles').first()).toBeAttached(); + await expect(page.locator('text[font-weight="bold"]')).not.toBeAttached(); + }); + }); +} diff --git a/packages/charts/tsconfig.build.json b/packages/charts/tsconfig.build.json index 0604991aae0..73dc0046591 100644 --- a/packages/charts/tsconfig.build.json +++ b/packages/charts/tsconfig.build.json @@ -8,5 +8,13 @@ "path": "../main/tsconfig.build.json" } ], - "exclude": ["node_modules", "**/*.cy.ts", "**/*.cy.tsx", "**/*.stories.tsx"] + "exclude": [ + "node_modules", + "**/*.cy.ts", + "**/*.cy.tsx", + "**/*.spec.tsx", + "**/*.stories.tsx", + "**/test", + "src/test-utils" + ] } diff --git a/playwright-ct.config.ts b/playwright-ct.config.ts index 35ddd9f819d..45b9ae31ee6 100644 --- a/playwright-ct.config.ts +++ b/playwright-ct.config.ts @@ -5,7 +5,11 @@ import tsconfigPaths from 'vite-tsconfig-paths'; export default defineConfig({ testDir: '.', - testMatch: ['**/packages/main/src/components/**/test/*.spec.tsx', '**/playwright/test/**/*.spec.tsx'], + testMatch: [ + '**/packages/main/src/components/**/test/*.spec.tsx', + '**/packages/charts/src/**/test/*.spec.tsx', + '**/playwright/test/**/*.spec.tsx', + ], testIgnore: ['**/*.cy.tsx', '**/*.cy.ts', '**/*.stories.tsx', '**/*.mdx'], fullyParallel: true, forbidOnly: !!process.env.CI, @@ -22,11 +26,25 @@ export default defineConfig({ name: 'Playwright Coverage Report', outputFile: 'temp/playwright-coverage/report.html', coverage: { - sourceFilter: (sourcePath: string) => - (sourcePath.includes('packages/main/src/components/SelectDialog') || - sourcePath.includes('packages/main/src/components/Splitter')) && - !sourcePath.includes('node_modules') && - !sourcePath.includes('/test/'), + sourceFilter: (sourcePath: string) => { + const included = + sourcePath.includes('packages/main/src/components/SelectDialog') || + sourcePath.includes('packages/main/src/components/Splitter') || + (sourcePath.includes('packages/charts/src/') && + !sourcePath.includes('packages/charts/src/resources/') && + !sourcePath.includes('packages/charts/src/test-utils/') && + !sourcePath.includes('packages/charts/src/interfaces/') && + !sourcePath.includes('packages/charts/src/enums/')); + return ( + included && + !sourcePath.includes('node_modules') && + !sourcePath.includes('/dist/') && + !sourcePath.includes('/test/') && + !sourcePath.endsWith('.stories.tsx') && + !sourcePath.endsWith('.module.css.ts') && + !/packages\/[^/]+\/src\/index\.ts$/.test(sourcePath) + ); + }, reports: ['lcovonly'], outputDir: 'temp/playwright-coverage', },