@intentius/behold 0.7.0 → 0.9.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 +94 -2
- package/README.md +133 -2
- package/demos.json +25 -1
- package/dist/cli.js +3176 -330
- package/example-argo-estate/README.md +71 -0
- package/example-argo-estate/app-a/chant.config.ts +18 -0
- package/example-argo-estate/app-a/manifests/app.yaml +54 -0
- package/example-argo-estate/app-a/package.json +13 -0
- package/example-argo-estate/app-a/src/app.ts +35 -0
- package/example-argo-estate/app-a/tsconfig.json +1 -0
- package/example-argo-estate/app-b/chant.config.ts +18 -0
- package/example-argo-estate/app-b/manifests/app.yaml +54 -0
- package/example-argo-estate/app-b/package.json +13 -0
- package/example-argo-estate/app-b/src/app.ts +34 -0
- package/example-argo-estate/app-b/tsconfig.json +1 -0
- package/example-argo-estate/control-plane/chant.config.ts +29 -0
- package/example-argo-estate/control-plane/package.json +13 -0
- package/example-argo-estate/control-plane/src/argo.ts +69 -0
- package/example-argo-estate/control-plane/tsconfig.json +1 -0
- package/example-argo-estate/package-lock.json +1949 -0
- package/example-argo-estate/package.json +10 -0
- package/example-carve/README.md +194 -0
- package/example-carve/app/chant.config.ts +6 -0
- package/example-carve/app/package-lock.json +1075 -0
- package/example-carve/app/package.json +13 -0
- package/example-carve/app/src/carved.ts +30 -0
- package/example-carve/app/tsconfig.json +1 -0
- package/example-carve/carve-report.json +872 -0
- package/example-carve/legacy-tf/cdn.tf +23 -0
- package/example-carve/legacy-tf/compute.tf +63 -0
- package/example-carve/legacy-tf/floci-override.tf.disabled +61 -0
- package/example-carve/legacy-tf/modules/cdn/main.tf +72 -0
- package/example-carve/legacy-tf/naming.tf +10 -0
- package/example-carve/legacy-tf/network.tf +119 -0
- package/example-carve/legacy-tf/observability.tf +18 -0
- package/example-carve/legacy-tf/outputs.tf +16 -0
- package/example-carve/legacy-tf/storage.tf +33 -0
- package/example-carve/legacy-tf/terraform.tfstate +602 -0
- package/example-carve/legacy-tf/versions.tf +40 -0
- package/example-flux-estate/README.md +22 -12
- package/example-flux-estate/app-a/package.json +2 -2
- package/example-flux-estate/app-a/src/app.ts +2 -1
- package/example-flux-estate/app-b/chant.config.ts +4 -3
- package/example-flux-estate/app-b/package.json +2 -2
- package/example-flux-estate/app-b/src/app.ts +5 -3
- package/example-flux-estate/control-plane/package.json +2 -2
- package/example-flux-estate/control-plane/src/flux.ts +10 -2
- package/example-flux-estate/package-lock.json +17 -17
- package/example-k8s/README.md +16 -2
- package/example-k8s/base/deployment.yaml +21 -0
- package/example-k8s/base/kustomization.yaml +3 -0
- package/example-k8s/base/service.yaml +12 -0
- package/example-k8s/chant.config.ts +10 -0
- package/example-k8s/overlays/dev/kustomization.yaml +6 -0
- package/example-k8s/package-lock.json +18 -18
- package/example-k8s/package.json +3 -3
- package/example-writes/package-lock.json +14 -14
- package/example-writes/package.json +3 -3
- package/package.json +9 -6
- package/web/app.js +1027 -42
- package/web/carve-steps.js +610 -0
- package/web/carve-steps.test.js +233 -0
- package/web/demos.js +71 -0
- package/web/demos.test.js +83 -0
- package/web/icons/cncf/argo.svg +1 -0
- package/web/icons/cncf/flux.svg +1 -0
- package/web/icons/cncf/helm.svg +1 -0
- package/web/icons/k8s/c-role.svg +1 -0
- package/web/icons/k8s/cm.svg +1 -0
- package/web/icons/k8s/crb.svg +1 -0
- package/web/icons/k8s/crd.svg +1 -0
- package/web/icons/k8s/cronjob.svg +1 -0
- package/web/icons/k8s/deploy.svg +1 -0
- package/web/icons/k8s/ds.svg +1 -0
- package/web/icons/k8s/ep.svg +1 -0
- package/web/icons/k8s/group.svg +1 -0
- package/web/icons/k8s/hpa.svg +1 -0
- package/web/icons/k8s/ing.svg +1 -0
- package/web/icons/k8s/job.svg +1 -0
- package/web/icons/k8s/limits.svg +1 -0
- package/web/icons/k8s/netpol.svg +1 -0
- package/web/icons/k8s/ns.svg +1 -0
- package/web/icons/k8s/pod.svg +1 -0
- package/web/icons/k8s/psp.svg +1 -0
- package/web/icons/k8s/pv.svg +1 -0
- package/web/icons/k8s/pvc.svg +1 -0
- package/web/icons/k8s/quota.svg +1 -0
- package/web/icons/k8s/rb.svg +1 -0
- package/web/icons/k8s/role.svg +1 -0
- package/web/icons/k8s/rs.svg +1 -0
- package/web/icons/k8s/sa.svg +1 -0
- package/web/icons/k8s/sc.svg +1 -0
- package/web/icons/k8s/secret.svg +1 -0
- package/web/icons/k8s/sts.svg +1 -0
- package/web/icons/k8s/svc.svg +1 -0
- package/web/icons/k8s/user.svg +1 -0
- package/web/icons/k8s/vol.svg +1 -0
- package/web/index.html +285 -85
- package/web/json-view.js +334 -0
- package/web/json-view.test.js +218 -0
- package/web/layout-store.js +278 -0
- package/web/layout-store.test.js +388 -0
- package/web/panel.js +28 -0
- package/web/theme.js +114 -6
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// The hand-layout delta store (#228) — what a dragged node or a resized
|
|
2
|
+
// containment box remembers, and where.
|
|
3
|
+
//
|
|
4
|
+
// Deltas, never absolute positions. The graph is chant's and the layout is
|
|
5
|
+
// yours, and that ordering must not invert: a stored `{dx,dy}` is an offset
|
|
6
|
+
// from whatever dagre decided on THIS render, so a node that legitimately
|
|
7
|
+
// moved in the source still lands under your offset instead of pinning itself
|
|
8
|
+
// to a stale coordinate. A delta whose node id is gone from the graph is
|
|
9
|
+
// dropped without a word (`applicable`).
|
|
10
|
+
//
|
|
11
|
+
// No DOM in here on purpose — this is the testable half of #228 (see
|
|
12
|
+
// web/layout-store.test.js). app.js owns the pointer work and the SVG.
|
|
13
|
+
//
|
|
14
|
+
// Two tiers, and the second one lands here too: `localStorage` (free, per
|
|
15
|
+
// browser) and the `.behold/layout.json` sidecar behind `GET/POST /api/layout`
|
|
16
|
+
// (shareable, versionable, and what a server-side export bakes in — see
|
|
17
|
+
// src/layout.ts). `mergeLayouts` decides who wins when both have an opinion,
|
|
18
|
+
// and every server call degrades to localStorage-only without a word.
|
|
19
|
+
|
|
20
|
+
const PREFIX = "behold.layout";
|
|
21
|
+
const NUM = ["dx", "dy", "dw", "dh"];
|
|
22
|
+
|
|
23
|
+
/** A path or a lens name → one flat, readable key segment. */
|
|
24
|
+
export function slug(s) {
|
|
25
|
+
return (
|
|
26
|
+
String(s ?? "")
|
|
27
|
+
.toLowerCase()
|
|
28
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
29
|
+
.replace(/^-+|-+$/g, "") || "unknown"
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The storage key for one project + lens: `behold.layout.<project>.<lens>`. */
|
|
34
|
+
export function layoutKey(projectKey, lens) {
|
|
35
|
+
return `${PREFIX}.${slug(projectKey)}.${slug(lens)}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The project half of the key, from /api/project's payload (see initPickers). */
|
|
39
|
+
export function projectKeyOf(info) {
|
|
40
|
+
return slug(info && info.projectDir);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The lens half of the key. `?logical=1` and the plain graph must keep
|
|
45
|
+
* independent layouts (#228's acceptance), and so must radial vs. straight and
|
|
46
|
+
* one stack vs. another — every one of those re-lays the graph out from
|
|
47
|
+
* scratch, so a delta from one means nothing in another. The env is
|
|
48
|
+
* deliberately NOT in the key: picking an env recolours the same nodes, it
|
|
49
|
+
* doesn't re-place them, and losing your layout on an overlay flip would be
|
|
50
|
+
* the wrong trade.
|
|
51
|
+
*/
|
|
52
|
+
export function lensKeyOf({ zoom, radial, stack } = {}) {
|
|
53
|
+
return [zoom || "graph", radial ? "radial" : null, stack ? `stack-${stack}` : null].filter(Boolean).join("+");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Coerce arbitrary parsed JSON into `{id: {dx,dy,dw,dh}}` of finite non-zero numbers. */
|
|
57
|
+
export function normalize(raw) {
|
|
58
|
+
const out = {};
|
|
59
|
+
if (!raw || typeof raw !== "object") return out;
|
|
60
|
+
for (const [id, d] of Object.entries(raw)) {
|
|
61
|
+
if (!id || !d || typeof d !== "object") continue;
|
|
62
|
+
const clean = {};
|
|
63
|
+
for (const k of NUM) {
|
|
64
|
+
const v = Number(d[k]);
|
|
65
|
+
if (Number.isFinite(v) && v !== 0) clean[k] = v;
|
|
66
|
+
}
|
|
67
|
+
if (Object.keys(clean).length) out[id] = clean;
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** True when nothing is hand-placed — drives whether the reset control shows. */
|
|
73
|
+
export function isEmpty(deltas) {
|
|
74
|
+
return Object.keys(normalize(deltas)).length === 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Read one key. Unparseable, missing, or a storage that throws → `{}`. */
|
|
78
|
+
export function readLayout(storage, key) {
|
|
79
|
+
try {
|
|
80
|
+
return normalize(JSON.parse(storage.getItem(key) || "{}"));
|
|
81
|
+
} catch {
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Write one key (an empty layout removes it rather than storing `{}`). */
|
|
87
|
+
export function writeLayout(storage, key, deltas) {
|
|
88
|
+
const d = normalize(deltas);
|
|
89
|
+
try {
|
|
90
|
+
if (Object.keys(d).length) storage.setItem(key, JSON.stringify(d));
|
|
91
|
+
else storage.removeItem(key);
|
|
92
|
+
} catch {
|
|
93
|
+
/* private mode, quota, a static export off file:// — layout is a nicety */
|
|
94
|
+
}
|
|
95
|
+
return d;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function clearLayout(storage, key) {
|
|
99
|
+
try {
|
|
100
|
+
storage.removeItem(key);
|
|
101
|
+
} catch {
|
|
102
|
+
/* see writeLayout */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Replace one id's delta, returning a NEW map. An all-zero delta is pruned. */
|
|
107
|
+
export function setDelta(deltas, id, delta) {
|
|
108
|
+
const next = normalize(deltas);
|
|
109
|
+
const clean = normalize({ [id]: delta })[id];
|
|
110
|
+
if (clean) next[id] = clean;
|
|
111
|
+
else delete next[id];
|
|
112
|
+
return next;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Drop deltas whose id is no longer in the graph — silently, per #228. */
|
|
116
|
+
export function applicable(deltas, liveIds) {
|
|
117
|
+
const live = liveIds instanceof Set ? liveIds : new Set(liveIds || []);
|
|
118
|
+
const out = {};
|
|
119
|
+
for (const [id, d] of Object.entries(normalize(deltas))) if (live.has(id)) out[id] = d;
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- The delta → SVG math ---------------------------------------------------
|
|
124
|
+
// MUST stay identical to the copies in src/layout.ts, which is what bakes a
|
|
125
|
+
// layout into a server-rendered export. web/layout-store.test.js imports both
|
|
126
|
+
// modules and asserts they agree across a table of cases, so a drift fails a
|
|
127
|
+
// test instead of quietly making an export disagree with the screen it came
|
|
128
|
+
// from. (Same discipline as `canonicalKey`, mirrored in app.js and export.ts.)
|
|
129
|
+
|
|
130
|
+
/** A node group's transform with its delta ridden on top of dagre's own. */
|
|
131
|
+
export function nodeTransform(base, d) {
|
|
132
|
+
const dx = (d && d.dx) || 0;
|
|
133
|
+
const dy = (d && d.dy) || 0;
|
|
134
|
+
if (!dx && !dy) return base;
|
|
135
|
+
return `translate(${dx}, ${dy}) ${base}`.trim();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** First and last coordinate pair of a path `d` — pinhole's own edge anchors. */
|
|
139
|
+
export function pathAnchors(d) {
|
|
140
|
+
const n = String(d || "").match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi);
|
|
141
|
+
if (!n || n.length < 4) return null;
|
|
142
|
+
return { sx: +n[0], sy: +n[1], ex: +n[n.length - 2], ey: +n[n.length - 1] };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** An edge whose ends moved: a straight line between the original anchors,
|
|
146
|
+
* each shifted by ITS OWN end's delta. #228 accepts the straight-line fallback
|
|
147
|
+
* explicitly; spline re-routing stays pinhole's job. */
|
|
148
|
+
export function straightEdge(anchors, from, to) {
|
|
149
|
+
const { sx, sy, ex, ey } = anchors;
|
|
150
|
+
return `M ${sx + ((from && from.dx) || 0)} ${sy + ((from && from.dy) || 0)} L ${ex + ((to && to.dx) || 0)} ${ey + ((to && to.dy) || 0)}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --- The containment clamp (#267) -------------------------------------------
|
|
154
|
+
// A card may not leave the box that draws around it. The box is a claim the
|
|
155
|
+
// estate makes — this namespace, this overlay — and a card floating outside one
|
|
156
|
+
// reads as a claim chant never made. So a drag stops at the wall instead, and
|
|
157
|
+
// "I need more room" means grow the box first (#245 already resizes them).
|
|
158
|
+
//
|
|
159
|
+
// No twin in src/layout.ts, unlike the three functions above: the server-side
|
|
160
|
+
// bake does not reconstruct containment boxes (only pinhole's architecture
|
|
161
|
+
// layout stamps them with a `data-group-id`, and not every lens uses it — see
|
|
162
|
+
// wrapContainmentBoxes in web/app.js), so it has nothing to clamp against and
|
|
163
|
+
// deliberately does not try.
|
|
164
|
+
|
|
165
|
+
/** How far a card keeps off its container's edge, in viewBox units. */
|
|
166
|
+
export const CLAMP_PAD = 8;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* `delta` clamped so `rect` — the node's ORIGINAL, un-displaced box — lands
|
|
170
|
+
* inside `bounds` minus `pad`. `{dw,dh}` ride through untouched; a clamp is
|
|
171
|
+
* about position.
|
|
172
|
+
*
|
|
173
|
+
* Missing or degenerate geometry returns the delta unchanged. That is the
|
|
174
|
+
* fail-open the DOM side needs: a graph pane that hasn't laid out yet measures
|
|
175
|
+
* nothing, and "no measurement" must mean "no clamp", not "everything snaps to
|
|
176
|
+
* the origin".
|
|
177
|
+
*
|
|
178
|
+
* A container with no room for the card (a box dragged smaller than its child)
|
|
179
|
+
* has an empty range; the card centres on that axis rather than being pinned to
|
|
180
|
+
* an arbitrary end of it.
|
|
181
|
+
*/
|
|
182
|
+
export function clampDelta(delta, rect, bounds, pad = CLAMP_PAD) {
|
|
183
|
+
const d = { ...(delta || {}) };
|
|
184
|
+
if (!rect || !bounds || !(rect.w >= 0) || !(rect.h >= 0) || !(bounds.w > 0) || !(bounds.h > 0)) return d;
|
|
185
|
+
d.dx = clampAxis(d.dx || 0, rect.x, rect.w, bounds.x, bounds.w, pad);
|
|
186
|
+
d.dy = clampAxis(d.dy || 0, rect.y, rect.h, bounds.y, bounds.h, pad);
|
|
187
|
+
return d;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function clampAxis(v, start, len, boundStart, boundLen, pad) {
|
|
191
|
+
const lo = boundStart + pad - start;
|
|
192
|
+
const hi = boundStart + boundLen - pad - (start + len);
|
|
193
|
+
if (!Number.isFinite(lo) || !Number.isFinite(hi) || !Number.isFinite(v)) return v;
|
|
194
|
+
if (hi < lo) return (lo + hi) / 2; // no room at all — centre, don't pick a wall
|
|
195
|
+
return Math.min(hi, Math.max(lo, v));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// --- The server tier --------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The two tiers, merged for display. **Local wins where both have an id.**
|
|
202
|
+
*
|
|
203
|
+
* You are looking at this browser's picture: the drag you just did is in
|
|
204
|
+
* localStorage and must not be argued with by a sidecar someone else committed
|
|
205
|
+
* (or that you yourself pushed from another machine). An id only the server has
|
|
206
|
+
* still comes through — that is what makes a shared layout worth having — so
|
|
207
|
+
* the merge adds without ever overwriting. The reverse ordering would mean a
|
|
208
|
+
* `git pull` silently undoing a placement you can see on your screen.
|
|
209
|
+
*
|
|
210
|
+
* The stale-id rule is unchanged and applies after: `applicable` drops whatever
|
|
211
|
+
* is no longer in the graph, whichever tier it came from.
|
|
212
|
+
*/
|
|
213
|
+
export function mergeLayouts(local, server) {
|
|
214
|
+
return { ...normalize(server), ...normalize(local) };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The lens's deltas as the server has them (`{}` on any refusal), plus whether
|
|
218
|
+
* it would accept a write. No server, a static export, preview mode, a
|
|
219
|
+
* read-only project, a 404 from an older behold — all the same answer: this is
|
|
220
|
+
* a localStorage-only session, and nothing says so out loud. */
|
|
221
|
+
export async function fetchServerLayout(fetchFn, lens) {
|
|
222
|
+
try {
|
|
223
|
+
const res = await fetchFn(`/api/layout?lens=${encodeURIComponent(lens)}`);
|
|
224
|
+
if (!res || !res.ok) return { deltas: {}, writable: false };
|
|
225
|
+
const body = await res.json();
|
|
226
|
+
return { deltas: normalize(body && body.deltas), writable: !!(body && body.writable) };
|
|
227
|
+
} catch {
|
|
228
|
+
return { deltas: {}, writable: false };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Push one lens's map to the sidecar. Resolves true iff it was stored.
|
|
233
|
+
* `keepalive` so a push flushed on the way out of the page (see `debounce`'s
|
|
234
|
+
* `flush`) still completes after the document is gone. */
|
|
235
|
+
export async function postServerLayout(fetchFn, lens, deltas) {
|
|
236
|
+
try {
|
|
237
|
+
const res = await fetchFn("/api/layout", {
|
|
238
|
+
method: "POST",
|
|
239
|
+
headers: { "content-type": "application/json" },
|
|
240
|
+
body: JSON.stringify({ lens, deltas: normalize(deltas) }),
|
|
241
|
+
keepalive: true,
|
|
242
|
+
});
|
|
243
|
+
return !!(res && res.ok);
|
|
244
|
+
} catch {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** A trailing-edge debouncer for the POST above: a drag emits a pointer-move
|
|
250
|
+
* storm and ends on the one write that matters. `flush()` runs a pending call
|
|
251
|
+
* now — what a page leaving mid-debounce needs, or the sidecar would miss the
|
|
252
|
+
* last placement the localStorage tier already has. Exported so the test can
|
|
253
|
+
* drive it with a fake clock rather than sleeping. */
|
|
254
|
+
// The default timers are wrappers, not bare references: a detached
|
|
255
|
+
// `setTimeout` is called with no `this` and Chrome answers that with an
|
|
256
|
+
// "Illegal invocation" TypeError.
|
|
257
|
+
export function debounce(fn, ms, setTimer = (cb, t) => setTimeout(cb, t), clearTimer = (id) => clearTimeout(id)) {
|
|
258
|
+
let t = null;
|
|
259
|
+
let last = null;
|
|
260
|
+
const run = (...args) => {
|
|
261
|
+
if (t !== null) clearTimer(t);
|
|
262
|
+
last = args;
|
|
263
|
+
t = setTimer(() => {
|
|
264
|
+
t = null;
|
|
265
|
+
last = null;
|
|
266
|
+
fn(...args);
|
|
267
|
+
}, ms);
|
|
268
|
+
};
|
|
269
|
+
run.flush = () => {
|
|
270
|
+
if (t === null) return;
|
|
271
|
+
clearTimer(t);
|
|
272
|
+
t = null;
|
|
273
|
+
const args = last || [];
|
|
274
|
+
last = null;
|
|
275
|
+
fn(...args);
|
|
276
|
+
};
|
|
277
|
+
return run;
|
|
278
|
+
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
// #228: the hand-layout delta store, checked without a browser. The pointer
|
|
2
|
+
// work and the SVG live in app.js and are covered by smoke/ui-smoke.mjs; every
|
|
3
|
+
// rule that decides WHAT gets stored and under which key lives here.
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
applicable,
|
|
7
|
+
clampDelta,
|
|
8
|
+
clearLayout,
|
|
9
|
+
CLAMP_PAD,
|
|
10
|
+
debounce,
|
|
11
|
+
fetchServerLayout,
|
|
12
|
+
isEmpty,
|
|
13
|
+
layoutKey,
|
|
14
|
+
lensKeyOf,
|
|
15
|
+
mergeLayouts,
|
|
16
|
+
nodeTransform,
|
|
17
|
+
normalize,
|
|
18
|
+
pathAnchors,
|
|
19
|
+
postServerLayout,
|
|
20
|
+
projectKeyOf,
|
|
21
|
+
readLayout,
|
|
22
|
+
setDelta,
|
|
23
|
+
slug,
|
|
24
|
+
straightEdge,
|
|
25
|
+
writeLayout,
|
|
26
|
+
} from "./layout-store.js";
|
|
27
|
+
// The server half of #228 — imported here on purpose: this is the one file
|
|
28
|
+
// that can hold both copies of the delta→SVG math at once (tsconfig excludes
|
|
29
|
+
// web/, so a src/*.test.ts couldn't import the browser module), and the
|
|
30
|
+
// parity block at the bottom is what keeps them from drifting.
|
|
31
|
+
import * as server from "../src/layout.ts";
|
|
32
|
+
|
|
33
|
+
/** A localStorage stand-in; `fail` makes every operation throw (private mode). */
|
|
34
|
+
function fakeStorage(seed = {}, fail = false) {
|
|
35
|
+
const map = new Map(Object.entries(seed));
|
|
36
|
+
return {
|
|
37
|
+
map,
|
|
38
|
+
getItem(k) {
|
|
39
|
+
if (fail) throw new Error("nope");
|
|
40
|
+
return map.has(k) ? map.get(k) : null;
|
|
41
|
+
},
|
|
42
|
+
setItem(k, v) {
|
|
43
|
+
if (fail) throw new Error("nope");
|
|
44
|
+
map.set(k, v);
|
|
45
|
+
},
|
|
46
|
+
removeItem(k) {
|
|
47
|
+
if (fail) throw new Error("nope");
|
|
48
|
+
map.delete(k);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("keys", () => {
|
|
54
|
+
it("slugs a project dir and a lens into one readable key", () => {
|
|
55
|
+
expect(layoutKey("/estates/stub-estate", "components")).toBe("behold.layout.estates-stub-estate.components");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("takes the project half from /api/project's projectDir", () => {
|
|
59
|
+
expect(projectKeyOf({ projectDir: "/Users/me/work/My Estate" })).toBe("users-me-work-my-estate");
|
|
60
|
+
expect(projectKeyOf(null)).toBe("unknown");
|
|
61
|
+
expect(projectKeyOf({})).toBe("unknown");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("gives the logical lens and the plain graph independent keys (#228 acceptance)", () => {
|
|
65
|
+
const p = "/estates/x";
|
|
66
|
+
expect(layoutKey(p, lensKeyOf({ zoom: "logical" }))).not.toBe(layoutKey(p, lensKeyOf({ zoom: "resources" })));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("separates radial from straight, and one stack from another", () => {
|
|
70
|
+
expect(lensKeyOf({ zoom: "resources", radial: true })).toBe("resources+radial");
|
|
71
|
+
expect(lensKeyOf({ zoom: "resources", stack: "edge" })).toBe("resources+stack-edge");
|
|
72
|
+
expect(lensKeyOf({ zoom: "resources" })).toBe("resources");
|
|
73
|
+
expect(layoutKey("/e", lensKeyOf({ zoom: "resources", radial: true }))).not.toBe(layoutKey("/e", lensKeyOf({ zoom: "resources" })));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("keeps the env OUT of the key — an overlay recolours, it does not re-place", () => {
|
|
77
|
+
expect(lensKeyOf({ zoom: "components", env: "prod" })).toBe(lensKeyOf({ zoom: "components" }));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("never produces an empty segment", () => {
|
|
81
|
+
expect(slug("///")).toBe("unknown");
|
|
82
|
+
expect(layoutKey("", "")).toBe("behold.layout.unknown.unknown");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("normalize", () => {
|
|
87
|
+
it("keeps finite non-zero deltas only", () => {
|
|
88
|
+
expect(normalize({ a: { dx: 10, dy: 0, dw: 4 } })).toEqual({ a: { dx: 10, dw: 4 } });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("drops junk without throwing", () => {
|
|
92
|
+
expect(normalize(null)).toEqual({});
|
|
93
|
+
expect(normalize("nope")).toEqual({});
|
|
94
|
+
expect(normalize({ a: 5, b: null, c: { dx: NaN }, d: { dx: "x" }, e: { nope: 1 } })).toEqual({});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("parses numeric strings (JSON round-trips are not always clean)", () => {
|
|
98
|
+
expect(normalize({ a: { dx: "12.5" } })).toEqual({ a: { dx: 12.5 } });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("isEmpty ignores entries that normalize away", () => {
|
|
102
|
+
expect(isEmpty({ a: { dx: 0, dy: 0 } })).toBe(true);
|
|
103
|
+
expect(isEmpty({ a: { dx: 1 } })).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("setDelta", () => {
|
|
108
|
+
it("replaces one id and leaves the rest alone", () => {
|
|
109
|
+
const next = setDelta({ a: { dx: 1 }, b: { dy: 2 } }, "a", { dx: 9, dy: 9 });
|
|
110
|
+
expect(next).toEqual({ a: { dx: 9, dy: 9 }, b: { dy: 2 } });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("prunes an id dragged back to where dagre put it", () => {
|
|
114
|
+
expect(setDelta({ a: { dx: 1 } }, "a", { dx: 0, dy: 0 })).toEqual({});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("does not mutate its input", () => {
|
|
118
|
+
const before = { a: { dx: 1 } };
|
|
119
|
+
setDelta(before, "a", { dx: 4 });
|
|
120
|
+
expect(before).toEqual({ a: { dx: 1 } });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("carries a box's size delta alongside its position delta", () => {
|
|
124
|
+
expect(setDelta({}, "box:vpc", { dx: 3, dy: 0, dw: 40, dh: -20 })).toEqual({ "box:vpc": { dx: 3, dw: 40, dh: -20 } });
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("applicable", () => {
|
|
129
|
+
it("silently drops a delta for a node that left the estate", () => {
|
|
130
|
+
expect(applicable({ gone: { dx: 5 }, here: { dy: 3 } }, ["here"])).toEqual({ here: { dy: 3 } });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("takes a Set as well as an array", () => {
|
|
134
|
+
expect(applicable({ a: { dx: 1 } }, new Set(["a"]))).toEqual({ a: { dx: 1 } });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("is a no-op filter when nothing is live", () => {
|
|
138
|
+
expect(applicable({ a: { dx: 1 } }, [])).toEqual({});
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("storage", () => {
|
|
143
|
+
it("round-trips through a storage", () => {
|
|
144
|
+
const s = fakeStorage();
|
|
145
|
+
const key = layoutKey("/estates/x", "components");
|
|
146
|
+
writeLayout(s, key, { api: { dx: 12, dy: -4 } });
|
|
147
|
+
expect(readLayout(s, key)).toEqual({ api: { dx: 12, dy: -4 } });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("removes the key rather than storing an empty layout", () => {
|
|
151
|
+
const s = fakeStorage();
|
|
152
|
+
writeLayout(s, "k", { api: { dx: 1 } });
|
|
153
|
+
writeLayout(s, "k", {});
|
|
154
|
+
expect(s.map.has("k")).toBe(false);
|
|
155
|
+
expect(readLayout(s, "k")).toEqual({});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("survives a corrupt value", () => {
|
|
159
|
+
expect(readLayout(fakeStorage({ k: "{not json" }), "k")).toEqual({});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("survives a storage that throws (private mode, quota, file://)", () => {
|
|
163
|
+
const s = fakeStorage({}, true);
|
|
164
|
+
expect(() => writeLayout(s, "k", { a: { dx: 1 } })).not.toThrow();
|
|
165
|
+
expect(readLayout(s, "k")).toEqual({});
|
|
166
|
+
expect(() => clearLayout(s, "k")).not.toThrow();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("clearLayout drops just this lens's key", () => {
|
|
170
|
+
const s = fakeStorage();
|
|
171
|
+
writeLayout(s, layoutKey("/e", "components"), { a: { dx: 1 } });
|
|
172
|
+
writeLayout(s, layoutKey("/e", "logical"), { a: { dy: 2 } });
|
|
173
|
+
clearLayout(s, layoutKey("/e", "components"));
|
|
174
|
+
expect(readLayout(s, layoutKey("/e", "components"))).toEqual({});
|
|
175
|
+
expect(readLayout(s, layoutKey("/e", "logical"))).toEqual({ a: { dy: 2 } });
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// --- The server tier (#228, second half) ------------------------------------
|
|
180
|
+
|
|
181
|
+
describe("mergeLayouts", () => {
|
|
182
|
+
it("local wins where both tiers have the same id", () => {
|
|
183
|
+
expect(mergeLayouts({ api: { dx: 10 } }, { api: { dx: -99 } })).toEqual({ api: { dx: 10 } });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("an id only the sidecar has still comes through — that's the point of sharing", () => {
|
|
187
|
+
expect(mergeLayouts({ api: { dx: 10 } }, { worker: { dy: 4 } })).toEqual({ api: { dx: 10 }, worker: { dy: 4 } });
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("is the whole server layout when nothing is local (a fresh browser)", () => {
|
|
191
|
+
expect(mergeLayouts({}, { api: { dx: 1 } })).toEqual({ api: { dx: 1 } });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("normalizes both sides, so junk from either can't reach the SVG", () => {
|
|
195
|
+
expect(mergeLayouts({ a: { dx: 0 } }, { b: "nope", c: { dy: 3 } })).toEqual({ c: { dy: 3 } });
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("fetchServerLayout", () => {
|
|
200
|
+
const res = (body, ok = true) => ({ ok, json: async () => body });
|
|
201
|
+
|
|
202
|
+
it("asks for one lens and normalizes what comes back", async () => {
|
|
203
|
+
const fetchFn = vi.fn(async () => res({ lens: "components", deltas: { api: { dx: "5" }, junk: { dx: 0 } }, writable: true }));
|
|
204
|
+
expect(await fetchServerLayout(fetchFn, "components")).toEqual({ deltas: { api: { dx: 5 } }, writable: true });
|
|
205
|
+
expect(fetchFn).toHaveBeenCalledWith("/api/layout?lens=components");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("encodes the lens key (a stack name can be anything)", async () => {
|
|
209
|
+
const fetchFn = vi.fn(async () => res({ deltas: {}, writable: false }));
|
|
210
|
+
await fetchServerLayout(fetchFn, "resources+stack-edge");
|
|
211
|
+
expect(fetchFn).toHaveBeenCalledWith("/api/layout?lens=resources%2Bstack-edge");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("a refusal is an empty, unwritable answer — never a throw", async () => {
|
|
215
|
+
expect(await fetchServerLayout(async () => res({ error: "read-only" }, false), "components")).toEqual({ deltas: {}, writable: false });
|
|
216
|
+
expect(await fetchServerLayout(async () => {
|
|
217
|
+
throw new Error("offline");
|
|
218
|
+
}, "components")).toEqual({ deltas: {}, writable: false });
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("postServerLayout", () => {
|
|
223
|
+
it("posts the normalized lens map as JSON", async () => {
|
|
224
|
+
const fetchFn = vi.fn(async () => ({ ok: true }));
|
|
225
|
+
expect(await postServerLayout(fetchFn, "components", { api: { dx: 3, dy: 0 } })).toBe(true);
|
|
226
|
+
const [url, init] = fetchFn.mock.calls[0];
|
|
227
|
+
expect(url).toBe("/api/layout");
|
|
228
|
+
expect(init.method).toBe("POST");
|
|
229
|
+
expect(init.headers["content-type"]).toBe("application/json");
|
|
230
|
+
expect(init.keepalive).toBe(true); // survives a flush on the way out of the page
|
|
231
|
+
expect(JSON.parse(init.body)).toEqual({ lens: "components", deltas: { api: { dx: 3 } } });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("a rejection is false, not an exception (offline is fine — localStorage has it)", async () => {
|
|
235
|
+
expect(await postServerLayout(async () => ({ ok: false }), "components", {})).toBe(false);
|
|
236
|
+
expect(
|
|
237
|
+
await postServerLayout(async () => {
|
|
238
|
+
throw new Error("offline");
|
|
239
|
+
}, "components", {}),
|
|
240
|
+
).toBe(false);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("clampDelta — a card stays inside the box drawn around it (#267)", () => {
|
|
245
|
+
// The smoke stub's own geometry: a 150×64 card at (40,80) in a 580×220 box
|
|
246
|
+
// at (20,40). Same numbers as smoke/stub.mjs, so a failure here and a failure
|
|
247
|
+
// there are the same failure.
|
|
248
|
+
const card = { x: 40, y: 80, w: 150, h: 64 };
|
|
249
|
+
const box = { x: 20, y: 40, w: 580, h: 220 };
|
|
250
|
+
|
|
251
|
+
it("leaves a delta that keeps the card inside alone", () => {
|
|
252
|
+
expect(clampDelta({ dx: 50, dy: 30 }, card, box)).toEqual({ dx: 50, dy: 30 });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("stops at the wall minus the padding, on every side", () => {
|
|
256
|
+
expect(clampDelta({ dx: -900, dy: -900 }, card, box)).toEqual({ dx: 20 + CLAMP_PAD - 40, dy: 40 + CLAMP_PAD - 80 });
|
|
257
|
+
expect(clampDelta({ dx: 900, dy: 900 }, card, box)).toEqual({ dx: 600 - CLAMP_PAD - 190, dy: 260 - CLAMP_PAD - 144 });
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("clamps one axis without touching the other", () => {
|
|
261
|
+
expect(clampDelta({ dx: 900, dy: 10 }, card, box)).toEqual({ dx: 402, dy: 10 });
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("carries {dw,dh} through — a clamp is about position", () => {
|
|
265
|
+
expect(clampDelta({ dx: 900, dy: 0, dw: 5, dh: 6 }, card, box)).toEqual({ dx: 402, dy: 0, dw: 5, dh: 6 });
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("centres the card when the container has no room for it at all", () => {
|
|
269
|
+
const tiny = { x: 100, y: 100, w: 50, h: 20 };
|
|
270
|
+
const got = clampDelta({ dx: 900, dy: -900 }, card, tiny);
|
|
271
|
+
expect(got.dx + card.x + card.w / 2).toBeCloseTo(tiny.x + tiny.w / 2);
|
|
272
|
+
expect(got.dy + card.y + card.h / 2).toBeCloseTo(tiny.y + tiny.h / 2);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("fails OPEN on missing geometry — no measurement means no clamp", () => {
|
|
276
|
+
expect(clampDelta({ dx: 900, dy: 900 }, null, box)).toEqual({ dx: 900, dy: 900 });
|
|
277
|
+
expect(clampDelta({ dx: 900, dy: 900 }, card, null)).toEqual({ dx: 900, dy: 900 });
|
|
278
|
+
expect(clampDelta({ dx: 900, dy: 900 }, card, { x: 0, y: 0, w: 0, h: 0 })).toEqual({ dx: 900, dy: 900 });
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("a growing box loosens the clamp; a shrinking one tightens it", () => {
|
|
282
|
+
const grown = { ...box, w: box.w + 100 };
|
|
283
|
+
expect(clampDelta({ dx: 900, dy: 0 }, card, grown).dx).toBe(clampDelta({ dx: 900, dy: 0 }, card, box).dx + 100);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("normalizes away to nothing when the clamp lands on zero", () => {
|
|
287
|
+
// setDelta is what the drag actually calls; a clamped-to-origin delta must
|
|
288
|
+
// prune like any other no-op, not persist as `{dx: 0, dy: 0}`.
|
|
289
|
+
expect(setDelta({}, "api", clampDelta({ dx: 0, dy: 0 }, card, box))).toEqual({});
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe("debounce", () => {
|
|
294
|
+
const fakeTimers = () => {
|
|
295
|
+
const timers = [];
|
|
296
|
+
return { timers, set: (cb) => timers.push(cb) - 1, clear: (i) => (timers[i] = null), run: () => timers.forEach((cb) => cb && cb()) };
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
it("fires once, with the last arguments — a drag is one write, not sixty", () => {
|
|
300
|
+
const t = fakeTimers();
|
|
301
|
+
const fn = vi.fn();
|
|
302
|
+
const d = debounce(fn, 100, t.set, t.clear);
|
|
303
|
+
d("a");
|
|
304
|
+
d("b");
|
|
305
|
+
d("c");
|
|
306
|
+
t.run();
|
|
307
|
+
expect(fn.mock.calls).toEqual([["c"]]);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("flush runs a pending call now — what a page leaving mid-debounce needs", () => {
|
|
311
|
+
const t = fakeTimers();
|
|
312
|
+
const fn = vi.fn();
|
|
313
|
+
const d = debounce(fn, 100, t.set, t.clear);
|
|
314
|
+
d("a");
|
|
315
|
+
d.flush();
|
|
316
|
+
expect(fn.mock.calls).toEqual([["a"]]);
|
|
317
|
+
t.run(); // the cancelled timer must not fire it a second time
|
|
318
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("flush with nothing pending does nothing", () => {
|
|
322
|
+
const t = fakeTimers();
|
|
323
|
+
const fn = vi.fn();
|
|
324
|
+
debounce(fn, 100, t.set, t.clear).flush();
|
|
325
|
+
expect(fn).not.toHaveBeenCalled();
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// --- Parity with src/layout.ts ----------------------------------------------
|
|
330
|
+
// The client paints the deltas in the browser; the server bakes the same ones
|
|
331
|
+
// into an exported SVG. If these two ever disagree, an export stops matching
|
|
332
|
+
// the screen it was taken from — silently. So they are checked against each
|
|
333
|
+
// other here, on the same table.
|
|
334
|
+
describe("the delta→SVG math matches the server's copy", () => {
|
|
335
|
+
const CASES = [
|
|
336
|
+
["translate(40, 80)", { dx: 12, dy: -4 }],
|
|
337
|
+
["translate(40, 80) scale(0.9)", { dx: 0.5, dy: 0 }],
|
|
338
|
+
["", { dx: 3, dy: 4 }],
|
|
339
|
+
["translate(1, 2)", { dx: 0, dy: 0 }],
|
|
340
|
+
["translate(1, 2)", { dw: 20, dh: 10 }],
|
|
341
|
+
["translate(1, 2)", {}],
|
|
342
|
+
];
|
|
343
|
+
it("nodeTransform", () => {
|
|
344
|
+
for (const [base, d] of CASES) expect(nodeTransform(base, d)).toBe(server.nodeTransform(base, d));
|
|
345
|
+
expect(nodeTransform("translate(40, 80)", { dx: 12, dy: -4 })).toBe("translate(12, -4) translate(40, 80)");
|
|
346
|
+
expect(nodeTransform("translate(1, 2)", { dw: 5 })).toBe("translate(1, 2)");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("pathAnchors", () => {
|
|
350
|
+
const DS = ["M 115 112 C 115 112, 305 112, 305 112", "M1 2L3 4", "M 1e2 -3.5 L 7 8", "M 1 2", "", null];
|
|
351
|
+
for (const d of DS) expect(pathAnchors(d)).toEqual(server.pathAnchors(d));
|
|
352
|
+
expect(pathAnchors("M 115 112 C 115 112, 305 112, 305 112")).toEqual({ sx: 115, sy: 112, ex: 305, ey: 112 });
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("straightEdge", () => {
|
|
356
|
+
const a = { sx: 115, sy: 112, ex: 305, ey: 112 };
|
|
357
|
+
for (const [from, to] of [
|
|
358
|
+
[{ dx: 10, dy: 5 }, undefined],
|
|
359
|
+
[undefined, { dx: -2.5, dy: 0 }],
|
|
360
|
+
[{ dx: 1 }, { dy: 2 }],
|
|
361
|
+
[undefined, undefined],
|
|
362
|
+
]) {
|
|
363
|
+
expect(straightEdge(a, from, to)).toBe(server.straightEdge(a, from, to));
|
|
364
|
+
}
|
|
365
|
+
expect(straightEdge(a, { dx: 10, dy: 5 }, undefined)).toBe("M 125 117 L 305 112");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("slug and normalize agree, so a key written by one is read by the other", () => {
|
|
369
|
+
for (const s of ["/estates/stub-estate", "Resources+Radial", "///", "", "stack-edge"]) expect(slug(s)).toBe(server.slug(s));
|
|
370
|
+
for (const raw of [{ a: { dx: 1, dy: 0 } }, { a: { dx: "12.5" } }, { a: 5 }, null, "nope", { a: { dx: NaN } }]) {
|
|
371
|
+
expect(normalize(raw)).toEqual(server.normalizeDeltas(raw));
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("the lens key the client stores under is the one the server derives from a request", () => {
|
|
376
|
+
const q = (s) => new URLSearchParams(s);
|
|
377
|
+
expect(server.lensFromQuery(q("components=1"))).toBe(lensKeyOf({ zoom: "components" }));
|
|
378
|
+
expect(server.lensFromQuery(q("logical=1"))).toBe(lensKeyOf({ zoom: "logical" }));
|
|
379
|
+
expect(server.lensFromQuery(q("env=prod&runtime=1&detail=3"))).toBe(lensKeyOf({ zoom: "runtime" }));
|
|
380
|
+
expect(server.lensFromQuery(q("detail=1"))).toBe(lensKeyOf({ zoom: "composites" }));
|
|
381
|
+
expect(server.lensFromQuery(q("detail=3"))).toBe(lensKeyOf({ zoom: "attributes" }));
|
|
382
|
+
expect(server.lensFromQuery(q(""))).toBe(lensKeyOf({ zoom: "resources" }));
|
|
383
|
+
expect(server.lensFromQuery(q("detail=2&radial=1"))).toBe(lensKeyOf({ zoom: "resources", radial: true }));
|
|
384
|
+
expect(server.lensFromQuery(q("detail=2&stack=edge"))).toBe(lensKeyOf({ zoom: "resources", stack: "edge" }));
|
|
385
|
+
// The env is in neither — an overlay recolours the same nodes (#228).
|
|
386
|
+
expect(server.lensFromQuery(q("components=1&env=prod&tier=dev"))).toBe(server.lensFromQuery(q("components=1")));
|
|
387
|
+
});
|
|
388
|
+
});
|