@micha.bigler/ui-core-micha 2.42.2 → 3.0.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.
@@ -233,39 +233,36 @@ describe('ScatterReferenceCurve / ScatterReferenceLine (THEME-10)', () => {
233
233
  });
234
234
  });
235
235
 
236
- // CHART-8: minHeight/height/aspect resolution, wired through the actual component.
237
- describe('ScatterChart minHeight/height resolution (CHART-8)', () => {
236
+ // UCM-CHART-12: `size`/`height` resolution, wired through the actual component. Replaces the
237
+ // deleted `minHeight`/`aspect` CHART-8 trio.
238
+ describe('ScatterChart size/height resolution (UCM-CHART-12)', () => {
238
239
  afterEach(() => { cleanup(); vi.clearAllMocks(); });
239
240
 
240
- it('sizes the chart from minHeight alone when neither height nor aspect is set', () => {
241
- renderScatter({ minHeight: 300 });
242
- expect(scatterSpy.mock.calls.at(-1)[0].height).toBe(300);
243
- expect(window.getComputedStyle(screen.getByTestId('scatter-chart-container')).minHeight).toBe('300px');
241
+ it.each([
242
+ ['compact', 240],
243
+ ['standard', 320],
244
+ ['tall', 400],
245
+ ])('resolves size="%s" to %ipx, on both the chart height and the wrapper', (size, px) => {
246
+ renderScatter({ size });
247
+ expect(scatterSpy.mock.calls.at(-1)[0].height).toBe(px);
248
+ expect(window.getComputedStyle(screen.getByTestId('scatter-chart-container')).height).toBe(`${px}px`);
244
249
  });
245
250
 
246
- it('leaves minHeight as a floor and gives the chart no fixed height when aspect is set (no height)', () => {
247
- renderScatter({ minHeight: 320, aspect: 1.8 });
248
- expect(scatterSpy.mock.calls.at(-1)[0].height).toBeUndefined();
249
- const style = window.getComputedStyle(screen.getByTestId('scatter-chart-container'));
250
- expect(style.minHeight).toBe('320px');
251
- expect(style.aspectRatio).toBe('1.8 / 1');
251
+ it('lets height override size entirely, as the documented escape', () => {
252
+ renderScatter({ size: 'tall', height: 280 });
253
+ expect(scatterSpy.mock.calls.at(-1)[0].height).toBe(280);
254
+ expect(window.getComputedStyle(screen.getByTestId('scatter-chart-container')).height).toBe('280px');
252
255
  });
253
256
 
254
- it('caps the wrapper at height when minHeight is larger, closing the dead-space gap', () => {
255
- renderScatter({ minHeight: 420, height: 380 });
256
- expect(scatterSpy.mock.calls.at(-1)[0].height).toBe(380);
257
- expect(window.getComputedStyle(screen.getByTestId('scatter-chart-container')).minHeight).toBe('380px');
257
+ it('throws in dev when a caller still passes minHeight, naming the replacement', () => {
258
+ expect(() => renderScatter({ minHeight: 300 })).toThrow(/size=.*standard.*tall/);
258
259
  });
259
260
 
260
- it('is byte-identical when minHeight equals height', () => {
261
- renderScatter({ minHeight: 320, height: 320 });
262
- expect(scatterSpy.mock.calls.at(-1)[0].height).toBe(320);
263
- expect(window.getComputedStyle(screen.getByTestId('scatter-chart-container')).minHeight).toBe('320px');
261
+ it('throws in dev when a caller still passes aspect, naming the removal', () => {
262
+ expect(() => renderScatter({ aspect: 1.8 })).toThrow(/aspect/);
264
263
  });
265
264
 
266
- it('is unchanged when height is passed alone', () => {
267
- renderScatter({ height: 280 });
268
- expect(scatterSpy.mock.calls.at(-1)[0].height).toBe(280);
269
- expect(window.getComputedStyle(screen.getByTestId('scatter-chart-container')).minHeight).toBe('auto');
265
+ it('throws in dev when a caller still passes margin, naming the removal', () => {
266
+ expect(() => renderScatter({ margin: { top: 1 } })).toThrow(/margin/);
270
267
  });
271
268
  });
@@ -15,14 +15,13 @@ 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
17
  import {
18
- defaultNumericTickFormatter,
18
+ CHART_SIZE_SPACING_UNITS,
19
19
  PACKAGE_DEFAULT_MARGIN,
20
- resolveChartHeight,
20
+ assertRemovedChartProp,
21
+ defaultNumericTickFormatter,
22
+ resolveChartLayout,
21
23
  sizeYAxisForContent,
22
- spaceForRotatedTicks,
23
- warnOnHeightMismatch,
24
24
  withAxisDefaults,
25
- withMarginDefaults,
26
25
  } from '../src/components/charts/chartDefaults';
27
26
  import { createAppTheme } from '../src/theme/createAppTheme';
28
27
 
@@ -113,6 +112,9 @@ describe('chart chrome defaults', () => {
113
112
  expect(timeSeriesProps.yAxis[0].tickLabelStyle.fontSize).toBe(theme.typography.caption.fontSize);
114
113
  expect(timeSeriesProps.grid).toEqual({ horizontal: true });
115
114
  expect(timeSeriesProps.skipAnimation).toBe(true);
115
+ // UCM-CHART-12: TimeSeriesChart's inner BarChart now takes size="standard" instead of a
116
+ // fixed height prop -- the resolved height must still land on the historical 320px default.
117
+ expect(timeSeriesProps.height).toBe(320);
116
118
  });
117
119
 
118
120
  it('keeps caller tick font, grid, showMark, and legendPosition values instead of the defaults', () => {
@@ -163,65 +165,71 @@ describe('chart chrome defaults', () => {
163
165
  expect(barSpy.mock.calls.at(-1)[0].xAxis[0].scaleType).toBe('band');
164
166
  });
165
167
 
166
- it('CHART-10: grows only xAxis.height for rotated tick labels, never margin.bottom on top of it', () => {
167
- renderBar({ xAxis: [{ data: ['Jan', 'Feb'], tickLabelStyle: { angle: -45 } }] });
168
+ // UCM-CHART-12: rotation is now an explicit xLabels="angled" request, not an implicit
169
+ // consequence of a caller-supplied tickLabelStyle.angle -- replaces the old CHART-10 regression
170
+ // test (grows only xAxis.height, never margin.bottom on top of it) against the new API.
171
+ it('grows only xAxis.height for xLabels="angled", never margin.bottom on top of it', () => {
172
+ renderBar({
173
+ xAxis: [{ data: ['A label long enough to need real rotation room'] }],
174
+ xLabels: 'angled',
175
+ });
168
176
  const rotatedProps = barSpy.mock.calls.at(-1)[0];
169
177
  expect(rotatedProps.margin.bottom).toBe(PACKAGE_DEFAULT_MARGIN.bottom);
170
178
  expect(rotatedProps.xAxis[0].height).toBeGreaterThan(45);
171
179
 
172
180
  cleanup();
173
- renderBar({ xAxis: [{ data: ['Jan', 'Feb'], tickLabelStyle: { angle: 0 } }] });
174
- expect(barSpy.mock.calls.at(-1)[0].margin).toEqual(PACKAGE_DEFAULT_MARGIN);
175
- });
176
-
177
- // THEME-11 -- the package now forms an opinion on chart margins (was: MUI's own flat 20px on
178
- // every side, unconditionally, because this package never set one).
179
- describe('withMarginDefaults / package margin default (THEME-11)', () => {
180
- it('applies the package default on every side when the caller sets no margin', () => {
181
- renderBar();
181
+ renderBar({ xAxis: [{ data: ['Jan', 'Feb'] }], xLabels: 'horizontal' });
182
+ expect(barSpy.mock.calls.at(-1)[0].margin.bottom).toBe(PACKAGE_DEFAULT_MARGIN.bottom);
183
+ });
184
+
185
+ // THEME-11 -- the package forms its own opinion on chart margins (was: MUI's own flat 20px on
186
+ // every side). UCM-CHART-12: there is no caller-facing `margin` prop any more (the model owns
187
+ // spacing completely), so these assert the rendered default directly rather than through
188
+ // `withMarginDefaults`, which UCM-CHART-12 removed as dead code once its only caller (the
189
+ // deleted `margin` prop) was gone.
190
+ describe('package margin default (THEME-11 / UCM-CHART-12)', () => {
191
+ it('applies the package default on every side when there is no legend to make room for', () => {
192
+ renderBar({ hideLegend: true });
182
193
  expect(barSpy.mock.calls.at(-1)[0].margin).toEqual(PACKAGE_DEFAULT_MARGIN);
183
194
  });
184
195
 
185
- // CHART-10: the axis band, not the margin, carries the rotated-tick allowance -- a
186
- // rotated-tick chart gets the SAME flat package bottom margin as any other chart. Named
187
- // "still gives" in CHART-8/9 history; the correct behaviour is now the opposite of that name.
188
196
  it('gives a rotated-tick chart the same flat package bottom margin as an unrotated one', () => {
189
- renderBar({ xAxis: [{ data: ['January', 'February'], tickLabelStyle: { angle: -90 } }] });
197
+ renderBar({
198
+ xAxis: [{ data: ['January', 'February'] }],
199
+ xLabels: 'angled',
200
+ hideLegend: true,
201
+ });
190
202
  const props = barSpy.mock.calls.at(-1)[0];
191
203
  expect(props.margin.bottom).toBe(PACKAGE_DEFAULT_MARGIN.bottom);
192
204
  expect(props.xAxis[0].height).toBeGreaterThan(45);
193
205
  });
194
206
 
195
- // Scope 1's merge semantics: a caller-supplied PARTIAL margin keeps its own value on the
196
- // side it set and gets the package default on the others -- never MUI's wider 20px, and
197
- // never silently reset to the package default on the side the caller DID set.
198
- it('merges a caller-supplied partial margin per side, not wholesale', () => {
199
- renderBar({ margin: { left: 60 } });
200
- const { margin } = barSpy.mock.calls.at(-1)[0];
201
- expect(margin.left).toBe(60);
202
- expect(margin.top).toBe(PACKAGE_DEFAULT_MARGIN.top);
203
- expect(margin.bottom).toBe(PACKAGE_DEFAULT_MARGIN.bottom);
204
- expect(margin.right).toBe(PACKAGE_DEFAULT_MARGIN.right);
205
- });
206
-
207
- it('keeps every side a caller sets when they supply a full margin object', () => {
208
- renderBar({ margin: { top: 1, bottom: 2, left: 3, right: 4 } });
209
- expect(barSpy.mock.calls.at(-1)[0].margin).toEqual({ top: 1, bottom: 2, left: 3, right: 4 });
210
- });
211
-
212
- // A LINEAR x-axis's last tick label sits AT the plot edge (unlike a band axis's centred
213
- // ticks) and overhangs by about half its width -- the reason `right` is trimmed less than
214
- // `left`/`bottom`. Pinned here as a value; the actual no-clip claim is verified by the
215
- // rendered check (WO Part C), not derivable from a mocked MUI component.
216
207
  it('keeps a wider default right margin than left/bottom, for a linear x-axis\'s edge-sitting last tick', () => {
217
208
  expect(PACKAGE_DEFAULT_MARGIN.right).toBeGreaterThan(PACKAGE_DEFAULT_MARGIN.left);
218
209
  expect(PACKAGE_DEFAULT_MARGIN.right).toBeGreaterThan(PACKAGE_DEFAULT_MARGIN.bottom);
219
210
  });
220
211
 
221
212
  it('applies the SAME package default to LineChart as BarChart', () => {
222
- renderLine();
213
+ renderLine({ hideLegend: true });
223
214
  expect(lineSpy.mock.calls.at(-1)[0].margin).toEqual(PACKAGE_DEFAULT_MARGIN);
224
215
  });
216
+
217
+ it('grows margin.bottom by the legend band when a legend renders at the bottom (the default)', () => {
218
+ renderBar({ series: [{ data: [1] }, { data: [2] }] }); // 2 series -> legend shown
219
+ const props = barSpy.mock.calls.at(-1)[0];
220
+ expect(props.margin.bottom).toBeGreaterThan(PACKAGE_DEFAULT_MARGIN.bottom);
221
+ expect(props.margin.top).toBe(PACKAGE_DEFAULT_MARGIN.top);
222
+ });
223
+
224
+ it('grows margin.top instead when legendPosition.vertical is "top"', () => {
225
+ renderBar({
226
+ series: [{ data: [1] }, { data: [2] }],
227
+ legendPosition: { vertical: 'top' },
228
+ });
229
+ const props = barSpy.mock.calls.at(-1)[0];
230
+ expect(props.margin.top).toBeGreaterThan(PACKAGE_DEFAULT_MARGIN.top);
231
+ expect(props.margin.bottom).toBe(PACKAGE_DEFAULT_MARGIN.bottom);
232
+ });
225
233
  });
226
234
 
227
235
  it('uses the series colour as the fill when a caller opts line markers back in', () => {
@@ -267,6 +275,269 @@ describe('chart chrome defaults', () => {
267
275
  });
268
276
  });
269
277
 
278
+ // UCM-CHART-12: removed props (`minHeight`, `aspect`, `margin`) throw a dev-mode error naming
279
+ // their replacement instead of being silently ignored -- 38 existing call sites across the estate
280
+ // hit this. Gated on NODE_ENV so a shared package cannot crash a consumer's production page.
281
+ describe('removed chart props throw in dev, naming their replacement (UCM-CHART-12)', () => {
282
+ afterEach(cleanup);
283
+
284
+ it('assertRemovedChartProp is a no-op when the value is unset', () => {
285
+ expect(() => assertRemovedChartProp('BarChart', 'minHeight', undefined, 'x')).not.toThrow();
286
+ });
287
+
288
+ it('assertRemovedChartProp throws in development, naming the component, prop, and replacement', () => {
289
+ expect(() => assertRemovedChartProp('BarChart', 'minHeight', 320, 'Use size instead.'))
290
+ .toThrow(/BarChart.*minHeight.*Use size instead\./s);
291
+ });
292
+
293
+ it('assertRemovedChartProp stays inert in production', () => {
294
+ const original = process.env.NODE_ENV;
295
+ process.env.NODE_ENV = 'production';
296
+ try {
297
+ expect(() => assertRemovedChartProp('BarChart', 'minHeight', 320, 'Use size instead.')).not.toThrow();
298
+ } finally {
299
+ process.env.NODE_ENV = original;
300
+ }
301
+ });
302
+
303
+ it.each([
304
+ ['BarChart', BarChart, 'minHeight', 320],
305
+ ['BarChart', BarChart, 'aspect', 1.8],
306
+ ['BarChart', BarChart, 'margin', { top: 1 }],
307
+ ['LineChart', LineChart, 'minHeight', 320],
308
+ ['LineChart', LineChart, 'aspect', 1.8],
309
+ ['LineChart', LineChart, 'margin', { top: 1 }],
310
+ ])('%s throws when a caller still passes %s', (_name, Component, prop, value) => {
311
+ expect(() => render(withProviders(React.createElement(Component, {
312
+ series: [{ label: 'Cases', data: [1, 2] }],
313
+ [prop]: value,
314
+ })))).toThrow();
315
+ });
316
+ });
317
+
318
+ // UCM-CHART-12 -- the composition invariant, the actual deliverable of this WO. Two things are
319
+ // asserted per case, and they are NOT the same guarantee (reviewer finding R1):
320
+ // (1) the SUM `plotHeight + xAxisBand + xTitleBand + legendBand === chartHeight` -- true by
321
+ // construction (`plotHeight` is defined as the residual of the other three), so on its own
322
+ // this proves only that `resolveChartLayout`'s return shape is internally consistent, NOT the
323
+ // absence of a double-counted term.
324
+ // (2) the PER-TERM MAPPING -- `xAxis[0].height` carries EXACTLY `xAxisBand + xTitleBand` and
325
+ // nothing else, `margin`'s growth over the package baseline carries EXACTLY `legendBand` and
326
+ // nothing else. THIS is what would have caught a `UCM-CHART-10`-shaped bug (the same
327
+ // allowance reserved twice, once in axis height and again in margin) -- assertion (1) alone
328
+ // would not have, since a term double-counted in both places still sums correctly.
329
+ describe('resolveChartLayout composition invariant (UCM-CHART-12)', () => {
330
+ const bandAxis = (data) => [{ scaleType: 'band', data, label: undefined }];
331
+
332
+ it.each([
333
+ ['none', []],
334
+ ['short', ['Jan', 'Feb']],
335
+ ['long', ['A much longer ward name than the others']],
336
+ ['long-and-many', Array.from({ length: 12 }, (_, i) => `Ward number ${i} with a longish name`)],
337
+ ['empty-strings', ['', '', '']],
338
+ ])('holds the height invariant for the "%s" label load, with no term double-counted', (_label, data) => {
339
+ const layout = resolveChartLayout({
340
+ size: 'standard',
341
+ xAxis: bandAxis(data),
342
+ yAxis: [{ scaleType: 'linear', min: 0, max: 100 }],
343
+ spacing: theme.spacing,
344
+ measureTextWidth: () => null, // force the deterministic estimate fallback
345
+ });
346
+ const { bands } = layout;
347
+ expect(bands.plotHeight + bands.xAxisBand + bands.xTitleBand + bands.legendBand)
348
+ .toBe(layout.chartHeight);
349
+ // The per-term mapping -- the actual double-count guard (see the describe-level comment).
350
+ expect(layout.xAxis[0].height).toBe(bands.xAxisBand + bands.xTitleBand);
351
+ expect(layout.margin.top + layout.margin.bottom - PACKAGE_DEFAULT_MARGIN.top - PACKAGE_DEFAULT_MARGIN.bottom)
352
+ .toBe(bands.legendBand);
353
+ });
354
+
355
+ it('collapses the x-axis band to zero when every tick label is blank (the Access-scatter case)', () => {
356
+ const layout = resolveChartLayout({
357
+ xAxis: [{ scaleType: 'band', data: ['', '', ''] }],
358
+ yAxis: [{ scaleType: 'linear' }],
359
+ spacing: theme.spacing,
360
+ });
361
+ expect(layout.bands.xAxisBand).toBe(0);
362
+ expect(layout.xAxis[0].height).toBe(0);
363
+ });
364
+
365
+ it('collapses the x-axis band to zero when there is no tick data at all', () => {
366
+ const layout = resolveChartLayout({
367
+ xAxis: [{ scaleType: 'band', data: [] }],
368
+ yAxis: [{ scaleType: 'linear' }],
369
+ spacing: theme.spacing,
370
+ });
371
+ expect(layout.bands.xAxisBand).toBe(0);
372
+ });
373
+
374
+ it('assumes non-empty content when the axis has no data array at all (a numeric scale)', () => {
375
+ const layout = resolveChartLayout({
376
+ xAxis: [{ scaleType: 'linear' }],
377
+ yAxis: [{ scaleType: 'linear' }],
378
+ spacing: theme.spacing,
379
+ });
380
+ expect(layout.bands.xAxisBand).toBeGreaterThan(0);
381
+ });
382
+
383
+ it('reserves the x-title band only when the x-axis carries a label', () => {
384
+ const withoutTitle = resolveChartLayout({
385
+ xAxis: [{ scaleType: 'band', data: ['Jan'] }],
386
+ yAxis: [{ scaleType: 'linear' }],
387
+ spacing: theme.spacing,
388
+ });
389
+ expect(withoutTitle.bands.xTitleBand).toBe(0);
390
+
391
+ const withTitle = resolveChartLayout({
392
+ xAxis: [{ scaleType: 'band', data: ['Jan'], label: 'Month' }],
393
+ yAxis: [{ scaleType: 'linear' }],
394
+ spacing: theme.spacing,
395
+ });
396
+ expect(withTitle.bands.xTitleBand).toBeGreaterThan(0);
397
+ });
398
+
399
+ it('reserves the legend band only when the legend is not hidden', () => {
400
+ const hidden = resolveChartLayout({
401
+ xAxis: [{ scaleType: 'band', data: ['Jan'] }],
402
+ yAxis: [{ scaleType: 'linear' }],
403
+ hideLegend: true,
404
+ spacing: theme.spacing,
405
+ });
406
+ expect(hidden.bands.legendBand).toBe(0);
407
+
408
+ const shown = resolveChartLayout({
409
+ xAxis: [{ scaleType: 'band', data: ['Jan'] }],
410
+ yAxis: [{ scaleType: 'linear' }],
411
+ hideLegend: false,
412
+ spacing: theme.spacing,
413
+ });
414
+ expect(shown.bands.legendBand).toBeGreaterThan(0);
415
+ });
416
+
417
+ // Reviewer finding R2: a `vertical: 'middle'` legend renders on the SIDE (consuming width, not
418
+ // height) -- it must not reserve a height band or grow margin.top/bottom, or the height
419
+ // invariant would over-reserve height for space the legend never occupies there.
420
+ it('reserves no height band for a side-placed ("middle") legend', () => {
421
+ const layout = resolveChartLayout({
422
+ xAxis: [{ scaleType: 'band', data: ['Jan'] }],
423
+ yAxis: [{ scaleType: 'linear' }],
424
+ hideLegend: false,
425
+ legendPosition: { vertical: 'middle' },
426
+ spacing: theme.spacing,
427
+ });
428
+ expect(layout.bands.legendBand).toBe(0);
429
+ expect(layout.margin.top).toBe(PACKAGE_DEFAULT_MARGIN.top);
430
+ expect(layout.margin.bottom).toBe(PACKAGE_DEFAULT_MARGIN.bottom);
431
+ });
432
+
433
+ it('holds the width invariant when a containerWidth is supplied (test-only -- presets never pass this)', () => {
434
+ const layout = resolveChartLayout({
435
+ xAxis: [{ scaleType: 'band', data: ['Jan', 'Feb'] }],
436
+ yAxis: [{ scaleType: 'linear', min: 0, max: 250000 }],
437
+ tickFontSize: 12,
438
+ spacing: theme.spacing,
439
+ containerWidth: 600,
440
+ });
441
+ const { bands } = layout;
442
+ expect(bands.yAxisBand + bands.plotWidth + bands.rightPad).toBe(600);
443
+ expect(bands.yAxisBand).toBeGreaterThan(0);
444
+ });
445
+ });
446
+
447
+ describe('resolveChartLayout size tokens and height escape (UCM-CHART-12)', () => {
448
+ it.each(Object.entries(CHART_SIZE_SPACING_UNITS))('resolves size="%s" through the theme spacing scale', (size, units) => {
449
+ const layout = resolveChartLayout({ size, spacing: theme.spacing });
450
+ expect(layout.chartHeight).toBe(units * 8);
451
+ });
452
+
453
+ it('pins "standard" to the historical 320px default', () => {
454
+ const layout = resolveChartLayout({ size: 'standard', spacing: theme.spacing });
455
+ expect(layout.chartHeight).toBe(320);
456
+ });
457
+
458
+ it('lets height override the size token entirely', () => {
459
+ const layout = resolveChartLayout({ size: 'tall', height: 111, spacing: theme.spacing });
460
+ expect(layout.chartHeight).toBe(111);
461
+ });
462
+
463
+ it('throws on an unknown size token', () => {
464
+ expect(() => resolveChartLayout({ size: 'huge', spacing: theme.spacing })).toThrow(/Unknown chart size/);
465
+ });
466
+
467
+ it('falls back to a numeric 8px unit when no spacing function is given', () => {
468
+ const layout = resolveChartLayout({ size: 'standard' });
469
+ expect(layout.chartHeight).toBe(320);
470
+ });
471
+ });
472
+
473
+ describe('resolveChartLayout xLabels rotation (UCM-CHART-12)', () => {
474
+ it('"horizontal" never rotates, regardless of tick count or label length', () => {
475
+ const layout = resolveChartLayout({
476
+ xAxis: [{ scaleType: 'band', data: Array.from({ length: 20 }, (_, i) => `A rather long label ${i}`) }],
477
+ yAxis: [{ scaleType: 'linear' }],
478
+ xLabels: 'horizontal',
479
+ spacing: theme.spacing,
480
+ });
481
+ expect(layout.xAxis[0].tickLabelStyle?.angle).toBeFalsy();
482
+ });
483
+
484
+ it('"angled" always rotates, even for a single short label', () => {
485
+ const layout = resolveChartLayout({
486
+ xAxis: [{ scaleType: 'band', data: ['Jan'] }],
487
+ yAxis: [{ scaleType: 'linear' }],
488
+ xLabels: 'angled',
489
+ spacing: theme.spacing,
490
+ });
491
+ expect(layout.xAxis[0].tickLabelStyle.angle).toBe(-45);
492
+ });
493
+
494
+ it('"auto" stays horizontal for a few short labels, rotates for many long ones', () => {
495
+ const few = resolveChartLayout({
496
+ xAxis: [{ scaleType: 'band', data: ['Jan', 'Feb', 'Mar'] }],
497
+ yAxis: [{ scaleType: 'linear' }],
498
+ xLabels: 'auto',
499
+ spacing: theme.spacing,
500
+ });
501
+ expect(few.xAxis[0].tickLabelStyle?.angle).toBeFalsy();
502
+
503
+ const many = resolveChartLayout({
504
+ xAxis: [{ scaleType: 'band', data: Array.from({ length: 12 }, (_, i) => `Ward name ${i}`) }],
505
+ yAxis: [{ scaleType: 'linear' }],
506
+ xLabels: 'auto',
507
+ spacing: theme.spacing,
508
+ });
509
+ expect(many.xAxis[0].tickLabelStyle.angle).toBe(-45);
510
+ });
511
+
512
+ it('drives the rotated tick band from a measured width, not a flat constant', () => {
513
+ const short = resolveChartLayout({
514
+ xAxis: [{ scaleType: 'band', data: ['A'], tickLabelStyle: { fontSize: 12 } }],
515
+ yAxis: [{ scaleType: 'linear' }],
516
+ xLabels: 'angled',
517
+ spacing: theme.spacing,
518
+ measureTextWidth: () => 10,
519
+ });
520
+ const long = resolveChartLayout({
521
+ xAxis: [{ scaleType: 'band', data: ['A much longer label'], tickLabelStyle: { fontSize: 12 } }],
522
+ yAxis: [{ scaleType: 'linear' }],
523
+ xLabels: 'angled',
524
+ spacing: theme.spacing,
525
+ measureTextWidth: () => 140,
526
+ });
527
+ expect(long.bands.xAxisBand).toBeGreaterThan(short.bands.xAxisBand);
528
+ });
529
+
530
+ it('does not override an axis that already sets its own height', () => {
531
+ const layout = resolveChartLayout({
532
+ xAxis: [{ scaleType: 'band', data: ['January'], height: 200 }],
533
+ yAxis: [{ scaleType: 'linear' }],
534
+ xLabels: 'angled',
535
+ spacing: theme.spacing,
536
+ });
537
+ expect(layout.xAxis[0].height).toBe(200);
538
+ });
539
+ });
540
+
270
541
  // THEME-9 #2 -- `withAxisDefaults` broadcast the caller's single xAxisLabel/
271
542
  // yAxisLabel string onto EVERY entry of a multi-axis array via `.map()`.
272
543
  // Measured live: hram's dual-axis chart carried the identical 43-character
@@ -416,147 +687,3 @@ describe('sizeYAxisForContent (THEME-9)', () => {
416
687
  expect(secondary.width).toBeGreaterThan(primary.width);
417
688
  });
418
689
  });
419
-
420
- // THEME-11 -- pure-function contract of the merge helper itself, independent of any chart
421
- // component wiring (that's covered above, in 'chart chrome defaults').
422
- describe('withMarginDefaults (THEME-11)', () => {
423
- it('returns the package default untouched when the caller sets no margin', () => {
424
- expect(withMarginDefaults(undefined)).toEqual(PACKAGE_DEFAULT_MARGIN);
425
- });
426
-
427
- it('merges a partial object per side', () => {
428
- expect(withMarginDefaults({ top: 40 })).toEqual({ ...PACKAGE_DEFAULT_MARGIN, top: 40 });
429
- });
430
-
431
- it('expands a numeric shorthand margin to all four sides before merging', () => {
432
- expect(withMarginDefaults(5)).toEqual({ top: 5, bottom: 5, left: 5, right: 5 });
433
- });
434
-
435
- it('leaves a fully-specified caller margin untouched', () => {
436
- const full = { top: 1, bottom: 2, left: 3, right: 4 };
437
- expect(withMarginDefaults(full)).toEqual(full);
438
- });
439
- });
440
-
441
- describe('spaceForRotatedTicks (CHART-10)', () => {
442
- const rotatedAxis = [{ data: ['January', 'February'], tickLabelStyle: { angle: -45, fontSize: 12 } }];
443
-
444
- it('reserves the allowance once, on axis.height, and leaves margin untouched', () => {
445
- const result = spaceForRotatedTicks(rotatedAxis, undefined);
446
- expect(result.xAxis[0].height).toBeGreaterThan(45);
447
- expect(result.margin).toBeUndefined();
448
- });
449
-
450
- it('is byte-identical at angle 0 -- each axis entry and the margin pass through untouched', () => {
451
- const flatAxis = [{ data: ['Jan', 'Feb'], tickLabelStyle: { angle: 0 } }];
452
- const margin = { top: 1 };
453
- const result = spaceForRotatedTicks(flatAxis, margin);
454
- expect(result.xAxis[0]).toBe(flatAxis[0]);
455
- expect(result.margin).toBe(margin);
456
- });
457
-
458
- it('preserves a caller-set margin.bottom untouched -- the allowance is never added on top of it', () => {
459
- const margin = { bottom: 50 };
460
- const result = spaceForRotatedTicks(rotatedAxis, margin);
461
- expect(result.margin).toBe(margin);
462
- expect(result.margin.bottom).toBe(50);
463
- });
464
-
465
- it('drives the height estimate from the longest formatted tick, not a flat constant', () => {
466
- const shortLabels = [{ data: ['A', 'B'], tickLabelStyle: { angle: -45, fontSize: 12 } }];
467
- const longLabels = [{
468
- data: ['A much longer ward name than the others', 'B'],
469
- tickLabelStyle: { angle: -45, fontSize: 12 },
470
- }];
471
- const shortResult = spaceForRotatedTicks(shortLabels, undefined);
472
- const longResult = spaceForRotatedTicks(longLabels, undefined);
473
- expect(longResult.xAxis[0].height).toBeGreaterThan(shortResult.xAxis[0].height);
474
- });
475
-
476
- it('does not override an axis that already sets its own height', () => {
477
- const axis = [{ data: ['January'], tickLabelStyle: { angle: -45 }, height: 200 }];
478
- const result = spaceForRotatedTicks(axis, undefined);
479
- expect(result.xAxis[0].height).toBe(200);
480
- });
481
- });
482
-
483
- // CHART-8 -- pure-function contract of the minHeight/height/aspect resolver, independent of any
484
- // chart component wiring (component-level wiring is covered per-preset in each own test file).
485
- describe('resolveChartHeight (CHART-8)', () => {
486
- it('sizes the chart from minHeight alone when neither height nor aspect is set', () => {
487
- expect(resolveChartHeight({ minHeight: 300 })).toEqual({ wrapperMinHeight: 300, chartHeight: 300 });
488
- });
489
-
490
- it('leaves minHeight as a floor and gives the chart no fixed height when aspect is set (no height)', () => {
491
- expect(resolveChartHeight({ minHeight: 320, aspect: 1.8 })).toEqual({
492
- wrapperMinHeight: 320,
493
- chartHeight: undefined,
494
- });
495
- });
496
-
497
- it('caps the wrapper at height when minHeight is larger, closing the dead-space gap', () => {
498
- expect(resolveChartHeight({ minHeight: 420, height: 380 })).toEqual({
499
- wrapperMinHeight: 380,
500
- chartHeight: 380,
501
- });
502
- });
503
-
504
- it('is byte-identical (both values unchanged) when minHeight equals height', () => {
505
- expect(resolveChartHeight({ minHeight: 320, height: 320 })).toEqual({
506
- wrapperMinHeight: 320,
507
- chartHeight: 320,
508
- });
509
- });
510
-
511
- it('leaves the wrapper unset when height is passed alone', () => {
512
- expect(resolveChartHeight({ height: 280 })).toEqual({ wrapperMinHeight: undefined, chartHeight: 280 });
513
- });
514
-
515
- it('does not cap the wrapper when minHeight is smaller than height', () => {
516
- expect(resolveChartHeight({ minHeight: 200, height: 380 })).toEqual({
517
- wrapperMinHeight: 200,
518
- chartHeight: 380,
519
- });
520
- });
521
- });
522
-
523
- describe('warnOnHeightMismatch (CHART-8)', () => {
524
- afterEach(() => vi.restoreAllMocks());
525
-
526
- it('warns, naming the component and both values, when minHeight and height disagree', () => {
527
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
528
- warnOnHeightMismatch('BarChart', { minHeight: 420, height: 380 });
529
- expect(warn).toHaveBeenCalledOnce();
530
- expect(warn.mock.calls[0][0]).toContain('BarChart');
531
- expect(warn.mock.calls[0][0]).toContain('420');
532
- expect(warn.mock.calls[0][0]).toContain('380');
533
- });
534
-
535
- it('does not warn when minHeight equals height', () => {
536
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
537
- warnOnHeightMismatch('BarChart', { minHeight: 320, height: 320 });
538
- expect(warn).not.toHaveBeenCalled();
539
- });
540
-
541
- it('does not warn on the legitimate minHeight + aspect (no height) combination', () => {
542
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
543
- warnOnHeightMismatch('BarChart', { minHeight: 320, height: undefined });
544
- expect(warn).not.toHaveBeenCalled();
545
- });
546
-
547
- it('does not warn when only one of the pair is set', () => {
548
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
549
- warnOnHeightMismatch('BarChart', { minHeight: undefined, height: 280 });
550
- expect(warn).not.toHaveBeenCalled();
551
- });
552
-
553
- it('gives ChartFrame-accurate wording when heightWins is false (CHART-9)', () => {
554
- const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
555
- warnOnHeightMismatch('ChartFrame', { minHeight: 420, height: 380 }, { heightWins: false });
556
- expect(warn).toHaveBeenCalledOnce();
557
- const message = warn.mock.calls[0][0];
558
- expect(message).toContain('ChartFrame');
559
- expect(message).toContain('height is ignored');
560
- expect(message).not.toContain('height wins');
561
- });
562
- });