spiega 0.1.0 → 0.1.2
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/README.md +48 -0
- package/package.json +1 -1
- package/src/doctor.js +41 -9
- package/src/render/edges.js +89 -17
- package/src/render/render.js +7 -3
- package/test/smoke.mjs +101 -0
package/README.md
CHANGED
|
@@ -176,6 +176,54 @@ never built. `spiega doctor` finds it:
|
|
|
176
176
|
It exits non-zero when something is wrong, so it can go in a shell prompt or a
|
|
177
177
|
pre-flight check. It makes no network calls and changes nothing.
|
|
178
178
|
|
|
179
|
+
### Developing on spiega, or just using it
|
|
180
|
+
|
|
181
|
+
Installing the package changes the **command**. It does not change the **agent
|
|
182
|
+
setup files**, and those are what an agent actually reads. The two move
|
|
183
|
+
independently, which is the whole reason this section exists:
|
|
184
|
+
|
|
185
|
+
| | set by | points at |
|
|
186
|
+
|---|---|---|
|
|
187
|
+
| the `spiega` command | `npm install -g` / `npm link` | whichever you ran last |
|
|
188
|
+
| skills, MCP entries, `AGENTS.md` blocks | **`spiega install`** | wherever spiega was *when you ran it* |
|
|
189
|
+
|
|
190
|
+
So `npm install -g spiega` on a machine that once had the repo linked leaves
|
|
191
|
+
every agent still loading the working tree — uncommitted edits and all — while
|
|
192
|
+
`spiega` on your own PATH runs the published copy. Nothing warns you: both work,
|
|
193
|
+
and they are different code.
|
|
194
|
+
|
|
195
|
+
`spiega doctor` is what tells you, and it names the path it found:
|
|
196
|
+
|
|
197
|
+
```
|
|
198
|
+
✗ claude skill linked to ~/Documents/…/spiega/skills/spiega, not this install
|
|
199
|
+
✗ claude MCP points at ~/Documents/…/spiega/src/mcp/server.js
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
**To use the published version**, as anyone else would:
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
npm install -g spiega
|
|
206
|
+
spiega install # rewires skills and MCP to the installed copy, backing up what it replaces
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**To go back to developing**, with agents reading your working tree:
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
cd spiega && npm link
|
|
213
|
+
spiega install
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Two things that bite:
|
|
217
|
+
|
|
218
|
+
- **Restart your agent sessions.** A running MCP server holds the code it was
|
|
219
|
+
launched with, so until it restarts it keeps serving the old location
|
|
220
|
+
whatever the config now says. `doctor` counts these.
|
|
221
|
+
- **Check `which -a spiega`, not `which spiega`.** An older install may have
|
|
222
|
+
left a launcher in `~/.local/bin` or `/opt/homebrew/bin` with an absolute
|
|
223
|
+
path baked in. Those shadow the npm copy or are shadowed by it depending on
|
|
224
|
+
PATH order — and an agent's non-interactive shell often has a different PATH
|
|
225
|
+
than yours.
|
|
226
|
+
|
|
179
227
|
### If you use herdr
|
|
180
228
|
|
|
181
229
|
```bash
|
package/package.json
CHANGED
package/src/doctor.js
CHANGED
|
@@ -47,11 +47,43 @@ const bad = (what, detail, fix) => ({ level: 'bad', what, detail, fix });
|
|
|
47
47
|
|
|
48
48
|
const tilde = p => String(p).replace(HOME, '~');
|
|
49
49
|
|
|
50
|
+
/* Most people meet spiega through `npm install -g`, where there is no
|
|
51
|
+
checkout and the word is simply wrong. Developers run it from a clone,
|
|
52
|
+
where "install" would be equally wrong. One word, decided once. */
|
|
53
|
+
const THIS = () => (isCheckout() ? 'this checkout' : 'this install');
|
|
54
|
+
|
|
55
|
+
/* Is ROOT itself a repository? `git -C` walks up, so containment is not
|
|
56
|
+
enough — see checkout() for what that cost. */
|
|
57
|
+
let _isCheckout = null;
|
|
58
|
+
function isCheckout() {
|
|
59
|
+
if (_isCheckout === null) {
|
|
60
|
+
const top = sh('git', ['-C', ROOT, 'rev-parse', '--show-toplevel']);
|
|
61
|
+
try {
|
|
62
|
+
_isCheckout = Boolean(top) && fs.existsSync(top) &&
|
|
63
|
+
fs.realpathSync(top) === fs.realpathSync(ROOT);
|
|
64
|
+
} catch { _isCheckout = false; }
|
|
65
|
+
}
|
|
66
|
+
return _isCheckout;
|
|
67
|
+
}
|
|
68
|
+
|
|
50
69
|
/* ---------- the checkout ---------- */
|
|
51
70
|
|
|
52
71
|
function checkout(rows) {
|
|
53
|
-
|
|
54
|
-
|
|
72
|
+
/* `git -C <dir>` walks UP until it finds a repository, so asking it about a
|
|
73
|
+
directory that is not one answers about whatever encloses it. Installed
|
|
74
|
+
from npm under nvm — which is itself a git clone — that is nvm: doctor
|
|
75
|
+
reported nvm's HEAD as "this checkout", nvm's dirty file as uncommitted
|
|
76
|
+
work, and then measured every skill and MCP entry against it. A page of
|
|
77
|
+
red crosses, all meaningless, on the most ordinary install there is.
|
|
78
|
+
|
|
79
|
+
So the repository has to BE this directory, not merely contain it. */
|
|
80
|
+
const head = isCheckout() ? sh('git', ['-C', ROOT, 'rev-parse', '--short', 'HEAD']) : null;
|
|
81
|
+
if (!head) {
|
|
82
|
+
rows.push(warn('checkout', sh('git', ['-C', ROOT, 'rev-parse', '--show-toplevel'])
|
|
83
|
+
? 'installed from npm, not a checkout — nothing to compare against'
|
|
84
|
+
: 'not a git checkout, so there is nothing to compare against'));
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
55
87
|
|
|
56
88
|
const when = Number(sh('git', ['-C', ROOT, 'log', '-1', '--format=%ct']) || 0) * 1000;
|
|
57
89
|
const subject = sh('git', ['-C', ROOT, 'log', '-1', '--format=%s']) || '';
|
|
@@ -159,8 +191,8 @@ function invocation(rows) {
|
|
|
159
191
|
|
|
160
192
|
const mine = [path.resolve(ROOT, 'bin', 'spiega'), path.resolve(ROOT, 'src', 'cli.js')];
|
|
161
193
|
rows.push(mine.includes(real)
|
|
162
|
-
? ok(
|
|
163
|
-
: bad(
|
|
194
|
+
? ok(`spiega is ${THIS()}`, `${tilde(which)} → ${tilde(real)}`)
|
|
195
|
+
: bad(`spiega is ${THIS()}`, `${tilde(which)} → ${tilde(real)}, which is NOT ${THIS()}`,
|
|
164
196
|
'spiega install'));
|
|
165
197
|
|
|
166
198
|
const floor = nodeFloor();
|
|
@@ -212,8 +244,8 @@ function skills(rows) {
|
|
|
212
244
|
if (st.isSymbolicLink()) {
|
|
213
245
|
const to = fs.readlinkSync(dest);
|
|
214
246
|
rows.push(to === SKILL_SRC
|
|
215
|
-
? ok(`${name} skill`, 'linked — tracks the checkout')
|
|
216
|
-
: bad(`${name} skill`, `linked to ${tilde(to)}, not
|
|
247
|
+
? ok(`${name} skill`, isCheckout() ? 'linked — tracks the checkout' : 'linked')
|
|
248
|
+
: bad(`${name} skill`, `linked to ${tilde(to)}, not ${THIS()}`, `spiega install ${name}`));
|
|
217
249
|
continue;
|
|
218
250
|
}
|
|
219
251
|
// a copy: current only until the source changes, so compare it
|
|
@@ -283,7 +315,7 @@ function registrations(rows) {
|
|
|
283
315
|
const entry = pick(readJSON(file));
|
|
284
316
|
if (!entry) { rows.push(warn(`${name} MCP`, 'not registered', `spiega install ${name}`)); continue; }
|
|
285
317
|
rows.push(entry === MCP_ENTRY
|
|
286
|
-
? ok(`${name} MCP`,
|
|
318
|
+
? ok(`${name} MCP`, `points at ${THIS()}`)
|
|
287
319
|
: bad(`${name} MCP`, `points at ${tilde(entry)}`, `spiega install ${name}`));
|
|
288
320
|
}
|
|
289
321
|
// Codex keeps its servers in TOML
|
|
@@ -292,8 +324,8 @@ function registrations(rows) {
|
|
|
292
324
|
const raw = fs.readFileSync(toml, 'utf8');
|
|
293
325
|
const has = /^\s*\[mcp_servers\.spiega\]/m.test(raw);
|
|
294
326
|
rows.push(has && raw.includes(MCP_ENTRY)
|
|
295
|
-
? ok('codex MCP',
|
|
296
|
-
: warn('codex MCP', has ?
|
|
327
|
+
? ok('codex MCP', `points at ${THIS()}`)
|
|
328
|
+
: warn('codex MCP', has ? `registered, but not at ${THIS()}` : 'not registered', 'spiega install codex'));
|
|
297
329
|
}
|
|
298
330
|
}
|
|
299
331
|
|
package/src/render/edges.js
CHANGED
|
@@ -146,8 +146,94 @@ function edgeTag(at, step, label, { lit, seed }) {
|
|
|
146
146
|
});
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
/*
|
|
150
|
-
|
|
149
|
+
/* Where one tag wants to sit, and how big it is, with no knowledge of the
|
|
150
|
+
others. On a short hop between neighbouring boxes the tag is wider than the
|
|
151
|
+
gap, so centring it on the line buries it under whatever is next door. Lift
|
|
152
|
+
it clear of both boxes instead — nudging it a few pixels is not enough,
|
|
153
|
+
since a box is far taller than the line is thick. `away` records which way
|
|
154
|
+
it was lifted, so that if it later has to move again it keeps going the way
|
|
155
|
+
it already went rather than back across the line it belongs to. */
|
|
156
|
+
/**
|
|
157
|
+
* @param {any} r
|
|
158
|
+
* @param {number|null} step
|
|
159
|
+
*/
|
|
160
|
+
export function tagBox(r, step) {
|
|
161
|
+
const { e } = r;
|
|
162
|
+
const label = (step != null ? String(step) + ' ' : '') + (e.label || '');
|
|
163
|
+
const spot = tagSpot(r.pts);
|
|
164
|
+
const w = lettering(label).length * 6.51 + 20;
|
|
165
|
+
let at = spot.at, away = 0;
|
|
166
|
+
if (w > spot.len * 0.95) {
|
|
167
|
+
const { a, b } = r;
|
|
168
|
+
if (!spot.vertical) {
|
|
169
|
+
away = b.x >= a.x ? -1 : 1;
|
|
170
|
+
at = away < 0
|
|
171
|
+
? [spot.at[0], Math.min(a.y, b.y) - 13]
|
|
172
|
+
: [spot.at[0], Math.max(a.y + a.h, b.y + b.h) + 13];
|
|
173
|
+
} else {
|
|
174
|
+
at = b.y >= a.y
|
|
175
|
+
? [Math.max(a.x + a.w, b.x + b.w) + w / 2 + 10, spot.at[1]]
|
|
176
|
+
: [Math.min(a.x, b.x) - w / 2 - 10, spot.at[1]];
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return { at, w, h: 19, away };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* Tags are placed one edge at a time, which is exactly why they collide: a
|
|
183
|
+
node that fans out sends several edges through the same gutter, each lifts
|
|
184
|
+
its tag clear of the same box, and they all land on the same spot. Nothing
|
|
185
|
+
downstream can tell, because each pill draws itself happily on top of the
|
|
186
|
+
last. So resolve them together, once, before any of them is drawn.
|
|
187
|
+
|
|
188
|
+
Order matters for stability: left to right, then top to bottom, so adding an
|
|
189
|
+
edge on the right never reshuffles the tags on the left. A tag that was
|
|
190
|
+
lifted keeps being pushed the way it was lifted; one sitting on its own line
|
|
191
|
+
moves to whichever side is nearer, since either reads fine. */
|
|
192
|
+
/**
|
|
193
|
+
* @param {any[]} planned
|
|
194
|
+
* @param {(r: any) => number|null} [stepFor]
|
|
195
|
+
* @returns {Map<string, number[]>}
|
|
196
|
+
*/
|
|
197
|
+
export function planTags(planned, stepFor = () => null) {
|
|
198
|
+
const PAD = 4;
|
|
199
|
+
const placed = [];
|
|
200
|
+
const out = new Map();
|
|
201
|
+
const items = planned
|
|
202
|
+
.map(r => ({ r, ...tagBox(r, stepFor(r)) }))
|
|
203
|
+
.filter(t => {
|
|
204
|
+
const { e } = t.r;
|
|
205
|
+
return e.label || stepFor(t.r) != null;
|
|
206
|
+
})
|
|
207
|
+
.sort((p, q) => (p.at[0] - q.at[0]) || (p.at[1] - q.at[1]));
|
|
208
|
+
|
|
209
|
+
const hits = (a, b) =>
|
|
210
|
+
Math.abs(a.at[0] - b.at[0]) < (a.w + b.w) / 2 + PAD &&
|
|
211
|
+
Math.abs(a.at[1] - b.at[1]) < (a.h + b.h) / 2 + PAD;
|
|
212
|
+
|
|
213
|
+
for (const t of items) {
|
|
214
|
+
// bounded: each pass moves clear of one neighbour, and there are finitely
|
|
215
|
+
// many. The cap stops a pathological spec from spinning here.
|
|
216
|
+
for (let guard = 0; guard < 40; guard++) {
|
|
217
|
+
const clash = placed.find(p => hits(p, t));
|
|
218
|
+
if (!clash) break;
|
|
219
|
+
const dir = t.away || (t.at[1] >= clash.at[1] ? 1 : -1);
|
|
220
|
+
t.at = [t.at[0], clash.at[1] + dir * ((t.h + clash.h) / 2 + PAD)];
|
|
221
|
+
t.away = dir;
|
|
222
|
+
}
|
|
223
|
+
placed.push(t);
|
|
224
|
+
out.set(t.r.key, t.at);
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* `state` is 'idle' | 'lit' | 'dim' — the board lights one path at a time.
|
|
230
|
+
`place` is the anchor planTags settled on; without one the tag places itself,
|
|
231
|
+
which is right for a lone edge and wrong for a fan. */
|
|
232
|
+
/**
|
|
233
|
+
* @param {any} r
|
|
234
|
+
* @param {{step?: number|null, state?: string, place?: number[]|null}} [opts]
|
|
235
|
+
*/
|
|
236
|
+
export function drawEdge(r, { step = null, state = 'idle', place = null } = {}) {
|
|
151
237
|
const { e, pts } = r;
|
|
152
238
|
const seed = hashSeed(r.key) % 100000;
|
|
153
239
|
const lit = state === 'lit', dim = state === 'dim';
|
|
@@ -170,21 +256,7 @@ export function drawEdge(r, { step = null, state = 'idle' } = {}) {
|
|
|
170
256
|
// a box is far taller than the line is thick. A pair of edges running both
|
|
171
257
|
// ways between the same two boxes splits above and below, so the two tags
|
|
172
258
|
// do not then collide with each other.
|
|
173
|
-
const
|
|
174
|
-
const need = label.length * 6.5 + 20;
|
|
175
|
-
let at = spot.at;
|
|
176
|
-
if (need > spot.len * 0.95) {
|
|
177
|
-
const { a, b } = r;
|
|
178
|
-
if (!spot.vertical) {
|
|
179
|
-
at = b.x >= a.x
|
|
180
|
-
? [spot.at[0], Math.min(a.y, b.y) - 13]
|
|
181
|
-
: [spot.at[0], Math.max(a.y + a.h, b.y + b.h) + 13];
|
|
182
|
-
} else {
|
|
183
|
-
at = b.y >= a.y
|
|
184
|
-
? [Math.max(a.x + a.w, b.x + b.w) + need / 2 + 10, spot.at[1]]
|
|
185
|
-
: [Math.min(a.x, b.x) - need / 2 - 10, spot.at[1]];
|
|
186
|
-
}
|
|
187
|
-
}
|
|
259
|
+
const at = place || tagBox(r, step).at;
|
|
188
260
|
out += `<g class="tag">${edgeTag(at, step, e.label, { lit, seed: seed + 5 })}</g>`;
|
|
189
261
|
}
|
|
190
262
|
return out + `</g>`;
|
package/src/render/render.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { PAPER, INK, MUTED, ACCENT, PANEL, handFilter } from './theme.js';
|
|
11
11
|
import { roughRect, stroke, fill, text, hashSeed } from './ink.js';
|
|
12
12
|
import { drawNode, drawBoundary } from './glyphs.js';
|
|
13
|
-
import { drawEdge, planEdges, edgeKey } from './edges.js';
|
|
13
|
+
import { drawEdge, planEdges, planTags, edgeKey } from './edges.js';
|
|
14
14
|
import { planCallouts, drawCallout } from './callout.js';
|
|
15
15
|
import { assign, layout, boundaryBox } from './layout.js';
|
|
16
16
|
|
|
@@ -104,10 +104,14 @@ export function renderSVG(spec, { active, placed = null, margin = 0, hand = true
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
const planned = planEdges(edges, boxes);
|
|
107
|
+
/** @param {{e: any, key: string}} r */
|
|
108
|
+
const stepFor = r => (r.e.step != null ? r.e.step : (stepOf[r.key] ?? null));
|
|
109
|
+
// every tag resolved against every other before any is drawn, or two edges
|
|
110
|
+
// leaving the same box land their pills on the same spot
|
|
111
|
+
const tags = planTags(planned, stepFor);
|
|
107
112
|
for (const r of planned) {
|
|
108
113
|
const state = !focus ? 'idle' : focus.edges.has(r.key) ? 'lit' : 'dim';
|
|
109
|
-
|
|
110
|
-
body += drawEdge(r, { step, state });
|
|
114
|
+
body += drawEdge(r, { step: stepFor(r), state, place: tags.get(r.key) || null });
|
|
111
115
|
}
|
|
112
116
|
|
|
113
117
|
for (const n of nodes) {
|
package/test/smoke.mjs
CHANGED
|
@@ -656,6 +656,107 @@ test('colour and glyphs stay out of piped output', async () => {
|
|
|
656
656
|
assert.ok(short.endsWith('spiega'), 'the end is what names it');
|
|
657
657
|
});
|
|
658
658
|
|
|
659
|
+
/* `git -C <dir>` walks UP until it finds a repository. npm's global prefix
|
|
660
|
+
under nvm sits inside nvm's own clone, so doctor answered about nvm:
|
|
661
|
+
reporting its HEAD as "this checkout", its dirty file as uncommitted work,
|
|
662
|
+
and then measuring every skill and MCP entry against it. The install is the
|
|
663
|
+
ordinary one — most people get Node from nvm — so the output almost nobody
|
|
664
|
+
should see was the output almost everybody got. */
|
|
665
|
+
test('doctor does not mistake an enclosing repo for its own checkout', async () => {
|
|
666
|
+
const cp = await import('node:child_process');
|
|
667
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'spiega-enclosed-'));
|
|
668
|
+
try {
|
|
669
|
+
const git = (...a) => cp.execFileSync('git', ['-C', tmp, ...a],
|
|
670
|
+
{ stdio: 'pipe', encoding: 'utf8' });
|
|
671
|
+
git('init', '-q');
|
|
672
|
+
fs.writeFileSync(path.join(tmp, 'f'), 'x');
|
|
673
|
+
git('add', '-A');
|
|
674
|
+
git('-c', 'user.email=t@t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false',
|
|
675
|
+
'commit', '-qm', 'the enclosing repo, not spiega');
|
|
676
|
+
fs.writeFileSync(path.join(tmp, 'dirty'), 'uncommitted');
|
|
677
|
+
|
|
678
|
+
// the package, copied in with no .git of its own — an npm install
|
|
679
|
+
const pkg = path.join(tmp, 'lib', 'node_modules', 'spiega');
|
|
680
|
+
fs.mkdirSync(pkg, { recursive: true });
|
|
681
|
+
for (const d of ['src', 'bin', 'skills', 'integrations']) {
|
|
682
|
+
if (fs.existsSync(path.join(ROOT, d))) fs.cpSync(path.join(ROOT, d), path.join(pkg, d), { recursive: true });
|
|
683
|
+
}
|
|
684
|
+
fs.copyFileSync(path.join(ROOT, 'package.json'), path.join(pkg, 'package.json'));
|
|
685
|
+
|
|
686
|
+
const home = path.join(tmp, 'home');
|
|
687
|
+
fs.mkdirSync(home, { recursive: true });
|
|
688
|
+
// doctor exits non-zero whenever it finds anything to report, which here
|
|
689
|
+
// it will — the point is what it says, not what it returns
|
|
690
|
+
let out;
|
|
691
|
+
try {
|
|
692
|
+
out = cp.execFileSync(process.execPath, [path.join(pkg, 'src', 'cli.js'), 'doctor'], {
|
|
693
|
+
encoding: 'utf8',
|
|
694
|
+
env: { HOME: home, PATH: '/usr/bin:/bin', SPIEGA_ASCII: '1', NO_COLOR: '1' },
|
|
695
|
+
});
|
|
696
|
+
} catch (e) {
|
|
697
|
+
out = String(e.stdout || '');
|
|
698
|
+
assert.ok(out, `doctor produced no output: ${e.stderr || e.message}`);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const line = out.split('\n').find(l => /\bcheckout\b/.test(l)) || '';
|
|
702
|
+
assert.match(line, /not a checkout/,
|
|
703
|
+
`doctor claimed a checkout it does not have: ${line.trim()}`);
|
|
704
|
+
assert.ok(!/the enclosing repo/.test(out),
|
|
705
|
+
'the enclosing repo\'s commit must not appear anywhere in the report');
|
|
706
|
+
assert.ok(!/uncommitted changes/.test(out),
|
|
707
|
+
'nor its dirty working tree');
|
|
708
|
+
} finally {
|
|
709
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
/* A node that fans out sends several edges through the same gutter. Each tag
|
|
714
|
+
lifts itself clear of the same box, independently, and lands in the same
|
|
715
|
+
place — and nothing downstream notices, because each pill draws over the
|
|
716
|
+
last. The shipped example did exactly this: "4 CHARGE" sat under "5 INSERT",
|
|
717
|
+
which is the first picture anyone renders. Overlap is the assertion; a
|
|
718
|
+
screenshot is the only other way to see it, and tests do not look. */
|
|
719
|
+
test('edge tags from the same box do not land on top of each other', async () => {
|
|
720
|
+
const { renderSVG } = await import('../src/render/render.js');
|
|
721
|
+
|
|
722
|
+
const tagRects = svg => [...svg.matchAll(/<g class="tag">([\s\S]*?)<\/g>/g)]
|
|
723
|
+
.map(m => {
|
|
724
|
+
const t = /<text[^>]*x="([-\d.]+)"[^>]*y="([-\d.]+)"[^>]*>([\s\S]*?)<\/text>/.exec(m[1]);
|
|
725
|
+
return t && { x: +t[1], y: +t[2], label: t[3], w: t[3].length * 6.51 + 20, h: 19 };
|
|
726
|
+
})
|
|
727
|
+
.filter(Boolean);
|
|
728
|
+
|
|
729
|
+
const overlaps = rs => {
|
|
730
|
+
const bad = [];
|
|
731
|
+
for (let i = 0; i < rs.length; i++)
|
|
732
|
+
for (let j = i + 1; j < rs.length; j++)
|
|
733
|
+
if (Math.abs(rs[i].x - rs[j].x) < (rs[i].w + rs[j].w) / 2 &&
|
|
734
|
+
Math.abs(rs[i].y - rs[j].y) < rs[i].h)
|
|
735
|
+
bad.push(`${rs[i].label} / ${rs[j].label}`);
|
|
736
|
+
return bad;
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
// the spec that ships, and the one that regressed
|
|
740
|
+
const example = JSON.parse(fs.readFileSync(path.join(ROOT, 'examples/order-flow.json'), 'utf8'));
|
|
741
|
+
const shipped = tagRects(renderSVG(example).svg);
|
|
742
|
+
assert.ok(shipped.length >= 7, `expected the example's tags, got ${shipped.length}`);
|
|
743
|
+
assert.deepEqual(overlaps(shipped), [], 'the example renders with its labels legible');
|
|
744
|
+
|
|
745
|
+
// a wider fan than the example has, so the resolver is tested past one clash
|
|
746
|
+
const fan = {
|
|
747
|
+
nodes: [{ id: 'hub', label: 'Hub' },
|
|
748
|
+
...Array.from({ length: 6 }, (_, i) => ({ id: `n${i}`, label: `Target ${i}` }))],
|
|
749
|
+
edges: Array.from({ length: 6 }, (_, i) =>
|
|
750
|
+
({ from: 'hub', to: `n${i}`, label: `a fairly long edge label ${i}` })),
|
|
751
|
+
};
|
|
752
|
+
const fanned = tagRects(renderSVG(fan).svg);
|
|
753
|
+
assert.equal(fanned.length, 6, 'every edge still gets its tag');
|
|
754
|
+
assert.deepEqual(overlaps(fanned), [], 'six tags out of one box all stay readable');
|
|
755
|
+
|
|
756
|
+
// and the resolver has to be stable: same spec, same answer
|
|
757
|
+
assert.equal(renderSVG(fan).svg, renderSVG(fan).svg, 'layout does not drift between renders');
|
|
758
|
+
});
|
|
759
|
+
|
|
659
760
|
test('the herdr plugin declares the actions it ships, and its scripts are runnable', () => {
|
|
660
761
|
const dir = path.join(ROOT, 'integrations/herdr');
|
|
661
762
|
const toml = fs.readFileSync(path.join(dir, 'herdr-plugin.toml'), 'utf8');
|