@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.
- package/bundle/openbridge-webcomponents.bundle.js +233 -53
- package/bundle/openbridge-webcomponents.bundle.js.map +1 -1
- package/custom-elements.json +66 -17
- package/dist/automation/automation-tank/automation-tank.css.js +94 -14
- package/dist/automation/automation-tank/automation-tank.css.js.map +1 -1
- package/dist/automation/automation-tank/automation-tank.d.ts +53 -5
- package/dist/automation/automation-tank/automation-tank.d.ts.map +1 -1
- package/dist/automation/automation-tank/automation-tank.js +39 -20
- package/dist/automation/automation-tank/automation-tank.js.map +1 -1
- package/dist/building-blocks/readout-block/readout-block.d.ts +27 -1
- package/dist/building-blocks/readout-block/readout-block.d.ts.map +1 -1
- package/dist/building-blocks/readout-block/readout-block.js +31 -10
- package/dist/building-blocks/readout-block/readout-block.js.map +1 -1
- package/dist/navigation-instruments/readout/readout-formatters.d.ts +61 -0
- package/dist/navigation-instruments/readout/readout-formatters.d.ts.map +1 -1
- package/dist/navigation-instruments/readout/readout-formatters.js +35 -2
- package/dist/navigation-instruments/readout/readout-formatters.js.map +1 -1
- package/dist/navigation-instruments/readout/readout.d.ts +19 -2
- package/dist/navigation-instruments/readout/readout.d.ts.map +1 -1
- package/dist/navigation-instruments/readout/readout.js +18 -4
- package/dist/navigation-instruments/readout/readout.js.map +1 -1
- package/dist/navigation-instruments/readout-list/readout-list.d.ts.map +1 -1
- package/dist/navigation-instruments/readout-list/readout-list.js +6 -3
- package/dist/navigation-instruments/readout-list/readout-list.js.map +1 -1
- package/dist/navigation-instruments/readout-list-item/readout-list-item.d.ts +19 -2
- package/dist/navigation-instruments/readout-list-item/readout-list-item.d.ts.map +1 -1
- package/dist/navigation-instruments/readout-list-item/readout-list-item.js +18 -4
- package/dist/navigation-instruments/readout-list-item/readout-list-item.js.map +1 -1
- package/package.json +1 -1
- package/src/automation/automation-tank/automation-tank.css +70 -3
- package/src/automation/automation-tank/automation-tank.stories.ts +140 -1
- package/src/automation/automation-tank/automation-tank.ts +106 -27
- package/src/building-blocks/readout-block/readout-block.stories.ts +20 -0
- package/src/building-blocks/readout-block/readout-block.ts +56 -10
- package/src/navigation-instruments/readout/readout-formatters.spec.ts +205 -0
- package/src/navigation-instruments/readout/readout-formatters.ts +128 -2
- package/src/navigation-instruments/readout/readout.ts +38 -5
- package/src/navigation-instruments/readout-list/readout-list.ts +10 -2
- 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.
|
|
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%,
|
|
141
|
-
*
|
|
142
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
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.
|
|
224
|
-
|
|
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.
|
|
273
|
+
const maxDigits = this.resolvedMaxDigits;
|
|
231
274
|
if (maxDigits <= 0) {
|
|
232
275
|
return '';
|
|
233
276
|
}
|
|
234
|
-
const integer = '0'.repeat(
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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.
|
|
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
|
+
});
|