@intentius/behold 0.17.0 → 0.18.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.
- package/AGENTS.md +517 -2
- package/LICENSE +190 -0
- package/README.md +22 -2
- package/THIRD_PARTY.md +75 -0
- package/demos.json +78 -15
- package/dist/cli.js +4585 -1467
- package/example-choudoufu-estate/.behold.json +8 -0
- package/example-choudoufu-estate/README.md +48 -0
- package/example-choudoufu-estate/monolith/behaviour.live.json +495 -0
- package/example-choudoufu-estate/monolith/carve.json +12 -0
- package/example-choudoufu-estate/monolith/main.tf +127 -0
- package/example-choudoufu-estate/scripts/choudoufu-down.sh +8 -0
- package/example-choudoufu-estate/scripts/choudoufu-up.sh +62 -0
- package/example-choudoufu-estate/team-a/behaviour.live.json +179 -0
- package/example-choudoufu-estate/team-a/main.tf +58 -0
- package/example-choudoufu-estate/team-b/behaviour.live.json +179 -0
- package/example-choudoufu-estate/team-b/main.tf +73 -0
- package/example-choudoufu-estate/team-c/behaviour.live.json +179 -0
- package/example-choudoufu-estate/team-c/main.tf +51 -0
- package/licenses/cncf-artwork-LICENSE.md +13 -0
- package/licenses/iTerm2-Color-Schemes-LICENSE +31 -0
- package/licenses/kubernetes-icons-LICENSE +201 -0
- package/package.json +24 -7
- package/web/app.js +929 -20
- package/web/behaviour-scale.js +380 -0
- package/web/behaviour-scale.test.js +307 -0
- package/web/cross-env.js +58 -0
- package/web/cross-env.test.js +61 -0
- package/web/index.html +39 -1
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// The colour-by modes' arithmetic (#399 M2, #401 M4 of #397) — everything the
|
|
2
|
+
// behaviour overlay DECIDES, with no DOM and no fetch in it, so it can be unit
|
|
3
|
+
// tested beside the file the way web/theme.js is.
|
|
4
|
+
//
|
|
5
|
+
// The one rule the whole module exists to keep: behold states no figure of its
|
|
6
|
+
// own. Every number below arrives from a `attrs._behaviour` block a lexicon's
|
|
7
|
+
// engine wrote (src/behaviour.ts validates it against #398's contract), and
|
|
8
|
+
// what happens here is positioning on an axis, a min, a median, and a count.
|
|
9
|
+
// Where a figure IS computed here rather than read — the headroom min and
|
|
10
|
+
// median a box's totals row shows — the label says "computed", because
|
|
11
|
+
// `meta.behaviour` carries only cost sums and the engine stated no headroom
|
|
12
|
+
// aggregate for behold to quote.
|
|
13
|
+
import { hexToOklch, oklchToHex } from "./theme.js";
|
|
14
|
+
|
|
15
|
+
const clamp = (x, lo, hi) => (x < lo ? lo : x > hi ? hi : x);
|
|
16
|
+
|
|
17
|
+
/** The block a node carries, or undefined. One reader, so a rename is one edit. */
|
|
18
|
+
export function blockOf(node) {
|
|
19
|
+
return (node && node.attrs && node.attrs._behaviour) || undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Headroom, as the mode paints it: **the lower of the axes present**.
|
|
24
|
+
*
|
|
25
|
+
* A missing axis is absent, never 0 (#398) — "the engine did not report cpu"
|
|
26
|
+
* and "there is no cpu left" are opposite claims. So a block with only
|
|
27
|
+
* `latency` is read on latency alone, and a block whose `headroom` somehow
|
|
28
|
+
* carries neither is not a headroom figure at all (the M1 validator already
|
|
29
|
+
* refuses one, so this returns undefined rather than inventing a floor).
|
|
30
|
+
*/
|
|
31
|
+
export function headroomOf(block) {
|
|
32
|
+
if (!block || !block.headroom) return undefined;
|
|
33
|
+
const axes = [block.headroom.cpu, block.headroom.latency].filter((v) => typeof v === "number");
|
|
34
|
+
return axes.length ? Math.min(...axes) : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The figure the active mode reads off one block. `drift` reads none. */
|
|
38
|
+
export function figureOf(mode, block) {
|
|
39
|
+
if (mode === "cost") return block && block.cost ? block.cost.perHour : undefined;
|
|
40
|
+
if (mode === "headroom") return headroomOf(block);
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The domain a mode's scale spans, over the entities that carry a figure.
|
|
46
|
+
*
|
|
47
|
+
* `cost` has no absolute range — a per-hour figure means nothing until you know
|
|
48
|
+
* what the rest of the estate costs — so its domain is the estate's own min and
|
|
49
|
+
* max. `headroom` is already a fraction of capacity, so its domain is fixed at
|
|
50
|
+
* 0..1: half free is half free whether the estate is one box or forty, and
|
|
51
|
+
* stretching it to the observed range would make a comfortable estate's best
|
|
52
|
+
* card read as its worst.
|
|
53
|
+
*/
|
|
54
|
+
export function domainFor(mode, values) {
|
|
55
|
+
const xs = values.filter((v) => typeof v === "number" && Number.isFinite(v));
|
|
56
|
+
if (mode === "headroom") return { min: 0, max: 1, n: xs.length, absolute: true };
|
|
57
|
+
if (!xs.length) return null;
|
|
58
|
+
return { min: Math.min(...xs), max: Math.max(...xs), n: xs.length, absolute: false };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Where a figure sits on its domain, 0..1. A flat domain (every entity the
|
|
62
|
+
* same price) puts everything mid-scale rather than at an end — an end would
|
|
63
|
+
* claim a spread the figures do not have. */
|
|
64
|
+
export function positionOf(value, domain) {
|
|
65
|
+
if (!domain || typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
|
66
|
+
if (domain.max === domain.min) return 0.5;
|
|
67
|
+
return clamp((value - domain.min) / (domain.max - domain.min), 0, 1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A colour at `t` on a ramp of anchor colours, interpolated in OKLCH so the
|
|
72
|
+
* steps read as even brightness rather than as even RGB arithmetic — the same
|
|
73
|
+
* space theme.js already does its perceptual work in.
|
|
74
|
+
*/
|
|
75
|
+
export function rampColor(t, stops) {
|
|
76
|
+
if (!stops.length) return "#888888";
|
|
77
|
+
if (stops.length === 1) return stops[0];
|
|
78
|
+
const x = clamp(t, 0, 1) * (stops.length - 1);
|
|
79
|
+
const i = Math.min(Math.floor(x), stops.length - 2);
|
|
80
|
+
const f = x - i;
|
|
81
|
+
const a = hexToOklch(stops[i]);
|
|
82
|
+
const b = hexToOklch(stops[i + 1]);
|
|
83
|
+
// Hue takes the short way round the wheel, or a green→red ramp would swing
|
|
84
|
+
// backwards through blue on a palette whose two anchors straddle 0°.
|
|
85
|
+
let dH = b.H - a.H;
|
|
86
|
+
if (dH > 180) dH -= 360;
|
|
87
|
+
if (dH < -180) dH += 360;
|
|
88
|
+
return oklchToHex({ L: a.L + (b.L - a.L) * f, C: a.C + (b.C - a.C) * f, H: (a.H + dH * f + 360) % 360 });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The two modes' ramps, DERIVED from the active theme the way #229 derives the
|
|
93
|
+
* chrome — no mode owns a fixed colour, and all 552 palettes have to come out
|
|
94
|
+
* of here with a scale you can read low from high.
|
|
95
|
+
*
|
|
96
|
+
* `cost` is a SINGLE-HUE ramp on the accent's own hue and chroma, walked in
|
|
97
|
+
* lightness away from the theme's background: quiet and close to the ground at
|
|
98
|
+
* the cheap end, full strength at the expensive one. Deliberately not a
|
|
99
|
+
* green-to-red ramp — expensive is not a verdict, and behold has no standing to
|
|
100
|
+
* paint an estate's bill as a fault. Magnitude is all the engine stated, so
|
|
101
|
+
* magnitude is all the colour says.
|
|
102
|
+
*
|
|
103
|
+
* Anchored to the BACKGROUND's lightness rather than to the accent's own, for
|
|
104
|
+
* the reason #229 pushes `--active` off the panel: a palette whose blue slot
|
|
105
|
+
* happens to sit where a fixed offset would land (Hot Dog Stand's, on a red
|
|
106
|
+
* ground) otherwise derives two ends you cannot tell apart. Walking outward
|
|
107
|
+
* from the ground gives every one of the 552 palettes a spread, and it is the
|
|
108
|
+
* spread, not the offset, that the scale is for.
|
|
109
|
+
*
|
|
110
|
+
* `headroom` IS a good-to-bad axis — #399 says so in as many words — so it
|
|
111
|
+
* rides the three status hues the drift overlay already anchors across every
|
|
112
|
+
* palette: degraded at no headroom, foreign in the middle, managed at plenty.
|
|
113
|
+
* The card's drift bar keeps its own colour underneath, so the two never occupy
|
|
114
|
+
* the same pixel. Where a palette paints red and green alike (Retro, Hot Dog
|
|
115
|
+
* Stand's mustard), the drift overlay reads alike there too — that is the
|
|
116
|
+
* palette's own choice and not a thing this scale may overrule.
|
|
117
|
+
*/
|
|
118
|
+
export function rampFor(mode, tokens) {
|
|
119
|
+
if (mode === "cost") {
|
|
120
|
+
const { C, H } = hexToOklch(tokens.pending);
|
|
121
|
+
const ground = hexToOklch(tokens.bg).L;
|
|
122
|
+
const away = ground < 0.5 ? 1 : -1;
|
|
123
|
+
// How far there is to walk before the walk runs out of sRGB. A ground
|
|
124
|
+
// sitting almost exactly mid-lightness (Grass) has barely half the room a
|
|
125
|
+
// near-black one does, so the span is what's actually available rather than
|
|
126
|
+
// a constant that silently clips at one end.
|
|
127
|
+
const room = away > 0 ? 0.95 - ground : ground - 0.05;
|
|
128
|
+
const span = Math.min(0.62, room);
|
|
129
|
+
return [atLightness(ground + away * span * 0.32, C * 0.35, H), atLightness(ground + away * span, C, H)];
|
|
130
|
+
}
|
|
131
|
+
if (mode === "headroom") return [tokens.degraded, tokens.foreign, tokens.managed];
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The colour at a requested OKLCH lightness, with chroma eased off until the
|
|
137
|
+
* result actually LANDS there in sRGB.
|
|
138
|
+
*
|
|
139
|
+
* Without this a saturated hue near either end of the range clips on the way
|
|
140
|
+
* out, and the ramp's two ends come back closer together than they were asked
|
|
141
|
+
* to be — which on one palette (Grass, whose background sits within a
|
|
142
|
+
* thousandth of mid-lightness) collapsed the scale to two shades of the same
|
|
143
|
+
* navy. Lightness is the thing the eye reads a sequential scale by, so
|
|
144
|
+
* lightness is the thing that is honoured and chroma is the thing that gives.
|
|
145
|
+
*/
|
|
146
|
+
function atLightness(L, C, H) {
|
|
147
|
+
const want = clamp(L, 0.05, 0.95);
|
|
148
|
+
let c = C;
|
|
149
|
+
let out = oklchToHex({ L: want, C: c, H });
|
|
150
|
+
for (let i = 0; i < 10 && Math.abs(hexToOklch(out).L - want) > 0.01; i++) {
|
|
151
|
+
c *= 0.75;
|
|
152
|
+
out = oklchToHex({ L: want, C: c, H });
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The fill one card draws in a colour-by mode.
|
|
159
|
+
*
|
|
160
|
+
* An entity with no block draws the drift overlay's own neutral — pinhole's
|
|
161
|
+
* `neutralFill`, the ground a card rides when chant could not read it — and
|
|
162
|
+
* NEVER the zero end of the scale. The zero end of a cost ramp says "free" and
|
|
163
|
+
* the zero end of a headroom ramp says "saturated"; both are claims about an
|
|
164
|
+
* entity nothing priced. `unpriced` comes back beside the colour so a caller
|
|
165
|
+
* can mark the card rather than infer the state back out of a hex.
|
|
166
|
+
*/
|
|
167
|
+
export function fillFor(value, domain, ramp, neutralFill) {
|
|
168
|
+
const t = positionOf(value, domain);
|
|
169
|
+
if (t === undefined) return { fill: neutralFill, unpriced: true };
|
|
170
|
+
return { fill: rampColor(t, ramp), unpriced: false };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** How many entities in a set carry a figure for this mode, and how many don't. */
|
|
174
|
+
export function countFigures(mode, blocks) {
|
|
175
|
+
let priced = 0;
|
|
176
|
+
let unpriced = 0;
|
|
177
|
+
for (const b of blocks) {
|
|
178
|
+
if (figureOf(mode, b) === undefined) unpriced++;
|
|
179
|
+
else priced++;
|
|
180
|
+
}
|
|
181
|
+
return { priced, unpriced };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The middle value of a sorted set — the even case averages the two middles,
|
|
185
|
+
* which is the ordinary definition and the only place this module produces a
|
|
186
|
+
* number no engine stated. Its caller labels it "computed" for that reason. */
|
|
187
|
+
export function median(values) {
|
|
188
|
+
const xs = values.filter((v) => typeof v === "number" && Number.isFinite(v)).sort((a, b) => a - b);
|
|
189
|
+
if (!xs.length) return undefined;
|
|
190
|
+
const i = xs.length >> 1;
|
|
191
|
+
return xs.length % 2 ? xs[i] : (xs[i - 1] + xs[i]) / 2;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A per-hour cost, at the precision a cost that small deserves. */
|
|
195
|
+
export function fmtCost(perHour, currency) {
|
|
196
|
+
if (typeof perHour !== "number" || !Number.isFinite(perHour)) return "—";
|
|
197
|
+
const digits = Math.abs(perHour) >= 1 ? 2 : 4;
|
|
198
|
+
return `${perHour.toFixed(digits)} ${currency}/h`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** A headroom fraction as the percentage of capacity still free. */
|
|
202
|
+
export function fmtHeadroom(v) {
|
|
203
|
+
return typeof v === "number" && Number.isFinite(v) ? `${Math.round(v * 100)}% free` : "—";
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The totals row for one scope (a box, or the whole estate), in the active
|
|
208
|
+
* mode's own terms.
|
|
209
|
+
*
|
|
210
|
+
* `cost` quotes arithmetic the server already did — `meta.behaviour.sum` or
|
|
211
|
+
* `.boxes[key]`, both of which are additions of engine figures — and, when the
|
|
212
|
+
* ENGINE stated an estate total, quotes that instead and says whose it is.
|
|
213
|
+
* That is the #398 precedence rule: `total` is the engine's word, `sum` is
|
|
214
|
+
* behold's, and the engine outranks the arithmetic.
|
|
215
|
+
*
|
|
216
|
+
* `headroom` has no server-side aggregate at all, because no engine stated one
|
|
217
|
+
* and adding fractions of capacity would be meaningless. The SPA takes the
|
|
218
|
+
* worst card and the middle card of the scope and says, in the row, that it
|
|
219
|
+
* computed them.
|
|
220
|
+
*
|
|
221
|
+
* `drift` gets no row: the drift overlay counts states, and a count of states
|
|
222
|
+
* is what the Model tab's legend has always shown.
|
|
223
|
+
*/
|
|
224
|
+
export function totalsFor(mode, blocks, sum, engineTotal) {
|
|
225
|
+
if (mode === "drift") return null;
|
|
226
|
+
const counts = countFigures(mode, blocks);
|
|
227
|
+
if (mode === "cost") {
|
|
228
|
+
// The engine's own total wins, and says so. Otherwise the server's sum;
|
|
229
|
+
// otherwise nothing to quote — never a figure computed here, because a
|
|
230
|
+
// currency behold cannot convert is exactly why the sum was withheld.
|
|
231
|
+
if (engineTotal) {
|
|
232
|
+
return {
|
|
233
|
+
mode,
|
|
234
|
+
source: "engine total",
|
|
235
|
+
value: fmtCost(engineTotal.perHour, engineTotal.currency),
|
|
236
|
+
counts,
|
|
237
|
+
computed: false,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
if (!sum) return { mode, source: "sum", value: "—", counts, computed: false, missing: true };
|
|
241
|
+
return {
|
|
242
|
+
mode,
|
|
243
|
+
source: "sum",
|
|
244
|
+
value: fmtCost(sum.perHour, sum.currency),
|
|
245
|
+
counts: { priced: sum.priced, unpriced: sum.unpriced },
|
|
246
|
+
computed: false,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
const xs = blocks.map((b) => headroomOf(b)).filter((v) => v !== undefined);
|
|
250
|
+
if (!xs.length) return { mode, source: "computed", value: "—", counts, computed: true, missing: true };
|
|
251
|
+
return {
|
|
252
|
+
mode,
|
|
253
|
+
source: "computed",
|
|
254
|
+
value: `min ${fmtHeadroom(Math.min(...xs))} · median ${fmtHeadroom(median(xs))}`,
|
|
255
|
+
counts,
|
|
256
|
+
computed: true,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The `n priced · m unpriced` tail every totals row and every legend carries. */
|
|
261
|
+
export function countsText(counts) {
|
|
262
|
+
return `${counts.priced} priced · ${counts.unpriced} unpriced`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The whole totals row as one line of text — what the smoke asserts, and what
|
|
266
|
+
* a narrow panel row renders. */
|
|
267
|
+
export function totalsText(totals) {
|
|
268
|
+
if (!totals) return "";
|
|
269
|
+
return `${totals.value} · ${countsText(totals.counts)}${totals.computed ? " · computed" : ""}`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The provenance badge (#401): `{engine} {version} · {tolerance} · {basis}`,
|
|
274
|
+
* beside every figure rather than once per page.
|
|
275
|
+
*
|
|
276
|
+
* `provenance` is per ENTITY in #398's contract, because one estate can be
|
|
277
|
+
* priced by two engines — so a badge over a SET of entities can only say what
|
|
278
|
+
* that set agrees on. Where they disagree the badge says `mixed` on the field
|
|
279
|
+
* that disagrees rather than picking one, and the tooltip names the engines.
|
|
280
|
+
* A badge over one entity is just that entity's own four fields.
|
|
281
|
+
*
|
|
282
|
+
* `modeled` is spelled out on hover as "modeled, not billed": the whole of #397
|
|
283
|
+
* turns on a prediction never being presented as a bill, and the badge is where
|
|
284
|
+
* that sentence has to live.
|
|
285
|
+
*/
|
|
286
|
+
export function badgeFor(blocks, meta) {
|
|
287
|
+
const provs = blocks.map((b) => b && b.provenance).filter(Boolean);
|
|
288
|
+
if (!provs.length) {
|
|
289
|
+
// No entity provenance in scope: the graph-level engine, when the lexicon
|
|
290
|
+
// stated one, is all there is to show — and it carries no tolerance or
|
|
291
|
+
// basis, because #398 puts those on the entity.
|
|
292
|
+
if (meta && meta.engine) return { text: `${meta.engine} ${meta.version}`, title: engineTitle(meta), engines: [`${meta.engine} ${meta.version}`] };
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
const one = (pick) => {
|
|
296
|
+
const vs = [...new Set(provs.map(pick))];
|
|
297
|
+
return vs.length === 1 ? vs[0] : "mixed";
|
|
298
|
+
};
|
|
299
|
+
const engines = [...new Set(provs.map((p) => `${p.engine} ${p.version}`))].sort();
|
|
300
|
+
const enginePart = engines.length === 1 ? engines[0] : `${engines.length} engines`;
|
|
301
|
+
const tolerance = one((p) => p.tolerance);
|
|
302
|
+
const basis = one((p) => p.basis);
|
|
303
|
+
const parts = [];
|
|
304
|
+
if (engines.length === 1) parts.push(`${provs[0].engine} ${provs[0].version}`);
|
|
305
|
+
else parts.push(enginePart);
|
|
306
|
+
parts.push(tolerance);
|
|
307
|
+
parts.push(basis);
|
|
308
|
+
const bases = [...new Set(provs.map((p) => p.basis))];
|
|
309
|
+
const title =
|
|
310
|
+
(engines.length === 1 ? `Stated by ${engines[0]}` : `Stated by ${engines.join(", ")}`) +
|
|
311
|
+
`, tolerance ${tolerance === "mixed" ? [...new Set(provs.map((p) => p.tolerance))].join(" / ") : tolerance}. ` +
|
|
312
|
+
(bases.length === 1
|
|
313
|
+
? bases[0] === "modeled"
|
|
314
|
+
? "modeled, not billed — a prediction from list prices, never a bill."
|
|
315
|
+
: "validated against a bill."
|
|
316
|
+
: "mixed basis — some figures are modeled, not billed; some are validated against a bill.") +
|
|
317
|
+
(meta && meta.at && meta.at.traffic ? ` At ${meta.at.traffic}.` : provs[0] && blocks[0] && blocks[0].at ? ` At ${blocks[0].at.traffic}.` : "");
|
|
318
|
+
return { text: parts.join(" · "), title, engines };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function engineTitle(meta) {
|
|
322
|
+
return `Stated by ${meta.engine} ${meta.version}.${meta.at && meta.at.traffic ? ` At ${meta.at.traffic}.` : ""} Provenance rides each figure; this scope carries none.`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Which colour-by modes this graph can offer, and why not.
|
|
327
|
+
*
|
|
328
|
+
* A refusal or an absence disables both behaviour modes and hands back the
|
|
329
|
+
* lexicon's own sentence for the tooltip — the disabled control is where a
|
|
330
|
+
* person asks "why not", so that is where the answer goes. `drift` is never
|
|
331
|
+
* disabled: it does not depend on an engine, which is the whole reason #401
|
|
332
|
+
* says the drift overlay keeps rendering through a refusal.
|
|
333
|
+
*/
|
|
334
|
+
export function modeAvailability(meta, mode) {
|
|
335
|
+
const b = (meta && meta.behaviour) || null;
|
|
336
|
+
const overlay = !!b;
|
|
337
|
+
if (!overlay) {
|
|
338
|
+
return { available: false, reason: "the behaviour overlay is only on a live overlay — pick an environment on the Scope tab", refusal: null };
|
|
339
|
+
}
|
|
340
|
+
if (b.refusal) {
|
|
341
|
+
return { available: false, reason: `${b.refusal.reason} ${b.refusal.remedy}`, refusal: b.refusal };
|
|
342
|
+
}
|
|
343
|
+
if (b.absent) return { available: false, reason: b.absent, refusal: null };
|
|
344
|
+
return { available: true, reason: "", refusal: null };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** The mode the SPA may actually paint in, given what the overlay carries.
|
|
348
|
+
* A picked mode outlives an overlay that cannot serve it — switch envs back
|
|
349
|
+
* and the pick is still there — but it never paints a scale over figures that
|
|
350
|
+
* are not on the page. */
|
|
351
|
+
export function effectiveMode(picked, meta) {
|
|
352
|
+
if (picked === "drift") return "drift";
|
|
353
|
+
return modeAvailability(meta, picked).available ? picked : "drift";
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export const COLOUR_MODES = ["drift", "cost", "headroom"];
|
|
357
|
+
|
|
358
|
+
/** The legend's own words for a mode. */
|
|
359
|
+
export const MODE_LABEL = { drift: "drift", cost: "cost", headroom: "headroom" };
|
|
360
|
+
|
|
361
|
+
/** The two ends of a mode's ramp, in words — the legend's axis labels. */
|
|
362
|
+
export function scaleEnds(mode, domain, currency = "") {
|
|
363
|
+
if (mode === "cost") {
|
|
364
|
+
if (!domain) return null;
|
|
365
|
+
return { low: fmtCost(domain.min, currency), high: fmtCost(domain.max, currency) };
|
|
366
|
+
}
|
|
367
|
+
if (mode === "headroom") return { low: "0% free", high: "100% free" };
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** A colour-mix-free CSS gradient over a ramp — sampled, so the stops are the
|
|
372
|
+
* same OKLCH walk the cards took rather than the browser's own sRGB blend. */
|
|
373
|
+
export function rampGradient(ramp, steps = 12) {
|
|
374
|
+
const stops = [];
|
|
375
|
+
for (let i = 0; i < steps; i++) {
|
|
376
|
+
const t = i / (steps - 1);
|
|
377
|
+
stops.push(`${rampColor(t, ramp)} ${(t * 100).toFixed(1)}%`);
|
|
378
|
+
}
|
|
379
|
+
return `linear-gradient(90deg, ${stops.join(", ")})`;
|
|
380
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// #399 M2 / #401 M4 of #397 — the colour-by modes' arithmetic, without a
|
|
2
|
+
// browser. The smoke drives the same decisions through the real SPA; this pins
|
|
3
|
+
// the rules that are easy to get subtly wrong and impossible to see in a
|
|
4
|
+
// screenshot: an unpriced card never landing at the zero end of a scale, a
|
|
5
|
+
// headroom axis the engine did not report never reading as a saturated one,
|
|
6
|
+
// the engine's own total outranking behold's sum, and a badge over two engines
|
|
7
|
+
// refusing to pick one of them.
|
|
8
|
+
import { describe, it, expect } from "vitest";
|
|
9
|
+
import { THEMES } from "./themes.js";
|
|
10
|
+
import { tokensFor, hexToOklch } from "./theme.js";
|
|
11
|
+
import {
|
|
12
|
+
badgeFor,
|
|
13
|
+
blockOf,
|
|
14
|
+
countFigures,
|
|
15
|
+
countsText,
|
|
16
|
+
domainFor,
|
|
17
|
+
effectiveMode,
|
|
18
|
+
figureOf,
|
|
19
|
+
fillFor,
|
|
20
|
+
fmtCost,
|
|
21
|
+
fmtHeadroom,
|
|
22
|
+
headroomOf,
|
|
23
|
+
median,
|
|
24
|
+
modeAvailability,
|
|
25
|
+
positionOf,
|
|
26
|
+
rampColor,
|
|
27
|
+
rampFor,
|
|
28
|
+
scaleEnds,
|
|
29
|
+
totalsFor,
|
|
30
|
+
totalsText,
|
|
31
|
+
} from "./behaviour-scale.js";
|
|
32
|
+
|
|
33
|
+
const prov = (over = {}) => ({ engine: "acme-sim", version: "1.4.2", tolerance: "±15%", basis: "modeled", ...over });
|
|
34
|
+
const block = (over = {}) => ({
|
|
35
|
+
at: { traffic: "100 rps, p50" },
|
|
36
|
+
cost: { perHour: 0.05, currency: "USD" },
|
|
37
|
+
headroom: { cpu: 0.6, latency: 0.4 },
|
|
38
|
+
errorRate: 0.001,
|
|
39
|
+
resilience: { failure: "one zone lost", verdict: "survives" },
|
|
40
|
+
provenance: prov(),
|
|
41
|
+
...over,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("reading a figure off a block", () => {
|
|
45
|
+
it("takes headroom as the LOWER of the axes present", () => {
|
|
46
|
+
expect(headroomOf(block({ headroom: { cpu: 0.6, latency: 0.4 } }))).toBe(0.4);
|
|
47
|
+
expect(headroomOf(block({ headroom: { cpu: 0.2, latency: 0.9 } }))).toBe(0.2);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("reads a block with one axis on that axis alone — a missing axis is absent, not zero", () => {
|
|
51
|
+
// The whole point of #398's "a missing axis is absent, not 0": a block that
|
|
52
|
+
// reports only latency must not read as a card with no CPU left.
|
|
53
|
+
expect(headroomOf(block({ headroom: { latency: 0.8 } }))).toBe(0.8);
|
|
54
|
+
expect(headroomOf(block({ headroom: { cpu: 0.8 } }))).toBe(0.8);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("has no figure at all for an entity with no block", () => {
|
|
58
|
+
expect(blockOf({ id: "a", attrs: {} })).toBeUndefined();
|
|
59
|
+
expect(figureOf("cost", undefined)).toBeUndefined();
|
|
60
|
+
expect(figureOf("headroom", undefined)).toBeUndefined();
|
|
61
|
+
expect(figureOf("drift", block())).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("the domain each mode spans", () => {
|
|
66
|
+
it("stretches cost over the estate's own min and max", () => {
|
|
67
|
+
const d = domainFor("cost", [0.01, 0.5, 0.25]);
|
|
68
|
+
expect(d).toMatchObject({ min: 0.01, max: 0.5, n: 3, absolute: false });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("keeps headroom absolute at 0..1, so a comfortable estate's best card is not painted as its worst", () => {
|
|
72
|
+
const d = domainFor("headroom", [0.7, 0.8, 0.9]);
|
|
73
|
+
expect(d).toMatchObject({ min: 0, max: 1, absolute: true });
|
|
74
|
+
// 0.7 free is high on the axis, not the bottom of a 0.7..0.9 stretch.
|
|
75
|
+
expect(positionOf(0.7, d)).toBeCloseTo(0.7, 6);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("has no cost domain when nothing in scope carries a price", () => {
|
|
79
|
+
expect(domainFor("cost", [])).toBeNull();
|
|
80
|
+
expect(positionOf(0.5, null)).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("puts a flat domain mid-scale rather than at an end", () => {
|
|
84
|
+
expect(positionOf(4, domainFor("cost", [4, 4, 4]))).toBe(0.5);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("the neutral-not-zero rule (#399's own acceptance test)", () => {
|
|
89
|
+
const tokens = tokensFor(THEMES.Argonaut || Object.values(THEMES)[0]);
|
|
90
|
+
const NEUTRAL = "#3a3a3a";
|
|
91
|
+
|
|
92
|
+
it("draws an entity with no block in the drift overlay's neutral, never the zero end of the cost scale", () => {
|
|
93
|
+
const ramp = rampFor("cost", tokens);
|
|
94
|
+
const domain = domainFor("cost", [0.01, 0.5]);
|
|
95
|
+
const unpriced = fillFor(undefined, domain, ramp, NEUTRAL);
|
|
96
|
+
const cheapest = fillFor(0.01, domain, ramp, NEUTRAL);
|
|
97
|
+
expect(unpriced).toEqual({ fill: NEUTRAL, unpriced: true });
|
|
98
|
+
expect(cheapest.unpriced).toBe(false);
|
|
99
|
+
expect(cheapest.fill).not.toBe(NEUTRAL);
|
|
100
|
+
// The distinction the rule exists for: "nothing priced this" must not
|
|
101
|
+
// render as "this is the cheapest thing in the estate".
|
|
102
|
+
expect(unpriced.fill).not.toBe(cheapest.fill);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("does the same in headroom mode, where the zero end would read as saturated", () => {
|
|
106
|
+
const ramp = rampFor("headroom", tokens);
|
|
107
|
+
const domain = domainFor("headroom", [0.4]);
|
|
108
|
+
expect(fillFor(undefined, domain, ramp, NEUTRAL)).toEqual({ fill: NEUTRAL, unpriced: true });
|
|
109
|
+
expect(fillFor(0, domain, ramp, NEUTRAL).fill).not.toBe(NEUTRAL);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("counts the unpriced rather than dropping them", () => {
|
|
113
|
+
const blocks = [block(), undefined, block({ cost: { perHour: 1, currency: "USD" } })];
|
|
114
|
+
expect(countFigures("cost", blocks)).toEqual({ priced: 2, unpriced: 1 });
|
|
115
|
+
expect(countsText(countFigures("cost", blocks))).toBe("2 priced · 1 unpriced");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("the ramps, derived from the theme", () => {
|
|
120
|
+
it("no mode owns a fixed colour — every ramp stop comes out of the active palette", () => {
|
|
121
|
+
for (const name of ["Argonaut", "Builtin Solarized Light", "Hot Dog Stand"]) {
|
|
122
|
+
const th = THEMES[name];
|
|
123
|
+
if (!th) continue;
|
|
124
|
+
const t = tokensFor(th);
|
|
125
|
+
for (const mode of ["cost", "headroom"]) {
|
|
126
|
+
for (const stop of rampFor(mode, t)) expect(stop).toMatch(/^#[0-9a-f]{6}$/i);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("gives every one of the 552 palettes a cost ramp you can tell low from high", () => {
|
|
132
|
+
const bad = [];
|
|
133
|
+
for (const th of Object.values(THEMES)) {
|
|
134
|
+
const ramp = rampFor("cost", tokensFor(th));
|
|
135
|
+
const low = rampColor(0, ramp);
|
|
136
|
+
const high = rampColor(1, ramp);
|
|
137
|
+
// The two ends have to differ perceptibly in lightness, or the scale is
|
|
138
|
+
// one flat colour and says nothing at all.
|
|
139
|
+
if (Math.abs(hexToOklch(low).L - hexToOklch(high).L) < 0.1) bad.push(th.name);
|
|
140
|
+
}
|
|
141
|
+
expect(bad).toEqual([]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("takes the headroom ramp's three stops from the drift overlay's own anchors, in every palette", () => {
|
|
145
|
+
// Not a separation assertion. A handful of palettes (Retro, Hot Dog Stand
|
|
146
|
+
// (Mustard)) paint slots 1 and 2 the same colour, so their red and their
|
|
147
|
+
// green ARE one colour — the drift legend reads alike on them too. That is
|
|
148
|
+
// the palette's choice; what this scale owes is that its ends are those
|
|
149
|
+
// anchors and nothing of its own invention.
|
|
150
|
+
const bad = [];
|
|
151
|
+
for (const th of Object.values(THEMES)) {
|
|
152
|
+
const t = tokensFor(th);
|
|
153
|
+
const ramp = rampFor("headroom", t);
|
|
154
|
+
if (rampColor(0, ramp) !== t.degraded || rampColor(1, ramp) !== t.managed || ramp[1] !== t.foreign) bad.push(th.name);
|
|
155
|
+
}
|
|
156
|
+
expect(bad).toEqual([]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("walks a hue the short way round, so a green-to-red ramp never swings through blue", () => {
|
|
160
|
+
// Two stops straddling 0°: red at ~29°, a green at ~145°. Midway must land
|
|
161
|
+
// in the yellows between them, not on the far side of the wheel.
|
|
162
|
+
const mid = hexToOklch(rampColor(0.5, ["#d02020", "#20b020"]));
|
|
163
|
+
expect(mid.H).toBeGreaterThan(29);
|
|
164
|
+
expect(mid.H).toBeLessThan(145);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("the totals row", () => {
|
|
169
|
+
const sum = { perHour: 0.5669, currency: "USD", priced: 41, unpriced: 4 };
|
|
170
|
+
|
|
171
|
+
it("is absent in drift mode — drift counts states, and the legend already does", () => {
|
|
172
|
+
expect(totalsFor("drift", [block()], sum, undefined)).toBeNull();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("quotes the server's sum of engine figures, with its own priced/unpriced counts", () => {
|
|
176
|
+
const t = totalsFor("cost", [block()], sum, undefined);
|
|
177
|
+
expect(t.source).toBe("sum");
|
|
178
|
+
expect(t.computed).toBe(false);
|
|
179
|
+
expect(totalsText(t)).toBe("0.5669 USD/h · 41 priced · 4 unpriced");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("lets the ENGINE's own total outrank behold's sum, and says whose it is", () => {
|
|
183
|
+
const t = totalsFor("cost", [block(), block()], sum, { perHour: 12.31, currency: "USD" });
|
|
184
|
+
expect(t.source).toBe("engine total");
|
|
185
|
+
expect(t.value).toBe("12.31 USD/h");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("says nothing rather than adding figures the server withheld (mixed currencies)", () => {
|
|
189
|
+
const t = totalsFor("cost", [block()], undefined, undefined);
|
|
190
|
+
expect(t.missing).toBe(true);
|
|
191
|
+
expect(t.value).toBe("—");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("computes headroom itself — no engine states an aggregate — and labels it computed", () => {
|
|
195
|
+
const blocks = [
|
|
196
|
+
block({ headroom: { cpu: 0.2, latency: 0.9 } }),
|
|
197
|
+
block({ headroom: { cpu: 0.6, latency: 0.7 } }),
|
|
198
|
+
block({ headroom: { cpu: 0.9, latency: 0.8 } }),
|
|
199
|
+
undefined,
|
|
200
|
+
];
|
|
201
|
+
const t = totalsFor("headroom", blocks, sum, undefined);
|
|
202
|
+
expect(t.computed).toBe(true);
|
|
203
|
+
expect(t.value).toBe("min 20% free · median 60% free");
|
|
204
|
+
expect(totalsText(t)).toBe("min 20% free · median 60% free · 3 priced · 1 unpriced · computed");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("takes the ordinary median of an even set", () => {
|
|
208
|
+
expect(median([0.1, 0.3, 0.5, 0.7])).toBeCloseTo(0.4, 6);
|
|
209
|
+
expect(median([])).toBeUndefined();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("prints a small per-hour figure at four decimals and a large one at two", () => {
|
|
213
|
+
expect(fmtCost(0.0416, "USD")).toBe("0.0416 USD/h");
|
|
214
|
+
expect(fmtCost(12.3, "EUR")).toBe("12.30 EUR/h");
|
|
215
|
+
expect(fmtHeadroom(0.415)).toBe("42% free");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("labels the cost scale's ends with the estate's own extremes", () => {
|
|
219
|
+
expect(scaleEnds("cost", domainFor("cost", [0.01, 0.5]), "USD")).toEqual({ low: "0.0100 USD/h", high: "0.5000 USD/h" });
|
|
220
|
+
expect(scaleEnds("headroom", domainFor("headroom", [0.5]))).toEqual({ low: "0% free", high: "100% free" });
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("the provenance badge (#401)", () => {
|
|
225
|
+
it("reads {engine} {version} · {tolerance} · {basis} off one entity's own block", () => {
|
|
226
|
+
const b = badgeFor([block()], { engine: "acme-sim", version: "1.4.2", at: { traffic: "100 rps, p50" } });
|
|
227
|
+
expect(b.text).toBe("acme-sim 1.4.2 · ±15% · modeled");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("spells `modeled` out as `modeled, not billed` on hover — the sentence #397 turns on", () => {
|
|
231
|
+
const b = badgeFor([block()], null);
|
|
232
|
+
expect(b.title).toContain("modeled, not billed");
|
|
233
|
+
expect(b.title).toContain("100 rps, p50");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("says `validated against a bill` for a figure that was", () => {
|
|
237
|
+
const b = badgeFor([block({ provenance: prov({ basis: "validated" }) })], null);
|
|
238
|
+
expect(b.text).toContain("validated");
|
|
239
|
+
expect(b.title).toContain("validated against a bill");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("refuses to pick one engine when a scope was priced by two", () => {
|
|
243
|
+
const b = badgeFor([block(), block({ provenance: prov({ engine: "other-sim", version: "2.0", tolerance: "±5%", basis: "validated" }) })], null);
|
|
244
|
+
expect(b.text).toBe("2 engines · mixed · mixed");
|
|
245
|
+
expect(b.engines).toEqual(["acme-sim 1.4.2", "other-sim 2.0"]);
|
|
246
|
+
expect(b.title).toContain("acme-sim 1.4.2, other-sim 2.0");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("falls back to the graph-level engine when no entity in scope carries provenance", () => {
|
|
250
|
+
const b = badgeFor([], { engine: "acme-sim", version: "1.4.2" });
|
|
251
|
+
expect(b.text).toBe("acme-sim 1.4.2");
|
|
252
|
+
expect(b.title).toContain("Provenance rides each figure");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("has nothing to badge when there is neither", () => {
|
|
256
|
+
expect(badgeFor([], null)).toBeNull();
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("which modes a graph can offer", () => {
|
|
261
|
+
it("offers nothing off the overlay at all — there is no behaviour block on the source graph", () => {
|
|
262
|
+
const a = modeAvailability({ mode: "graph" }, "cost");
|
|
263
|
+
expect(a.available).toBe(false);
|
|
264
|
+
expect(a.reason).toContain("pick an environment");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("keeps a picked mode alive when the overlay can serve it", () => {
|
|
268
|
+
const meta = { behaviour: { engine: "acme-sim", version: "1.4.2", sum: { perHour: 1, currency: "USD", priced: 2, unpriced: 0 } } };
|
|
269
|
+
expect(effectiveMode("cost", meta)).toBe("cost");
|
|
270
|
+
expect(effectiveMode("headroom", meta)).toBe("headroom");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("falls back to drift rather than painting a scale over figures nobody sent — the PICK survives, so a live env that IS priced comes back in cost", () => {
|
|
274
|
+
expect(effectiveMode("cost", { behaviour: { absent: "…" } })).toBe("drift");
|
|
275
|
+
expect(effectiveMode("drift", null)).toBe("drift");
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe("what a refusal and an absence do to the modes (#401)", () => {
|
|
280
|
+
const refusal = { reason: "no behavioural engine is configured for this lexicon.", remedy: "Set CHANT_BEHAVIOUR_ENGINE." };
|
|
281
|
+
|
|
282
|
+
it("disables both behaviour modes on a refusal and hands back the lexicon's own words", () => {
|
|
283
|
+
// The reason a disabled control carries is the LEXICON's sentence, not a
|
|
284
|
+
// generic "unavailable" — that is the whole of #401's refusal half.
|
|
285
|
+
const meta = { behaviour: { refusal } };
|
|
286
|
+
for (const mode of ["cost", "headroom"]) {
|
|
287
|
+
const a = modeAvailability(meta, mode);
|
|
288
|
+
expect(a.available).toBe(false);
|
|
289
|
+
expect(a.reason).toContain("no behavioural engine is configured");
|
|
290
|
+
expect(a.reason).toContain("Set CHANT_BEHAVIOUR_ENGINE.");
|
|
291
|
+
expect(a.refusal).toEqual(refusal);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("keeps drift painting through a refusal — drift never depended on an engine", () => {
|
|
296
|
+
expect(effectiveMode("drift", { behaviour: { refusal } })).toBe("drift");
|
|
297
|
+
expect(effectiveMode("cost", { behaviour: { refusal } })).toBe("drift");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("disables the modes on an absence too, with the absent line as the reason and no refusal to print", () => {
|
|
301
|
+
const absent = "no behaviour block: no node carries attrs._behaviour, and no report document at monolith/behaviour.live.json";
|
|
302
|
+
const a = modeAvailability({ behaviour: { absent } }, "cost");
|
|
303
|
+
expect(a.available).toBe(false);
|
|
304
|
+
expect(a.reason).toBe(absent);
|
|
305
|
+
expect(a.refusal).toBeNull();
|
|
306
|
+
});
|
|
307
|
+
});
|