@intentius/behold 0.17.0 → 0.19.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.
Files changed (60) hide show
  1. package/AGENTS.md +757 -26
  2. package/LICENSE +190 -0
  3. package/README.md +251 -187
  4. package/THIRD_PARTY.md +75 -0
  5. package/demos.json +80 -9
  6. package/dist/cli.js +5479 -1673
  7. package/example-argo-estate/package-lock.json +6 -6
  8. package/example-carve/app/package-lock.json +3 -3
  9. package/example-choudoufu-estate/.behold.json +8 -0
  10. package/example-choudoufu-estate/README.md +48 -0
  11. package/example-choudoufu-estate/monolith/behaviour.live.json +495 -0
  12. package/example-choudoufu-estate/monolith/carve.json +12 -0
  13. package/example-choudoufu-estate/monolith/main.tf +127 -0
  14. package/example-choudoufu-estate/scripts/choudoufu-down.sh +8 -0
  15. package/example-choudoufu-estate/scripts/choudoufu-up.sh +62 -0
  16. package/example-choudoufu-estate/team-a/behaviour.live.json +179 -0
  17. package/example-choudoufu-estate/team-a/main.tf +58 -0
  18. package/example-choudoufu-estate/team-b/behaviour.live.json +179 -0
  19. package/example-choudoufu-estate/team-b/main.tf +73 -0
  20. package/example-choudoufu-estate/team-c/behaviour.live.json +179 -0
  21. package/example-choudoufu-estate/team-c/main.tf +51 -0
  22. package/example-flux-estate/package-lock.json +6 -6
  23. package/example-k8s/package-lock.json +6 -6
  24. package/example-terraform-estate/README.md +109 -0
  25. package/example-terraform-estate/backends/backend.s3.tf +10 -0
  26. package/example-terraform-estate/envs/dev/README.md +5 -0
  27. package/example-terraform-estate/envs/prod/data.tf +18 -0
  28. package/example-terraform-estate/envs/prod/network.tf +80 -0
  29. package/example-terraform-estate/envs/prod/outputs.tf +13 -0
  30. package/example-terraform-estate/envs/prod/provider.tf +10 -0
  31. package/example-terraform-estate/envs/prod/services.tf +21 -0
  32. package/example-terraform-estate/envs/prod/storage.tf +31 -0
  33. package/example-terraform-estate/envs/prod/variables.tf +17 -0
  34. package/example-terraform-estate/envs/prod/versions.tf +10 -0
  35. package/example-terraform-estate/modules/service/main.tf +58 -0
  36. package/example-terraform-estate/modules/service/outputs.tf +7 -0
  37. package/example-terraform-estate/modules/service/variables.tf +16 -0
  38. package/example-terraform-estate/modules/service/versions.tf +10 -0
  39. package/example-terraform-estate/platform/iam.tf +40 -0
  40. package/example-terraform-estate/platform/kms.tf +16 -0
  41. package/example-terraform-estate/platform/outputs.tf +7 -0
  42. package/example-terraform-estate/platform/variables.tf +10 -0
  43. package/example-terraform-estate/platform/versions.tf +10 -0
  44. package/example-writes/package-lock.json +3 -3
  45. package/licenses/cncf-artwork-LICENSE.md +13 -0
  46. package/licenses/iTerm2-Color-Schemes-LICENSE +31 -0
  47. package/licenses/kubernetes-icons-LICENSE +201 -0
  48. package/package.json +25 -7
  49. package/web/app.js +1309 -41
  50. package/web/behaviour-scale.js +380 -0
  51. package/web/behaviour-scale.test.js +307 -0
  52. package/web/cross-env.js +58 -0
  53. package/web/cross-env.test.js +61 -0
  54. package/web/index.html +62 -2
  55. package/web/pending.js +67 -0
  56. package/web/pending.test.js +62 -0
  57. package/web/read-cost.js +49 -0
  58. package/web/read-cost.test.js +76 -0
  59. package/web/refresh-queue.js +26 -0
  60. package/web/refresh-queue.test.js +54 -0
@@ -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
+ });