@micha.bigler/ui-core-micha 2.39.1 → 2.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,234 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import i18next from 'i18next';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { cleanup, render } from '@testing-library/react';
6
+ import { I18nextProvider } from 'react-i18next';
7
+ import { ThemeProvider } from '@mui/material/styles';
8
+
9
+ const scatterSpy = vi.hoisted(() => vi.fn(({ children }) => <div data-testid="mui-scatter">{children}</div>));
10
+ vi.mock('@mui/x-charts/ScatterChart', () => ({ ScatterChart: scatterSpy }));
11
+ vi.mock('@mui/x-charts/hooks', () => ({
12
+ useXScale: () => (v) => v * 10,
13
+ useYScale: () => (v) => 100 - v * 10,
14
+ }));
15
+
16
+ import {
17
+ ScatterChart,
18
+ ScatterReferenceCurve,
19
+ ScatterReferenceLine,
20
+ scaleBubbleRadius,
21
+ } from '../src/components/charts/ScatterChart';
22
+ import { createAppTheme } from '../src/theme/createAppTheme';
23
+
24
+ const theme = createAppTheme({ palette: { primary: { main: '#3D5A99' } } });
25
+ const i18n = i18next.createInstance();
26
+ i18n.init({ lng: 'en', resources: { en: { translation: {} } }, interpolation: { escapeValue: false } });
27
+
28
+ function withProviders(children) {
29
+ return React.createElement(
30
+ ThemeProvider,
31
+ { theme },
32
+ React.createElement(I18nextProvider, { i18n }, children),
33
+ );
34
+ }
35
+
36
+ function renderScatter(props = {}) {
37
+ return render(withProviders(
38
+ React.createElement(ScatterChart, {
39
+ series: [{ id: 's', data: [{ x: 1, y: 1 }] }],
40
+ ...props,
41
+ }),
42
+ ));
43
+ }
44
+
45
+ // THEME-10 -- the formula the access-gap panel already hand-rolls
46
+ // (`max(3, 10*sqrt(pop/maxPop))`), pinned as a pure function.
47
+ describe('scaleBubbleRadius', () => {
48
+ it('matches the access-panel formula across the domain', () => {
49
+ expect(scaleBubbleRadius(0, 100)).toBe(3);
50
+ expect(scaleBubbleRadius(100, 100)).toBe(10);
51
+ expect(scaleBubbleRadius(25, 100)).toBeCloseTo(5, 5);
52
+ });
53
+
54
+ it('floors at the minimum radius for a degenerate or non-finite input', () => {
55
+ expect(scaleBubbleRadius(5, 0)).toBe(3);
56
+ expect(scaleBubbleRadius(NaN, 100)).toBe(3);
57
+ expect(scaleBubbleRadius(5, NaN)).toBe(3);
58
+ });
59
+ });
60
+
61
+ describe('ScatterChart mark count and colour default (THEME-10)', () => {
62
+ afterEach(() => { cleanup(); vi.clearAllMocks(); });
63
+
64
+ // The failure mode nobody sees: a scatter that silently drops points.
65
+ it('renders every supplied point, none dropped', () => {
66
+ renderScatter({ series: [{ id: 's', data: [{ x: 1, y: 1 }, { x: 2, y: 2 }, { x: 3, y: 3 }] }] });
67
+ expect(scatterSpy.mock.calls.at(-1)[0].series[0].data).toHaveLength(3);
68
+ });
69
+
70
+ it('defaults a single undifferentiated series to the neutral role colour, not a series hue', () => {
71
+ renderScatter();
72
+ expect(scatterSpy.mock.calls.at(-1)[0].colors).toEqual([theme.palette.text.secondary]);
73
+ });
74
+
75
+ it('uses the categorical palette, not the neutral tone, once a caller supplies multiple series', () => {
76
+ renderScatter({
77
+ series: [
78
+ { id: 'a', data: [{ x: 1, y: 1 }] },
79
+ { id: 'b', data: [{ x: 2, y: 2 }] },
80
+ ],
81
+ });
82
+ const { colors } = scatterSpy.mock.calls.at(-1)[0];
83
+ expect(colors).not.toEqual([theme.palette.text.secondary]);
84
+ expect(colors.length).toBeGreaterThan(1);
85
+ });
86
+
87
+ it('lets a caller override the palette explicitly (e.g. a family/role register)', () => {
88
+ renderScatter({ palette: ['#ABCDEF'] });
89
+ expect(scatterSpy.mock.calls.at(-1)[0].colors).toEqual(['#ABCDEF']);
90
+ });
91
+
92
+ // reviewer/ui_reviewer U3: a single series that sets its OWN colour is not "undifferentiated"
93
+ // -- the neutral default only applies when nothing else specifies a colour. This pins that
94
+ // ScatterChart itself never strips or overrides a series' own `.color` (MUI's own resolution
95
+ // of series.color vs. the colors fallback array is MUI's own contract, out of scope here --
96
+ // same boundary BarChart.test.jsx already draws for the identical `colors={palette ||
97
+ // neutralPalette.categorical}` pattern).
98
+ it('leaves a single series\' own explicit colour untouched and does not treat it as neutral', () => {
99
+ renderScatter({ series: [{ id: 's', color: '#123456', data: [{ x: 1, y: 1 }] }] });
100
+ const props = scatterSpy.mock.calls.at(-1)[0];
101
+ expect(props.series[0].color).toBe('#123456');
102
+ expect(props.colors).not.toEqual([theme.palette.text.secondary]);
103
+ });
104
+ });
105
+
106
+ describe('ScatterChart bubble z-order (THEME-10)', () => {
107
+ afterEach(() => { cleanup(); vi.clearAllMocks(); });
108
+
109
+ it('sorts each series largest-first when sizeAccessor is set, so a small bubble is never hidden under a large one', () => {
110
+ renderScatter({
111
+ sizeAccessor: (p) => p.pop,
112
+ series: [{ id: 's', data: [{ x: 1, y: 1, pop: 10 }, { x: 2, y: 2, pop: 500 }, { x: 3, y: 3, pop: 100 }] }],
113
+ });
114
+ const data = scatterSpy.mock.calls.at(-1)[0].series[0].data;
115
+ expect(data.map((p) => p.pop)).toEqual([500, 100, 10]);
116
+ });
117
+
118
+ it('leaves point order untouched outside bubble mode', () => {
119
+ const original = [{ x: 1, y: 1 }, { x: 2, y: 2 }];
120
+ renderScatter({ series: [{ id: 's', data: original }] });
121
+ expect(scatterSpy.mock.calls.at(-1)[0].series[0].data).toEqual(original);
122
+ });
123
+ });
124
+
125
+ describe('ScatterChart per-point marker style (THEME-10)', () => {
126
+ afterEach(() => { cleanup(); vi.clearAllMocks(); });
127
+
128
+ it('renders a hollow marker (fill: none) only for the point getPointStyle marks hollow', () => {
129
+ renderScatter({
130
+ getPointStyle: (p) => (p.isStatusQuo ? { hollow: true } : {}),
131
+ series: [{ id: 's', data: [{ x: 1, y: 1, isStatusQuo: true }, { x: 2, y: 2, isStatusQuo: false }] }],
132
+ });
133
+ const Marker = scatterSpy.mock.calls.at(-1)[0].slots.marker;
134
+ const markerProps = { seriesId: 's', color: '#111', x: 0, y: 0, isHighlighted: false, isFaded: false, size: 5 };
135
+
136
+ const hollow = render(React.createElement(Marker, { ...markerProps, dataIndex: 0 }));
137
+ const filled = render(React.createElement(Marker, { ...markerProps, dataIndex: 1 }));
138
+
139
+ expect(hollow.container.querySelector('circle').getAttribute('fill')).toBe('none');
140
+ expect(filled.container.querySelector('circle').getAttribute('fill')).toBe('#111');
141
+ });
142
+
143
+ it('sizes a bubble marker from sizeAccessor via scaleBubbleRadius, resolved against the POST-sort index', () => {
144
+ renderScatter({
145
+ sizeAccessor: (p) => p.pop,
146
+ series: [{ id: 's', data: [{ x: 1, y: 1, pop: 0 }, { x: 2, y: 2, pop: 100 }] }],
147
+ });
148
+ const Marker = scatterSpy.mock.calls.at(-1)[0].slots.marker;
149
+ const markerProps = { seriesId: 's', color: '#111', x: 0, y: 0, isHighlighted: false, isFaded: false, size: 5 };
150
+
151
+ // Data was re-sorted largest-first: index 0 is now pop:100, index 1 is pop:0.
152
+ const big = render(React.createElement(Marker, { ...markerProps, dataIndex: 0 }));
153
+ const small = render(React.createElement(Marker, { ...markerProps, dataIndex: 1 }));
154
+
155
+ expect(Number(big.container.querySelector('circle').getAttribute('r'))).toBeCloseTo(10, 5);
156
+ expect(Number(small.container.querySelector('circle').getAttribute('r'))).toBe(3);
157
+ });
158
+
159
+ // Live-render caught regression: bubble sizing must share ONE max across every series (a
160
+ // single comparable scale, matching the access-gap panel's own
161
+ // `maxPop = Math.max(1, ...points.map(p => p.population))` over ALL points regardless of
162
+ // division/settlement) -- a per-series max would let each series' own largest point always
163
+ // render at full radius, making cross-series bubble size meaningless.
164
+ it('sizes bubbles against ONE chart-wide max across all series, not a max per series', () => {
165
+ renderScatter({
166
+ sizeAccessor: (p) => p.pop,
167
+ series: [
168
+ { id: 'small-series', data: [{ x: 1, y: 1, pop: 100 }] },
169
+ { id: 'big-series', data: [{ x: 2, y: 2, pop: 1000 }] },
170
+ ],
171
+ });
172
+ const props = scatterSpy.mock.calls.at(-1)[0];
173
+ const Marker = props.slots.marker;
174
+ const markerProps = { color: '#111', x: 0, y: 0, isHighlighted: false, isFaded: false, size: 5 };
175
+
176
+ // If sized per-series, "small-series"'s own only point (pop:100) would be its own series
177
+ // max and render at full radius (10) -- wrongly implying it's as large as big-series's point.
178
+ const smallSeriesPoint = render(React.createElement(Marker, { ...markerProps, seriesId: 'small-series', dataIndex: 0 }));
179
+ const bigSeriesPoint = render(React.createElement(Marker, { ...markerProps, seriesId: 'big-series', dataIndex: 0 }));
180
+
181
+ const smallR = Number(smallSeriesPoint.container.querySelector('circle').getAttribute('r'));
182
+ const bigR = Number(bigSeriesPoint.container.querySelector('circle').getAttribute('r'));
183
+ expect(bigR).toBeCloseTo(10, 5);
184
+ expect(smallR).toBeCloseTo(10 * Math.sqrt(100 / 1000), 5);
185
+ expect(smallR).toBeLessThan(bigR);
186
+ });
187
+
188
+ it('renders a square/diamond shape when getPointStyle requests one', () => {
189
+ renderScatter({
190
+ getPointStyle: (p) => ({ shape: p.shape }),
191
+ series: [{ id: 's', data: [{ x: 1, y: 1, shape: 'square' }, { x: 2, y: 2, shape: 'diamond' }] }],
192
+ });
193
+ const Marker = scatterSpy.mock.calls.at(-1)[0].slots.marker;
194
+ const markerProps = { seriesId: 's', color: '#111', x: 0, y: 0, isHighlighted: false, isFaded: false, size: 5 };
195
+
196
+ const square = render(React.createElement(Marker, { ...markerProps, dataIndex: 0 }));
197
+ const diamond = render(React.createElement(Marker, { ...markerProps, dataIndex: 1 }));
198
+
199
+ expect(square.container.querySelector('rect')).not.toBeNull();
200
+ expect(diamond.container.querySelector('polygon')).not.toBeNull();
201
+ });
202
+ });
203
+
204
+ describe('ScatterReferenceCurve / ScatterReferenceLine (THEME-10)', () => {
205
+ it('draws a path through the curve points using the chart\'s own live scale', () => {
206
+ const { container } = render(React.createElement(ScatterReferenceCurve, {
207
+ points: [{ x: 0, y: 0 }, { x: 1, y: 1 }],
208
+ color: '#222',
209
+ }));
210
+ expect(container.querySelector('path').getAttribute('d')).toBe('M0,100 L10,90');
211
+ });
212
+
213
+ it('draws a labelled reference line at the given label position', () => {
214
+ const { container } = render(React.createElement(ScatterReferenceLine, {
215
+ from: { x: 0, y: 0 },
216
+ to: { x: 1, y: 1 },
217
+ label: 'No gap',
218
+ labelAt: { x: 0.9, y: 0.9 },
219
+ color: '#222',
220
+ }));
221
+ expect(container.querySelector('line')).not.toBeNull();
222
+ expect(container.textContent).toBe('No gap');
223
+ });
224
+
225
+ it('draws an unlabelled reference line when no label is given', () => {
226
+ const { container } = render(React.createElement(ScatterReferenceLine, {
227
+ from: { x: 0, y: 0 },
228
+ to: { x: 1, y: 1 },
229
+ color: '#222',
230
+ }));
231
+ expect(container.querySelector('line')).not.toBeNull();
232
+ expect(container.querySelector('text')).toBeNull();
233
+ });
234
+ });
@@ -216,12 +216,17 @@ describe('TimeSeriesChart', () => {
216
216
  ],
217
217
  };
218
218
 
219
- it('CHART-5: no custom yAxis when the shared single axis is not all-integer (today\'s real case)', () => {
219
+ it('CHART-5: no custom (integer-blanking) yAxis formatter when the shared single axis is not all-integer (today\'s real case)', () => {
220
220
  renderChart({ data: MIXED_DATA });
221
221
 
222
222
  const props = chartSpy.mock.calls.at(-1)[0];
223
223
  expect(props.yAxis).toHaveLength(1);
224
- expect(props.yAxis[0].valueFormatter).toBeUndefined();
224
+ // THEME-9: BarChart now always supplies a default (locale-grouped, non-
225
+ // blanking) tick formatter, so this is no longer undefined -- but it
226
+ // must still be BarChart's generic default, not CHART-5's own
227
+ // integer-blanking one (which would wrongly blank the 4.5/5.2/6.1 ticks).
228
+ expect(typeof props.yAxis[0].valueFormatter).toBe('function');
229
+ expect(props.yAxis[0].valueFormatter(4.5, { location: 'tick' })).not.toBe('');
225
230
  expect(props.yAxis[0].label).toBe('Count');
226
231
  });
227
232
 
@@ -293,8 +298,13 @@ describe('TimeSeriesChart', () => {
293
298
  const props = chartSpy.mock.calls.at(-1)[0];
294
299
  const primaryAxis = props.yAxis.find((axis) => axis.id === 'primary');
295
300
  const secondaryAxis = props.yAxis.find((axis) => axis.id === 'secondary');
296
- expect(typeof primaryAxis.valueFormatter).toBe('function');
297
- expect(secondaryAxis.valueFormatter).toBeUndefined();
301
+ expect(primaryAxis.valueFormatter(2, { location: 'tick' })).toBe('2');
302
+ expect(primaryAxis.valueFormatter(2.5, { location: 'tick' })).toBe('');
303
+ // THEME-9: BarChart's own default formatter now covers the secondary
304
+ // axis too (a function, not undefined) -- distinguished from the
305
+ // integer-blanking one above by NOT blanking a non-integer tick.
306
+ expect(typeof secondaryAxis.valueFormatter).toBe('function');
307
+ expect(secondaryAxis.valueFormatter(4.5, { location: 'tick' })).not.toBe('');
298
308
  });
299
309
 
300
310
  it('CHART-5: throws when a series declares axis "secondary" without secondaryYAxisLabel', () => {
@@ -318,12 +328,15 @@ describe('TimeSeriesChart', () => {
318
328
 
319
329
  it('CHART-5: toggling the non-integer series off switches the shared axis to integer-formatted', () => {
320
330
  renderChart({ data: MIXED_DATA });
321
- expect(chartSpy.mock.calls.at(-1)[0].yAxis[0].valueFormatter).toBeUndefined();
331
+ // THEME-9: BarChart's default formatter is present from the start now,
332
+ // so the CHART-5-specific signal is that a non-integer tick is NOT
333
+ // blanked yet -- the integer-blanking formatter isn't active.
334
+ expect(chartSpy.mock.calls.at(-1)[0].yAxis[0].valueFormatter(4.5, { location: 'tick' })).not.toBe('');
322
335
 
323
336
  fireEvent.click(screen.getByRole('checkbox', { name: 'Presence hours' }));
324
337
 
325
338
  const props = chartSpy.mock.calls.at(-1)[0];
326
- expect(typeof props.yAxis[0].valueFormatter).toBe('function');
339
+ expect(props.yAxis[0].valueFormatter(4.5, { location: 'tick' })).toBe('');
327
340
  });
328
341
 
329
342
  it('CHART-5: x-axis tickLabelInterval guarantees some labels always show, evenly spread', () => {
@@ -14,6 +14,13 @@ vi.mock('@mui/x-charts/LineChart', () => ({ LineChart: lineSpy, MarkElement: 'pa
14
14
  import { BarChart } from '../src/components/charts/BarChart';
15
15
  import { LineChart } from '../src/components/charts/LineChart';
16
16
  import { TimeSeriesChart } from '../src/components/charts/TimeSeriesChart';
17
+ import {
18
+ defaultNumericTickFormatter,
19
+ PACKAGE_DEFAULT_MARGIN,
20
+ sizeYAxisForContent,
21
+ withAxisDefaults,
22
+ withMarginDefaults,
23
+ } from '../src/components/charts/chartDefaults';
17
24
  import { createAppTheme } from '../src/theme/createAppTheme';
18
25
 
19
26
  const theme = createAppTheme({ palette: { primary: { main: '#3D5A99' } } });
@@ -153,15 +160,64 @@ describe('chart chrome defaults', () => {
153
160
  expect(barSpy.mock.calls.at(-1)[0].xAxis[0].scaleType).toBe('band');
154
161
  });
155
162
 
156
- it('adds a font-and-angle-derived bottom margin only for rotated tick labels', () => {
163
+ it('adds a font-and-angle-derived EXTRA bottom margin, on top of the package default, only for rotated tick labels', () => {
157
164
  renderBar({ xAxis: [{ data: ['Jan', 'Feb'], tickLabelStyle: { angle: -45 } }] });
158
165
  const rotatedProps = barSpy.mock.calls.at(-1)[0];
159
- expect(rotatedProps.margin.bottom).toBeGreaterThan(20);
166
+ expect(rotatedProps.margin.bottom).toBeGreaterThan(PACKAGE_DEFAULT_MARGIN.bottom);
160
167
  expect(rotatedProps.xAxis[0].height).toBeGreaterThan(45);
161
168
 
162
169
  cleanup();
163
170
  renderBar({ xAxis: [{ data: ['Jan', 'Feb'], tickLabelStyle: { angle: 0 } }] });
164
- expect(barSpy.mock.calls.at(-1)[0].margin).toBeUndefined();
171
+ expect(barSpy.mock.calls.at(-1)[0].margin).toEqual(PACKAGE_DEFAULT_MARGIN);
172
+ });
173
+
174
+ // THEME-11 -- the package now forms an opinion on chart margins (was: MUI's own flat 20px on
175
+ // every side, unconditionally, because this package never set one).
176
+ describe('withMarginDefaults / package margin default (THEME-11)', () => {
177
+ it('applies the package default on every side when the caller sets no margin', () => {
178
+ renderBar();
179
+ expect(barSpy.mock.calls.at(-1)[0].margin).toEqual(PACKAGE_DEFAULT_MARGIN);
180
+ });
181
+
182
+ // Trap 1 named in the WO: a package-level default injected BEFORE spaceForRotatedTicks'
183
+ // own `margin?.bottom != null` check would make every rotated-tick chart think the caller
184
+ // set bottom, and lose its extra rotation space entirely.
185
+ it('still gives a rotated-tick chart its enlarged bottom margin, not the flat package default', () => {
186
+ renderBar({ xAxis: [{ data: ['January', 'February'], tickLabelStyle: { angle: -90 } }] });
187
+ const props = barSpy.mock.calls.at(-1)[0];
188
+ expect(props.margin.bottom).toBeGreaterThan(PACKAGE_DEFAULT_MARGIN.bottom + 10);
189
+ });
190
+
191
+ // Scope 1's merge semantics: a caller-supplied PARTIAL margin keeps its own value on the
192
+ // side it set and gets the package default on the others -- never MUI's wider 20px, and
193
+ // never silently reset to the package default on the side the caller DID set.
194
+ it('merges a caller-supplied partial margin per side, not wholesale', () => {
195
+ renderBar({ margin: { left: 60 } });
196
+ const { margin } = barSpy.mock.calls.at(-1)[0];
197
+ expect(margin.left).toBe(60);
198
+ expect(margin.top).toBe(PACKAGE_DEFAULT_MARGIN.top);
199
+ expect(margin.bottom).toBe(PACKAGE_DEFAULT_MARGIN.bottom);
200
+ expect(margin.right).toBe(PACKAGE_DEFAULT_MARGIN.right);
201
+ });
202
+
203
+ it('keeps every side a caller sets when they supply a full margin object', () => {
204
+ renderBar({ margin: { top: 1, bottom: 2, left: 3, right: 4 } });
205
+ expect(barSpy.mock.calls.at(-1)[0].margin).toEqual({ top: 1, bottom: 2, left: 3, right: 4 });
206
+ });
207
+
208
+ // A LINEAR x-axis's last tick label sits AT the plot edge (unlike a band axis's centred
209
+ // ticks) and overhangs by about half its width -- the reason `right` is trimmed less than
210
+ // `left`/`bottom`. Pinned here as a value; the actual no-clip claim is verified by the
211
+ // rendered check (WO Part C), not derivable from a mocked MUI component.
212
+ it('keeps a wider default right margin than left/bottom, for a linear x-axis\'s edge-sitting last tick', () => {
213
+ expect(PACKAGE_DEFAULT_MARGIN.right).toBeGreaterThan(PACKAGE_DEFAULT_MARGIN.left);
214
+ expect(PACKAGE_DEFAULT_MARGIN.right).toBeGreaterThan(PACKAGE_DEFAULT_MARGIN.bottom);
215
+ });
216
+
217
+ it('applies the SAME package default to LineChart as BarChart', () => {
218
+ renderLine();
219
+ expect(lineSpy.mock.calls.at(-1)[0].margin).toEqual(PACKAGE_DEFAULT_MARGIN);
220
+ });
165
221
  });
166
222
 
167
223
  it('uses the series colour as the fill when a caller opts line markers back in', () => {
@@ -206,3 +262,174 @@ describe('chart chrome defaults', () => {
206
262
  });
207
263
  });
208
264
  });
265
+
266
+ // THEME-9 #2 -- `withAxisDefaults` broadcast the caller's single xAxisLabel/
267
+ // yAxisLabel string onto EVERY entry of a multi-axis array via `.map()`.
268
+ // Measured live: hram's dual-axis chart carried the identical 43-character
269
+ // label on both y-axes. This is the defect that actually shipped, pinned
270
+ // directly against the exported function rather than only through a
271
+ // rendered BarChart (which never happens to build a multi-axis array).
272
+ describe('withAxisDefaults label broadcast (THEME-9)', () => {
273
+ it('applies the single label argument only to the first axis of a multi-entry array', () => {
274
+ const result = withAxisDefaults(
275
+ [{ id: 'primary' }, { id: 'secondary' }],
276
+ 'Shared label',
277
+ { scaleType: 'linear' },
278
+ '12px',
279
+ );
280
+ expect(result[0].label).toBe('Shared label');
281
+ expect(result[1].label).toBeUndefined();
282
+ // The exact defect: never let one string land on two axes.
283
+ expect(result.filter((axis) => axis.label === 'Shared label')).toHaveLength(1);
284
+ });
285
+
286
+ it('leaves axes that set their own label untouched, whatever position they are in', () => {
287
+ const result = withAxisDefaults(
288
+ [{ label: 'Primary' }, { label: 'Secondary' }],
289
+ 'Fallback',
290
+ { scaleType: 'linear' },
291
+ '12px',
292
+ );
293
+ expect(result[0].label).toBe('Primary');
294
+ expect(result[1].label).toBe('Secondary');
295
+ });
296
+
297
+ it('still labels the common single-axis case unchanged', () => {
298
+ const result = withAxisDefaults([{ id: 'only' }], 'Cases', { scaleType: 'linear' }, '12px');
299
+ expect(result[0].label).toBe('Cases');
300
+
301
+ const synthesized = withAxisDefaults(undefined, 'Cases', { scaleType: 'linear' }, '12px');
302
+ expect(synthesized[0].label).toBe('Cases');
303
+ });
304
+ });
305
+
306
+ // THEME-9 #4 -- BarChart set no default tick formatter at all (raw domain
307
+ // numbers); a hand-rolled `Intl` compact-notation default was measured to be
308
+ // actively wrong in two of the four locales this package's consumers use:
309
+ // German does not compact thousands at all, and Swahili prepends a word
310
+ // ("elfu") that is WIDER than the raw number. Plain grouped formatting (no
311
+ // `notation: 'compact'`) is pinned here against a live `Intl.NumberFormat`
312
+ // computation rather than hardcoded locale punctuation, so the assertion
313
+ // survives ICU data updates.
314
+ describe('defaultNumericTickFormatter (THEME-9)', () => {
315
+ it.each(['de-CH', 'en', 'fr', 'sw'])('formats %s ticks the same way Intl grouping would, with no compact notation', (locale) => {
316
+ const formatter = defaultNumericTickFormatter(locale);
317
+ for (const value of [12500, 998000, 1000000000]) {
318
+ const expected = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value);
319
+ expect(formatter(value, { location: 'tick' })).toBe(expected);
320
+ // The exact failure mode measured live: compact notation prepending a
321
+ // wider word in Swahili, or a 'K'/'M'/'B' suffix elsewhere.
322
+ expect(formatter(value, { location: 'tick' })).not.toMatch(/[KMB]|elfu/i);
323
+ }
324
+ });
325
+
326
+ it('keeps full precision outside the tick context (tooltip/legend) rather than rounding away a meaningful difference', () => {
327
+ const formatter = defaultNumericTickFormatter('en');
328
+ expect(formatter(12500.456, { location: 'tooltip' })).toBe('12,500.456');
329
+ expect(formatter(12500.456, { location: 'tick' })).toBe('12,500.5');
330
+ });
331
+ });
332
+
333
+ // THEME-9 #1/#3 -- MUI's own y-axis width default is a flat 45px (ticks) +
334
+ // 20px (label) regardless of what the ticks actually say (confirmed against
335
+ // @mui/x-charts' own DEFAULT_AXIS_SIZE_WIDTH/AXIS_LABEL_DEFAULT_HEIGHT and
336
+ // its useChartDimensions selector, where margin and axis size are additive).
337
+ // That is both too generous for short ticks and too stingy for wide
338
+ // unit-suffixed ones -- the measured Accessibility-panel overlap. Margins
339
+ // and the exact rendered overlap are verified live, not by unit test (per
340
+ // the WO); this pins the sizing FUNCTION's own contract.
341
+ describe('sizeYAxisForContent (THEME-9)', () => {
342
+ it('sizes a linear axis from its own caller-supplied min/max', () => {
343
+ const [axis] = sizeYAxisForContent(
344
+ [{ scaleType: 'linear', min: 0, max: 1, label: 'Coverage (%)' }],
345
+ [],
346
+ '12px',
347
+ );
348
+ expect(axis.width).toBeGreaterThan(0);
349
+ });
350
+
351
+ it('reserves more width for a longer formatted tick, all else equal', () => {
352
+ const [shortTick] = sizeYAxisForContent(
353
+ [{ scaleType: 'linear', min: 0, max: 1 }], [], '12px',
354
+ );
355
+ const [wideTick] = sizeYAxisForContent(
356
+ [{ scaleType: 'linear', min: 0, max: 1, valueFormatter: (v) => `${v} minutes` }],
357
+ [], '12px',
358
+ );
359
+ expect(wideTick.width).toBeGreaterThan(shortTick.width);
360
+ });
361
+
362
+ it('falls back to the plotted series when the caller sets neither min nor max', () => {
363
+ const [axis] = sizeYAxisForContent(
364
+ [{ scaleType: 'linear' }],
365
+ [{ data: [1, 250000] }],
366
+ '12px',
367
+ );
368
+ expect(axis.width).toBeGreaterThan(0);
369
+ });
370
+
371
+ it('leaves the axis untouched when there is nothing to measure against', () => {
372
+ const [axis] = sizeYAxisForContent([{ scaleType: 'linear' }], [], '12px');
373
+ expect(axis.width).toBeUndefined();
374
+ });
375
+
376
+ it('never overrides a caller-supplied width', () => {
377
+ const [axis] = sizeYAxisForContent(
378
+ [{ scaleType: 'linear', min: 0, max: 1, width: 42 }], [], '12px',
379
+ );
380
+ expect(axis.width).toBe(42);
381
+ });
382
+
383
+ it('leaves a categorical (band/point) axis alone -- its ticks are names, not numbers', () => {
384
+ const [axis] = sizeYAxisForContent(
385
+ [{ scaleType: 'band', data: ['a', 'b'] }], [], '12px',
386
+ );
387
+ expect(axis.width).toBeUndefined();
388
+ });
389
+
390
+ // Reviewer-caught regression (THEME-9): a series with no explicit
391
+ // `yAxisId` is NOT "assigned to no axis" -- MUI defaults it to the FIRST
392
+ // axis in the array (`yAxisIds[0]`), whatever that axis' id is. Matching
393
+ // only `undefined === undefined` left the primary axis of a genuine
394
+ // dual-axis chart (id set, series unmarked) with zero matched series and
395
+ // therefore MUI's untouched flat default -- a silent no-op on exactly the
396
+ // dual-axis case this WO was measured against (TimeSeriesChart's own
397
+ // secondary-axis feature, CHART-5).
398
+ it('assigns an unmarked series to the FIRST axis in a dual-axis array, matching MUI\'s own default', () => {
399
+ const dualAxis = [{ scaleType: 'linear', id: 'primary' }, { scaleType: 'linear', id: 'secondary' }];
400
+ const series = [
401
+ { data: [1, 2, 3] }, // no yAxisId -- MUI assigns this to 'primary'
402
+ { data: [400000, 500000], yAxisId: 'secondary' },
403
+ ];
404
+
405
+ const [primary, secondary] = sizeYAxisForContent(dualAxis, series, '12px');
406
+ expect(primary.width).toBeGreaterThan(0);
407
+ expect(secondary.width).toBeGreaterThan(0);
408
+ // The secondary axis' series format much wider numbers -- it must
409
+ // reserve more width than the primary axis, not the same (which is what
410
+ // "both axes silently untouched" or "both matched the same series" would
411
+ // produce).
412
+ expect(secondary.width).toBeGreaterThan(primary.width);
413
+ });
414
+ });
415
+
416
+ // THEME-11 -- pure-function contract of the merge helper itself, independent of any chart
417
+ // component wiring (that's covered above, in 'chart chrome defaults').
418
+ describe('withMarginDefaults (THEME-11)', () => {
419
+ it('returns the package default untouched when the caller sets no margin', () => {
420
+ expect(withMarginDefaults(undefined)).toEqual(PACKAGE_DEFAULT_MARGIN);
421
+ });
422
+
423
+ it('merges a partial object per side', () => {
424
+ expect(withMarginDefaults({ top: 40 })).toEqual({ ...PACKAGE_DEFAULT_MARGIN, top: 40 });
425
+ });
426
+
427
+ it('expands a numeric shorthand margin to all four sides before merging', () => {
428
+ expect(withMarginDefaults(5)).toEqual({ top: 5, bottom: 5, left: 5, right: 5 });
429
+ });
430
+
431
+ it('leaves a fully-specified caller margin untouched', () => {
432
+ const full = { top: 1, bottom: 2, left: 3, right: 4 };
433
+ expect(withMarginDefaults(full)).toEqual(full);
434
+ });
435
+ });
@@ -37,6 +37,26 @@ describe('chart palette and formatters', () => {
37
37
  expect(createChartFormatters('fr-FR').ratio(1.5)).toContain('1');
38
38
  });
39
39
 
40
+ // THEME-10: an undifferentiated scatter cloud (no colour dimension encoded) needs a single
41
+ // neutral tone, distinct from the categorical/sequential arrays above.
42
+ it('resolves a single neutral tone from theme.palette.text.secondary', () => {
43
+ const theme = {
44
+ palette: {
45
+ mode: 'light',
46
+ primary: { main: 'primary-main' },
47
+ secondary: { main: 'secondary-main' },
48
+ info: { main: 'info-main' },
49
+ success: { main: 'success-main' },
50
+ warning: { main: 'warning-main' },
51
+ action: { disabled: 'action-disabled' },
52
+ text: { secondary: 'text-secondary' },
53
+ divider: 'divider',
54
+ },
55
+ };
56
+
57
+ expect(getNeutralChartPalette(theme).neutral).toBe('text-secondary');
58
+ });
59
+
40
60
  it('uses the theme categorical ramp when one is present', () => {
41
61
  const ramp = ['series-1', 'series-2', 'series-3'];
42
62
  const theme = {