@micha.bigler/ui-core-micha 2.40.0 → 2.41.1

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
+ });
@@ -0,0 +1,178 @@
1
+ // @vitest-environment jsdom
2
+ // AUTH-5: addPublicPath/matchesPublicPath/removePublicPath must accept a RegExp alongside a
3
+ // string, evaluated exclusively against pathname. Driven through the real response interceptor
4
+ // (not a reimplementation of matchesPublicPath) so these tests fail if the wiring, not just the
5
+ // matcher, breaks.
6
+ //
7
+ // The module keeps a `redirectingToLogin` latch that only resets on a real navigation, which
8
+ // never happens in jsdom — so each test re-imports a fresh module instance via
9
+ // vi.resetModules()/dynamic import, rather than sharing one import across the file.
10
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
11
+
12
+ function notAuthenticatedError(status = 401) {
13
+ return {
14
+ response: {
15
+ status,
16
+ data: { code: 'not_authenticated' },
17
+ },
18
+ config: {},
19
+ };
20
+ }
21
+
22
+ async function rejectThroughInterceptor(client, error) {
23
+ const handler = client.interceptors.response.handlers.find(Boolean);
24
+ try {
25
+ await handler.rejected(error);
26
+ } catch {
27
+ // The interceptor always re-rejects after handling — expected, not the assertion.
28
+ }
29
+ }
30
+
31
+ // jsdom's real `window.location` ties `pathname` to `history.pushState` through internal
32
+ // wiring that a deleted/replaced `location` object doesn't preserve, and `assign` is
33
+ // non-configurable so `vi.spyOn` can't touch it directly either. Sidestep both by installing a
34
+ // fully synthetic `location` this file controls end to end — the interceptor only ever reads
35
+ // `.pathname` and calls `.assign()`, so nothing else needs to be real.
36
+ function installLocationMock(initialPathname = '/') {
37
+ const assign = vi.fn();
38
+ delete window.location;
39
+ window.location = { pathname: initialPathname, search: '', hash: '', assign };
40
+ return assign;
41
+ }
42
+
43
+ function setPathname(pathname) {
44
+ window.location.pathname = pathname;
45
+ }
46
+
47
+ describe('apiClient public-path registry (AUTH-5)', () => {
48
+ let assignSpy;
49
+ let apiClient;
50
+ let addPublicPath;
51
+ let removePublicPath;
52
+
53
+ beforeEach(async () => {
54
+ vi.resetModules();
55
+ ({ default: apiClient, addPublicPath, removePublicPath } = await import('../src/auth/apiClient'));
56
+ assignSpy = installLocationMock();
57
+ });
58
+
59
+ it('string registration still matches by prefix (regression)', async () => {
60
+ setPathname('/custom-public');
61
+ addPublicPath('/custom-public');
62
+
63
+ await rejectThroughInterceptor(apiClient, notAuthenticatedError());
64
+
65
+ expect(assignSpy).not.toHaveBeenCalled();
66
+ });
67
+
68
+ it('a bare "/" registration does not match a deeper path via startsWith (regression)', async () => {
69
+ addPublicPath('/');
70
+ setPathname('/somewhere-else');
71
+
72
+ await rejectThroughInterceptor(apiClient, notAuthenticatedError());
73
+
74
+ expect(assignSpy).toHaveBeenCalledWith('/login');
75
+ });
76
+
77
+ it('a registered RegExp matches the intended dynamic path', async () => {
78
+ addPublicPath(/^\/[^/]+\/team$/);
79
+ setPathname('/jg-bern/team');
80
+
81
+ await rejectThroughInterceptor(apiClient, notAuthenticatedError());
82
+
83
+ expect(assignSpy).not.toHaveBeenCalled();
84
+ });
85
+
86
+ it('a non-matching path still redirects even with an unrelated RegExp registered', async () => {
87
+ addPublicPath(/^\/[^/]+\/team$/);
88
+ setPathname('/account');
89
+
90
+ await rejectThroughInterceptor(apiClient, notAuthenticatedError());
91
+
92
+ expect(assignSpy).toHaveBeenCalledWith('/login');
93
+ });
94
+
95
+ it('the RegExp is tested against pathname, not the query string', async () => {
96
+ // A pattern that would only match if the query string leaked into the tested value.
97
+ setPathname('/blocked-page');
98
+ window.location.search = '?next=/public';
99
+ addPublicPath(/public/);
100
+
101
+ await rejectThroughInterceptor(apiClient, notAuthenticatedError());
102
+
103
+ expect(assignSpy).toHaveBeenCalledWith('/login');
104
+ });
105
+
106
+ it('the RegExp is tested against pathname, not the fragment', async () => {
107
+ setPathname('/blocked-page');
108
+ window.location.hash = '#panel';
109
+ addPublicPath(/panel/);
110
+
111
+ await rejectThroughInterceptor(apiClient, notAuthenticatedError());
112
+
113
+ expect(assignSpy).toHaveBeenCalledWith('/login');
114
+ });
115
+
116
+ it('a pathname-anchored RegExp still matches when the URL also carries a query string', async () => {
117
+ setPathname('/public');
118
+ window.location.search = '?next=/admin';
119
+ addPublicPath(/^\/public$/);
120
+
121
+ await rejectThroughInterceptor(apiClient, notAuthenticatedError());
122
+
123
+ expect(assignSpy).not.toHaveBeenCalled();
124
+ });
125
+
126
+ it('removePublicPath removes a RegExp entry by identity', async () => {
127
+ const pattern = /^\/temp$/;
128
+ addPublicPath(pattern);
129
+ removePublicPath(pattern);
130
+ setPathname('/temp');
131
+
132
+ await rejectThroughInterceptor(apiClient, notAuthenticatedError());
133
+
134
+ expect(assignSpy).toHaveBeenCalledWith('/login');
135
+ });
136
+
137
+ it('removePublicPath does not remove a RegExp entry given only an equal-looking literal', async () => {
138
+ addPublicPath(/^\/temp$/);
139
+ removePublicPath(/^\/temp$/); // a different object, same source — must NOT remove the original
140
+ setPathname('/temp');
141
+
142
+ await rejectThroughInterceptor(apiClient, notAuthenticatedError());
143
+
144
+ expect(assignSpy).not.toHaveBeenCalled();
145
+ });
146
+
147
+ it('removePublicPath cannot remove a builtin path', async () => {
148
+ removePublicPath('/login');
149
+ setPathname('/login');
150
+
151
+ await rejectThroughInterceptor(apiClient, notAuthenticatedError());
152
+
153
+ expect(assignSpy).not.toHaveBeenCalled();
154
+ });
155
+
156
+ it('a stateful (global-flag) RegExp matches consistently across repeated calls', async () => {
157
+ // Without resetting lastIndex, a `g`-flagged RegExp would alternate
158
+ // matched/unmatched across calls since it is reused from the module-level Set.
159
+ addPublicPath(/^\/public$/g);
160
+ setPathname('/public');
161
+
162
+ await rejectThroughInterceptor(apiClient, notAuthenticatedError());
163
+ expect(assignSpy).not.toHaveBeenCalled();
164
+
165
+ await rejectThroughInterceptor(apiClient, notAuthenticatedError());
166
+ expect(assignSpy).not.toHaveBeenCalled();
167
+ });
168
+
169
+ it('a RegExp entry in the registry does not break evaluation of string entries', async () => {
170
+ addPublicPath(/^\/gallery\/\d+$/);
171
+ addPublicPath('/custom-string');
172
+ setPathname('/custom-string');
173
+
174
+ await rejectThroughInterceptor(apiClient, notAuthenticatedError());
175
+
176
+ expect(assignSpy).not.toHaveBeenCalled();
177
+ });
178
+ });
@@ -16,8 +16,10 @@ import { LineChart } from '../src/components/charts/LineChart';
16
16
  import { TimeSeriesChart } from '../src/components/charts/TimeSeriesChart';
17
17
  import {
18
18
  defaultNumericTickFormatter,
19
+ PACKAGE_DEFAULT_MARGIN,
19
20
  sizeYAxisForContent,
20
21
  withAxisDefaults,
22
+ withMarginDefaults,
21
23
  } from '../src/components/charts/chartDefaults';
22
24
  import { createAppTheme } from '../src/theme/createAppTheme';
23
25
 
@@ -158,15 +160,64 @@ describe('chart chrome defaults', () => {
158
160
  expect(barSpy.mock.calls.at(-1)[0].xAxis[0].scaleType).toBe('band');
159
161
  });
160
162
 
161
- 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', () => {
162
164
  renderBar({ xAxis: [{ data: ['Jan', 'Feb'], tickLabelStyle: { angle: -45 } }] });
163
165
  const rotatedProps = barSpy.mock.calls.at(-1)[0];
164
- expect(rotatedProps.margin.bottom).toBeGreaterThan(20);
166
+ expect(rotatedProps.margin.bottom).toBeGreaterThan(PACKAGE_DEFAULT_MARGIN.bottom);
165
167
  expect(rotatedProps.xAxis[0].height).toBeGreaterThan(45);
166
168
 
167
169
  cleanup();
168
170
  renderBar({ xAxis: [{ data: ['Jan', 'Feb'], tickLabelStyle: { angle: 0 } }] });
169
- 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
+ });
170
221
  });
171
222
 
172
223
  it('uses the series colour as the fill when a caller opts line markers back in', () => {
@@ -361,3 +412,24 @@ describe('sizeYAxisForContent (THEME-9)', () => {
361
412
  expect(secondary.width).toBeGreaterThan(primary.width);
362
413
  });
363
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 = {