@oicl/openbridge-webcomponents-full-bundle 2.0.0-next.121 → 2.0.0-next.122

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.
Files changed (39) hide show
  1. package/bundle/openbridge-webcomponents.bundle.js +233 -53
  2. package/bundle/openbridge-webcomponents.bundle.js.map +1 -1
  3. package/custom-elements.json +66 -17
  4. package/dist/automation/automation-tank/automation-tank.css.js +94 -14
  5. package/dist/automation/automation-tank/automation-tank.css.js.map +1 -1
  6. package/dist/automation/automation-tank/automation-tank.d.ts +53 -5
  7. package/dist/automation/automation-tank/automation-tank.d.ts.map +1 -1
  8. package/dist/automation/automation-tank/automation-tank.js +39 -20
  9. package/dist/automation/automation-tank/automation-tank.js.map +1 -1
  10. package/dist/building-blocks/readout-block/readout-block.d.ts +27 -1
  11. package/dist/building-blocks/readout-block/readout-block.d.ts.map +1 -1
  12. package/dist/building-blocks/readout-block/readout-block.js +31 -10
  13. package/dist/building-blocks/readout-block/readout-block.js.map +1 -1
  14. package/dist/navigation-instruments/readout/readout-formatters.d.ts +61 -0
  15. package/dist/navigation-instruments/readout/readout-formatters.d.ts.map +1 -1
  16. package/dist/navigation-instruments/readout/readout-formatters.js +35 -2
  17. package/dist/navigation-instruments/readout/readout-formatters.js.map +1 -1
  18. package/dist/navigation-instruments/readout/readout.d.ts +19 -2
  19. package/dist/navigation-instruments/readout/readout.d.ts.map +1 -1
  20. package/dist/navigation-instruments/readout/readout.js +18 -4
  21. package/dist/navigation-instruments/readout/readout.js.map +1 -1
  22. package/dist/navigation-instruments/readout-list/readout-list.d.ts.map +1 -1
  23. package/dist/navigation-instruments/readout-list/readout-list.js +6 -3
  24. package/dist/navigation-instruments/readout-list/readout-list.js.map +1 -1
  25. package/dist/navigation-instruments/readout-list-item/readout-list-item.d.ts +19 -2
  26. package/dist/navigation-instruments/readout-list-item/readout-list-item.d.ts.map +1 -1
  27. package/dist/navigation-instruments/readout-list-item/readout-list-item.js +18 -4
  28. package/dist/navigation-instruments/readout-list-item/readout-list-item.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/automation/automation-tank/automation-tank.css +70 -3
  31. package/src/automation/automation-tank/automation-tank.stories.ts +140 -1
  32. package/src/automation/automation-tank/automation-tank.ts +106 -27
  33. package/src/building-blocks/readout-block/readout-block.stories.ts +20 -0
  34. package/src/building-blocks/readout-block/readout-block.ts +56 -10
  35. package/src/navigation-instruments/readout/readout-formatters.spec.ts +205 -0
  36. package/src/navigation-instruments/readout/readout-formatters.ts +128 -2
  37. package/src/navigation-instruments/readout/readout.ts +38 -5
  38. package/src/navigation-instruments/readout-list/readout-list.ts +10 -2
  39. package/src/navigation-instruments/readout-list-item/readout-list-item.ts +38 -5
@@ -82,6 +82,22 @@ export enum TankOrientation {
82
82
  * slot — the parent controls the footprint and the tank renders
83
83
  * responsively inside it, just like a regular button. Compact / static
84
84
  * inner layout still applies; only the host box is changed.
85
+ *
86
+ * If the parent leaves *one* axis indefinite — a flex/grid slot sized with
87
+ * `min-height`/`max-height` rather than `height`, or a cross axis freed by
88
+ * `align-self: center` — the corresponding `100%` computes to `auto` and the
89
+ * tank derives that axis from the other one through the design aspect ratio
90
+ * of the matching `point` footprint (256×376 vertical, 420×256 horizontal,
91
+ * 170×282 compact/static, 244×208 horizontal compact/static). The size then
92
+ * does not depend on the chart cell, which takes its own size from the cell
93
+ * it was measured in and would otherwise make the constraint circular
94
+ * (issue #1121).
95
+ *
96
+ * If *both* axes are indefinite (a shrink-to-fit parent, e.g. an unsized
97
+ * `inline-block`) there is no axis left to derive from, so the host falls
98
+ * back to its text content. That is stable and non-circular, but much
99
+ * smaller than the design footprint — give the parent a definite size on at
100
+ * least one axis, and on both whenever the exact footprint matters.
85
101
  */
86
102
  export enum TankPositioning {
87
103
  point = 'point',
@@ -122,7 +138,7 @@ export enum TankChartMode {
122
138
  * @slot alert-icon - Custom icon for the alert frame.
123
139
  * @slot alert-label - Label for the alert frame.
124
140
  * @slot alert-timer - Timer for the alert frame.
125
- * @fires click - Fired when the tank is clicked. In `static` mode the tank renders a `<div role="img">` instead of a `<button>`, so it is not focusable or keyboard-activatable; pointer clicks still reach the host.
141
+ * @fires click - Fired when the tank is clicked. When `clickable` is `false` the tank renders a plain `<div>`, and in `static` mode a `<div role="img">`, instead of a `<button>` in both cases it is not focusable or keyboard-activatable; pointer clicks still reach the host.
126
142
  * @beta
127
143
  */
128
144
  @customElement('obc-automation-tank')
@@ -137,9 +153,10 @@ export class ObcAutomationTank extends SetpointMixin(LitElement) {
137
153
  @property({type: Boolean, reflect: true}) compact: boolean = false;
138
154
  /**
139
155
  * Host positioning model — see `TankPositioning` for details. Defaults to
140
- * `button` (host fills parent container, 100% × 100%, no anchor offset).
141
- * Set to `point` for the legacy P&ID canvas mode (fixed default dimensions
142
- * + top-center anchor offset).
156
+ * `button` (host fills parent container, 100% × 100%, falling back to the
157
+ * design aspect ratio on any axis the parent leaves indefinite, no anchor
158
+ * offset). Set to `point` for the legacy P&ID canvas mode (fixed default
159
+ * dimensions + top-center anchor offset).
143
160
  */
144
161
  @property({type: String, reflect: true}) positioning: TankPositioning =
145
162
  TankPositioning.button;
@@ -156,7 +173,38 @@ export class ObcAutomationTank extends SetpointMixin(LitElement) {
156
173
  * activatable controls.
157
174
  */
158
175
  @property({type: Boolean, reflect: true}) static: boolean = false;
159
- /** Enables the activated background color, used to indicate that the tank is activated/selected. */
176
+ /**
177
+ * Whether the tank is interactive. `true` (default) renders the root as a
178
+ * `<button>` with the full flat-mixin interaction surface. `false` renders a
179
+ * non-interactive `<div>` — the resting appearance is unchanged (same
180
+ * enabled-state colors and the same 1px border box, via the mixin's
181
+ * `noClick` variant), but the hover / pressed / focus-visible states are
182
+ * gone and the tank leaves the tab order.
183
+ *
184
+ * Everything else keeps rendering: the chart / bar, badges, readout, tag and
185
+ * the `alert` frame all behave exactly as they do on a clickable tank. Use
186
+ * this for a display-only tank that still shows live data — e.g. a row total
187
+ * aggregating the tanks beside it. For "device present, current state
188
+ * unknown" use `static` instead, which also hides the chart and shrinks to
189
+ * the compact footprint.
190
+ *
191
+ * `static` is already non-interactive, so this has no effect there.
192
+ *
193
+ * Property-only (`attribute: false`, per the repo's positive-default-true
194
+ * boolean convention — a `true` default cannot round-trip through an HTML
195
+ * boolean attribute). Set it as a property: `el.clickable = false`,
196
+ * `.clickable=${false}` in a Lit template, or the equivalent binding in the
197
+ * React / Vue / Angular / Svelte wrappers. A `clickable="false"` attribute in
198
+ * plain HTML is **not** observed and leaves the tank interactive.
199
+ */
200
+ @property({type: Boolean, attribute: false}) clickable: boolean = true;
201
+ /**
202
+ * Enables the activated background color, used to indicate that the tank is
203
+ * activated/selected. Requires an interactive tank — the `noClick` mixin
204
+ * variant used when `clickable` is `false` only paints the enabled state, so
205
+ * a non-clickable tank ignores this (matching `obc-elevated-card`).
206
+ * @availableWhen clickable==true
207
+ */
160
208
  @property({type: Boolean}) activated: boolean = false;
161
209
  @property({type: String}) tag: string = '';
162
210
 
@@ -1001,7 +1049,18 @@ export class ObcAutomationTank extends SetpointMixin(LitElement) {
1001
1049
  // The `activated` class goes on the interactive `.root` so the shared
1002
1050
  // `flat` style mixin paints the activated background/border on `.halo`
1003
1051
  // (its `visibleWrapperClass`), same as the mixin's hover/pressed states.
1004
- const rootClasses = classMap({root: true, activated: this.activated});
1052
+ //
1053
+ // `.clickable` selects between the two flat-mixin variants in CSS: the
1054
+ // full six-state one, or the `noClick` one that paints only the resting
1055
+ // enabled state. `static` is already display-only, so it never counts as
1056
+ // clickable. Same shape as `obc-elevated-card`'s `.not-clickable` split
1057
+ // and `obc-readout-list-item`'s `.root.clickable`.
1058
+ const isClickable = this.clickable && !this.static;
1059
+ const rootClasses = classMap({
1060
+ root: true,
1061
+ activated: this.activated,
1062
+ clickable: isClickable,
1063
+ });
1005
1064
 
1006
1065
  // `aria-live="polite"` + `aria-atomic="true"` on the root so the
1007
1066
  // slotted alert label (and any state change of the alert frame) is
@@ -1010,27 +1069,47 @@ export class ObcAutomationTank extends SetpointMixin(LitElement) {
1010
1069
  // TODO(a11y): the rest of the automation component family still lacks
1011
1070
  // this live-region announcement; consolidate when alert support is
1012
1071
  // factored into a shared mixin.
1013
- return html`
1014
- ${this.static
1015
- ? html`<div
1016
- class=${rootClasses}
1017
- role="img"
1018
- aria-label=${this.tag || 'Tank'}
1019
- aria-live="polite"
1020
- aria-atomic="true"
1021
- >
1022
- ${halo}
1023
- </div>`
1024
- : html`<button
1025
- class=${rootClasses}
1026
- type="button"
1027
- aria-label=${this.tag || 'Tank'}
1028
- aria-live="polite"
1029
- aria-atomic="true"
1030
- >
1031
- ${halo}
1032
- </button>`}
1033
- `;
1072
+ // Three root shapes:
1073
+ // - static: <div role="img"> — an opaque graphic standing in
1074
+ // for a device whose state is unknown, named by its tag.
1075
+ // - clickable false: a plain <div>. Deliberately no `role="img"` and no
1076
+ // `aria-label` here: unlike a static tank this one still shows live
1077
+ // data, and both would collapse the readout into a single opaque name
1078
+ // and hide the percent / value / tag from screen readers. The visible
1079
+ // content is the accessible content. (Same reasoning as the non-
1080
+ // clickable branch of `obc-readout-list-item`.)
1081
+ // - default: <button>.
1082
+ // The live region stays on all three so an `alert` label is announced
1083
+ // regardless of interactivity.
1084
+ if (this.static) {
1085
+ return html`<div
1086
+ class=${rootClasses}
1087
+ role="img"
1088
+ aria-label=${this.tag || 'Tank'}
1089
+ aria-live="polite"
1090
+ aria-atomic="true"
1091
+ >
1092
+ ${halo}
1093
+ </div>`;
1094
+ }
1095
+ if (!isClickable) {
1096
+ return html`<div
1097
+ class=${rootClasses}
1098
+ aria-live="polite"
1099
+ aria-atomic="true"
1100
+ >
1101
+ ${halo}
1102
+ </div>`;
1103
+ }
1104
+ return html`<button
1105
+ class=${rootClasses}
1106
+ type="button"
1107
+ aria-label=${this.tag || 'Tank'}
1108
+ aria-live="polite"
1109
+ aria-atomic="true"
1110
+ >
1111
+ ${halo}
1112
+ </button>`;
1034
1113
  }
1035
1114
 
1036
1115
  static override styles = unsafeCSS(compentStyle);
@@ -481,6 +481,19 @@ const UNAVAILABLE_CASES: {
481
481
  label: 'null · no fraction digits',
482
482
  args: {value: null, maxDigits: 3},
483
483
  },
484
+ // The knobs, not the value: a `fractionDigits` written by a failed runtime
485
+ // (`NaN`) must not silently format with zero decimals — a critical 0.4
486
+ // printed as a plausible-looking "0" reads as healthy. The reading dashes
487
+ // instead. A missing `maxDigits` dashes too, for consistency; with the
488
+ // precision still known, the placeholder keeps its fraction shape.
489
+ {
490
+ label: 'value 0.4 · fractionDigits NaN — precision failed to arrive',
491
+ args: {value: 0.4, maxDigits: 3, fractionDigits: Number.NaN},
492
+ },
493
+ {
494
+ label: 'value 0.4 · maxDigits NaN — same, for consistency',
495
+ args: {value: 0.4, maxDigits: Number.NaN, fractionDigits: 1},
496
+ },
484
497
  ];
485
498
 
486
499
  // Stacked in one column under identical settings so the decimal points and the
@@ -509,6 +522,13 @@ const ALIGNMENT_CASES: Partial<BlockArgs>[] = [
509
522
  * runtime data condition (sensor dropout, `0/0`, a bad parse) rather than a
510
523
  * programmer error, so they resolve to the dash rather than throwing.
511
524
  *
525
+ * 4. **A digit knob that never arrived dashes the reading too.** `NaN`,
526
+ * `null` or `undefined` in `fractionDigits` / `maxDigits` is a runtime
527
+ * failure of the writing system, and formatting with a default the author
528
+ * never chose would let a critical `0.4` pass for a healthy `0`. Finite
529
+ * out-of-range values are different: `fractionDigits` throws (programmer
530
+ * error), `maxDigits` clamps (width-only).
531
+ *
512
532
  * Hinted zeros are suppressed for an unavailable value, so the two "not
513
533
  * available" rows are identical and nothing reads `----Na.N`.
514
534
  *
@@ -15,6 +15,9 @@ import {
15
15
  formatNumericValue,
16
16
  readoutFormattedInteger,
17
17
  assertReadoutValueType,
18
+ assertReadoutFractionDigits,
19
+ isReadoutDigitCountMissing,
20
+ resolveReadoutDigitCount,
18
21
  resolveReadoutNumericValue,
19
22
  resolveReadoutTextValue,
20
23
  READOUT_UNAVAILABLE_DASH,
@@ -152,13 +155,23 @@ export class ObcReadoutBlock extends LitElement {
152
155
  @property({type: Boolean}) hasIcon = false;
153
156
 
154
157
  /**
155
- * Number of fraction digits.
158
+ * Number of fraction digits. Must be between 0 and 100 — the range
159
+ * `Number.prototype.toFixed` accepts; outside it throws a `RangeError`.
160
+ * A fractional count truncates (`2.7` → `2`). A count that never arrived
161
+ * (`NaN`, `null` or unset) renders the reading as the unavailable dash —
162
+ * formatting with a precision the author never chose would let a critical
163
+ * `0.4` pass for a healthy `0`.
156
164
  * @availableWhen valueType==number
157
165
  */
158
166
  @property({type: Number}) fractionDigits = 0;
159
167
 
160
168
  /**
161
169
  * Integer digits to reserve / hint (independent of `fractionDigits`).
170
+ * Bounded before use: a fractional count
171
+ * truncates (`2.7` → `2`), anything above 100 — including `Infinity` —
172
+ * caps at 100, and a negative count reserves nothing. A count that never
173
+ * arrived (`NaN`, `null` or unset) renders the reading as the unavailable
174
+ * dash, consistent with `fractionDigits`.
162
175
  * @availableWhen valueType==number
163
176
  */
164
177
  @property({type: Number}) maxDigits = 0;
@@ -214,24 +227,54 @@ export class ObcReadoutBlock extends LitElement {
214
227
  }
215
228
  }
216
229
 
230
+ /**
231
+ * `maxDigits` bounded to a usable count. Every width calculation below goes
232
+ * through this rather than the raw property, so an `Infinity` (or absurdly
233
+ * large) value cannot reach `String.prototype.repeat`.
234
+ */
235
+ private get resolvedMaxDigits(): number {
236
+ return resolveReadoutDigitCount(this.maxDigits);
237
+ }
238
+
239
+ /**
240
+ * Whether a digit knob failed to arrive (`NaN` / `null` / `undefined`).
241
+ * The reading then renders as the unavailable dash rather than silently
242
+ * formatting with a default the author never chose — a critical `0.4`
243
+ * formatted with a failed `fractionDigits` would otherwise print as a
244
+ * plausible-looking `0`. A missing `maxDigits` dashes too, for consistency:
245
+ * either knob arriving broken means the row's configuration cannot be
246
+ * trusted.
247
+ */
248
+ private get digitCountsMissing(): boolean {
249
+ return (
250
+ isReadoutDigitCountMissing(this.fractionDigits) ||
251
+ isReadoutDigitCountMissing(this.maxDigits)
252
+ );
253
+ }
254
+
217
255
  private get numericFormatOptions(): ReadoutNumericFormatOptions {
218
256
  return {
219
257
  // The unavailable placeholder stays short (`\u2012.\u2012\u2012`) rather than
220
258
  // spelling out every reserved digit position — `maxDigits` already
221
259
  // reserves the width, so it simply sits at the right edge of it.
222
260
  showZeroPadding: false,
223
- minValueLength: this.maxDigits,
224
- fractionDigits: this.fractionDigits,
261
+ minValueLength: this.resolvedMaxDigits,
262
+ // A missing precision shapes the placeholder as zero fraction digits (a
263
+ // single dash) — and keeps a runtime `undefined`/`null` out of the
264
+ // options object, which is typed `number` throughout the formatters.
265
+ fractionDigits: isReadoutDigitCountMissing(this.fractionDigits)
266
+ ? 0
267
+ : this.fractionDigits,
225
268
  };
226
269
  }
227
270
 
228
271
  /** Widest possible value string for width reservation (e.g. `"000.0"`). */
229
272
  private get reserverText(): string {
230
- const maxDigits = this.maxDigits;
273
+ const maxDigits = this.resolvedMaxDigits;
231
274
  if (maxDigits <= 0) {
232
275
  return '';
233
276
  }
234
- const integer = '0'.repeat(Math.max(maxDigits, 1));
277
+ const integer = '0'.repeat(maxDigits);
235
278
  return this.fractionDigits > 0
236
279
  ? `${integer}.${'0'.repeat(this.fractionDigits)}`
237
280
  : integer;
@@ -322,16 +365,19 @@ export class ObcReadoutBlock extends LitElement {
322
365
  // `changed`, skip the check, and render the invalid value as a plain dash:
323
366
  // exactly the silent failure this assertion exists to prevent.
324
367
  assertReadoutValueType('obc-readout-block', this.value, this.valueType);
368
+ assertReadoutFractionDigits('obc-readout-block', this.fractionDigits);
325
369
  }
326
370
 
327
371
  override render() {
328
372
  const valueSize = this.resolvedValueSize;
329
373
  const formatOptions = this.numericFormatOptions;
330
374
  const isTextMode = this.valueType === ReadoutValueType.text;
331
- const valueForFormat = resolveReadoutNumericValue(
332
- this.value,
333
- this.valueType
334
- );
375
+ // A missing digit knob makes the reading untrustworthy, so it resolves to
376
+ // "no reading" and renders the dash (see `digitCountsMissing`). Text mode
377
+ // is unaffected — it ignores the numeric knobs entirely.
378
+ const valueForFormat = this.digitCountsMissing
379
+ ? undefined
380
+ : resolveReadoutNumericValue(this.value, this.valueType);
335
381
  const textValue = resolveReadoutTextValue(this.value, this.valueType);
336
382
  // Text mode renders verbatim and ignores the numeric format options; a
337
383
  // blank / missing text value still falls back to the unavailable dash.
@@ -351,7 +397,7 @@ export class ObcReadoutBlock extends LitElement {
351
397
  valueForFormat === undefined ||
352
398
  valueForFormat < 0
353
399
  ? 0
354
- : Math.max(this.maxDigits - readoutFormattedInteger(text), 0);
400
+ : Math.max(this.resolvedMaxDigits - readoutFormattedInteger(text), 0);
355
401
  const hinted = hintCount > 0 ? '0'.repeat(hintCount) : '';
356
402
  // Hinted zeros own the width — they already fill to `maxDigits` — so when
357
403
  // `hintedZeros` is enabled an explicit `spaceReserver` is ignored (it has
@@ -1,6 +1,10 @@
1
1
  import {describe, it, expect} from 'vitest';
2
2
  import {
3
3
  assertReadoutValueType,
4
+ assertReadoutFractionDigits,
5
+ isReadoutDigitCountMissing,
6
+ resolveReadoutDigitCount,
7
+ READOUT_MAX_DIGITS,
4
8
  resolveReadoutNumericValue,
5
9
  resolveReadoutTextValue,
6
10
  formatNumericValue,
@@ -367,3 +371,204 @@ describe("designer's format specification (000.00)", () => {
367
371
  );
368
372
  });
369
373
  });
374
+
375
+ describe('assertReadoutFractionDigits', () => {
376
+ // `fractionDigits` reaches `Number.prototype.toFixed` unchanged, which throws
377
+ // outside 0…100. These are the values that already crash today, now reported
378
+ // against the component instead of as a bare RangeError.
379
+ it('throws below 0 and above the maximum', () => {
380
+ expect(() => assertReadoutFractionDigits('obc-readout', -1)).toThrow(
381
+ RangeError
382
+ );
383
+ expect(() =>
384
+ assertReadoutFractionDigits('obc-readout', READOUT_MAX_DIGITS + 1)
385
+ ).toThrow(RangeError);
386
+ expect(() =>
387
+ assertReadoutFractionDigits('obc-readout', Number.POSITIVE_INFINITY)
388
+ ).toThrow(RangeError);
389
+ });
390
+
391
+ it('names the component and the offending value', () => {
392
+ expect(() =>
393
+ assertReadoutFractionDigits('obc-readout-list-item', -1)
394
+ ).toThrow(/obc-readout-list-item.*between 0 and 100.*-1/s);
395
+ });
396
+
397
+ // `JSON.stringify` serialises these to `null`, which would hide the very
398
+ // value being rejected.
399
+ it('reports non-finite values verbatim in the message', () => {
400
+ expect(() =>
401
+ assertReadoutFractionDigits('obc-readout', Number.POSITIVE_INFINITY)
402
+ ).toThrow(/got Infinity/);
403
+ expect(() =>
404
+ assertReadoutFractionDigits('obc-readout', Number.NEGATIVE_INFINITY)
405
+ ).toThrow(/got -Infinity/);
406
+ });
407
+
408
+ // The components read `fractionDigits ?? 0` and `toFixed(undefined)` behaves
409
+ // as `toFixed(0)`, so clearing the property must not throw.
410
+ it('accepts an unset value', () => {
411
+ expect(() =>
412
+ assertReadoutFractionDigits('obc-readout', undefined as unknown as number)
413
+ ).not.toThrow();
414
+ expect(() =>
415
+ assertReadoutFractionDigits('obc-readout', null as unknown as number)
416
+ ).not.toThrow();
417
+ });
418
+
419
+ it('accepts the whole supported range', () => {
420
+ expect(() => assertReadoutFractionDigits('obc-readout', 0)).not.toThrow();
421
+ expect(() =>
422
+ assertReadoutFractionDigits('obc-readout', READOUT_MAX_DIGITS)
423
+ ).not.toThrow();
424
+ });
425
+
426
+ // Values `toFixed` itself tolerates are left alone rather than rejected.
427
+ it('tolerates what toFixed tolerates', () => {
428
+ expect(() => assertReadoutFractionDigits('obc-readout', 2.7)).not.toThrow();
429
+ expect(() =>
430
+ assertReadoutFractionDigits('obc-readout', Number.NaN)
431
+ ).not.toThrow();
432
+ });
433
+
434
+ // The assertion must agree with the runtime it is protecting.
435
+ it('accepts exactly the values toFixed accepts', () => {
436
+ // Covers both sides of the boundary, including the truncation cases the
437
+ // instruction file documents: 100.9 -> 100 and -0.5 -> -0 are accepted,
438
+ // while -1, 101 and the infinities are not.
439
+ for (const digits of [
440
+ 0,
441
+ 1,
442
+ 2.7,
443
+ -0.5,
444
+ 100.9,
445
+ Number.NaN,
446
+ undefined,
447
+ null,
448
+ READOUT_MAX_DIGITS,
449
+ -1,
450
+ READOUT_MAX_DIGITS + 1,
451
+ Number.POSITIVE_INFINITY,
452
+ Number.NEGATIVE_INFINITY,
453
+ ] as number[]) {
454
+ let assertionThrew = false;
455
+ let toFixedThrew = false;
456
+ try {
457
+ assertReadoutFractionDigits('obc-readout', digits);
458
+ } catch {
459
+ assertionThrew = true;
460
+ }
461
+ try {
462
+ (12.3).toFixed(digits);
463
+ } catch {
464
+ toFixedThrew = true;
465
+ }
466
+ expect(assertionThrew).toBe(toFixedThrew);
467
+ }
468
+ });
469
+ });
470
+
471
+ describe('resolveReadoutDigitCount', () => {
472
+ it('passes a usable count through', () => {
473
+ expect(resolveReadoutDigitCount(4)).toBe(4);
474
+ expect(resolveReadoutDigitCount(0)).toBe(0);
475
+ });
476
+
477
+ // `Infinity` is the value that throws out of `String.prototype.repeat`.
478
+ it('caps Infinity rather than throwing', () => {
479
+ expect(resolveReadoutDigitCount(Number.POSITIVE_INFINITY)).toBe(
480
+ READOUT_MAX_DIGITS
481
+ );
482
+ expect(() =>
483
+ '0'.repeat(resolveReadoutDigitCount(Number.POSITIVE_INFINITY))
484
+ ).not.toThrow();
485
+ });
486
+
487
+ it('caps a large finite count so the reserver stays bounded', () => {
488
+ expect(resolveReadoutDigitCount(1_000_000)).toBe(READOUT_MAX_DIGITS);
489
+ });
490
+
491
+ // Matches what the existing guards already did with these: reserve nothing.
492
+ it('is 0 for negative, NaN, null and undefined', () => {
493
+ expect(resolveReadoutDigitCount(-5)).toBe(0);
494
+ expect(resolveReadoutDigitCount(Number.NEGATIVE_INFINITY)).toBe(0);
495
+ expect(resolveReadoutDigitCount(Number.NaN)).toBe(0);
496
+ expect(resolveReadoutDigitCount(null)).toBe(0);
497
+ expect(resolveReadoutDigitCount(undefined)).toBe(0);
498
+ });
499
+
500
+ it('truncates a fractional count', () => {
501
+ expect(resolveReadoutDigitCount(2.7)).toBe(2);
502
+ });
503
+
504
+ // Whatever comes back must be safe to hand to `repeat`.
505
+ it('always returns a count repeat() accepts', () => {
506
+ for (const input of [
507
+ -5,
508
+ 0,
509
+ 2.7,
510
+ 4,
511
+ 1_000_000,
512
+ Number.NaN,
513
+ Number.POSITIVE_INFINITY,
514
+ Number.NEGATIVE_INFINITY,
515
+ ]) {
516
+ expect(() => '0'.repeat(resolveReadoutDigitCount(input))).not.toThrow();
517
+ }
518
+ });
519
+ });
520
+
521
+ describe('isReadoutDigitCountMissing', () => {
522
+ // The third contract besides throw and clamp: a knob that never arrived is
523
+ // a runtime data condition, and the reading renders as the dash rather than
524
+ // silently formatting with a default the author never chose.
525
+ it('is true for NaN, null and undefined', () => {
526
+ expect(isReadoutDigitCountMissing(Number.NaN)).toBe(true);
527
+ expect(isReadoutDigitCountMissing(null)).toBe(true);
528
+ expect(isReadoutDigitCountMissing(undefined)).toBe(true);
529
+ });
530
+
531
+ // A number that ARRIVED wrong is a different contract: out-of-range
532
+ // `fractionDigits` throws (`assertReadoutFractionDigits`), out-of-range
533
+ // `maxDigits` clamps (`resolveReadoutDigitCount`). Neither is "missing".
534
+ it('is false for any number that arrived, however wrong', () => {
535
+ expect(isReadoutDigitCountMissing(0)).toBe(false);
536
+ expect(isReadoutDigitCountMissing(2)).toBe(false);
537
+ expect(isReadoutDigitCountMissing(2.7)).toBe(false);
538
+ expect(isReadoutDigitCountMissing(-1)).toBe(false);
539
+ expect(isReadoutDigitCountMissing(101)).toBe(false);
540
+ expect(isReadoutDigitCountMissing(Number.POSITIVE_INFINITY)).toBe(false);
541
+ expect(isReadoutDigitCountMissing(Number.NEGATIVE_INFINITY)).toBe(false);
542
+ });
543
+ });
544
+
545
+ // The review that introduced this: a system writing `fractionDigits` at
546
+ // runtime can fail and produce `NaN` — `toFixed(NaN)` then formats with zero
547
+ // decimals, so a critical `0.4` prints as a plausible-looking `0` and an
548
+ // operator cannot tell the readout is broken. The dash makes it visible.
549
+ describe('formatNumericValue — missing precision', () => {
550
+ const opts = (fractionDigits: number) => ({
551
+ showZeroPadding: false,
552
+ minValueLength: 3,
553
+ fractionDigits,
554
+ });
555
+ const D = READOUT_UNAVAILABLE_DASH;
556
+
557
+ it('renders the dash, not a zero-decimal number', () => {
558
+ expect(formatNumericValue(0.4, opts(Number.NaN))).toBe(D);
559
+ expect(formatNumericValue(12.3, opts(Number.NaN))).toBe(D);
560
+ });
561
+
562
+ // Regression: `NaN < 1` is false, so the placeholder generator used to fall
563
+ // into its fraction branch where both `repeat(NaN)` calls produce '', and
564
+ // an unavailable value with a missing precision degenerated to a lone ".".
565
+ it('shapes the placeholder as zero fraction digits, not a lone dot', () => {
566
+ expect(formatNumericValue(undefined, opts(Number.NaN))).toBe(D);
567
+ expect(formatNumericValue(undefined, opts(Number.NaN))).not.toBe('.');
568
+ });
569
+
570
+ it('still formats normally with a precision that arrived', () => {
571
+ expect(formatNumericValue(0.4, opts(1))).toBe('0.4');
572
+ expect(formatNumericValue(12.3, opts(2))).toBe('12.30');
573
+ });
574
+ });