Skip to content

Commit ce71cf3

Browse files
authored
Merge branch 'main' into blove/homepage-workflow-doc-links
2 parents b7c5a78 + 5fcba51 commit ce71cf3

9 files changed

Lines changed: 3223 additions & 173 deletions
Lines changed: 390 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,390 @@
1+
import { test, expect, type Page } from '@playwright/test';
2+
import {
3+
CONCOURSES,
4+
GATES_A,
5+
GATES_B,
6+
MAIN,
7+
NEAT,
8+
PROVIDERS,
9+
VIEW,
10+
} from '../src/lib/airport-diagram';
11+
12+
const PLATE = '[data-diagram="airport"]';
13+
const STAND_COUNT = GATES_A.length + GATES_B.length;
14+
const NEAT_BOTTOM = NEAT.y + NEAT.height;
15+
16+
/**
17+
* The concourse table, flattened to something structured-cloneable so the
18+
* browser side can compare rendered type against the very constants the
19+
* component drew from. Only the fields the measurements need.
20+
*/
21+
const CONCOURSE_BOXES = CONCOURSES.map((c) => ({
22+
id: c.id,
23+
label: c.label,
24+
x0: c.box.x0,
25+
x1: c.box.x1,
26+
y0: c.box.y0,
27+
y1: c.box.y1,
28+
}));
29+
30+
/**
31+
* Measures the rendered plate against lib/airport-diagram.ts. getBBox reports
32+
* user space, so a containment check inside one coordinate system — text
33+
* inside the concourse that owns it, a mark inside its stand, a taxiway
34+
* against the main terminal — compares directly with the module's numbers.
35+
*
36+
* Overlap between two elements that do NOT share a coordinate system is the
37+
* exception: the airfield sits at a heading and every stand counter-rotates
38+
* about its own centre, so an axis-aligned box in one space is a tilted
39+
* quadrilateral in another. Those comparisons transform each bbox's four
40+
* corners into the root's user space and separate the quads properly, because
41+
* an axis-aligned bound around tilted type reports collisions that are not
42+
* there.
43+
*
44+
* Designing this band produced four collisions that only a human eye caught:
45+
* gate numbers over package labels, a taxiway through the main terminal,
46+
* sub-labels below a 26px concourse, and the scale bar on top of runway 09R.
47+
* Every check here is one of those, generalised.
48+
*/
49+
async function overflowReport(page: Page) {
50+
return page.evaluate(
51+
({ sel, concourses, main, neatBottom }) => {
52+
const issues: string[] = [];
53+
const svg = document.querySelector<SVGSVGElement>(sel);
54+
if (!svg) return ['the airport plate is not on the page'];
55+
56+
const r2 = (n: number) => Math.round(n * 10) / 10;
57+
const box = (el: SVGGraphicsElement) => el.getBBox();
58+
const rect = (b: DOMRect | SVGRect) => ({
59+
x0: b.x,
60+
y0: b.y,
61+
x1: b.x + b.width,
62+
y1: b.y + b.height,
63+
});
64+
const label = (el: Element) => {
65+
const cls = el.getAttribute('class');
66+
return cls ? `${el.tagName}.${cls}` : el.tagName;
67+
};
68+
69+
type Pt = { x: number; y: number };
70+
71+
/** The element's bbox as four corners in the plate's own user space. */
72+
const rootCTM = svg.getScreenCTM();
73+
if (!rootCTM) return ['the airport plate is not being rendered'];
74+
const toRoot = rootCTM.inverse();
75+
const quad = (el: SVGGraphicsElement): Pt[] | null => {
76+
const ctm = el.getScreenCTM();
77+
if (!ctm) return null;
78+
const m = toRoot.multiply(ctm);
79+
const b = el.getBBox();
80+
const p = (x: number, y: number) =>
81+
new DOMPoint(x, y).matrixTransform(m);
82+
return [
83+
p(b.x, b.y),
84+
p(b.x + b.width, b.y),
85+
p(b.x + b.width, b.y + b.height),
86+
p(b.x, b.y + b.height),
87+
];
88+
};
89+
90+
/** Separating-axis test on two convex quads. Touching is not overlapping. */
91+
const quadsOverlap = (a: Pt[], b: Pt[]) => {
92+
for (const poly of [a, b]) {
93+
for (let i = 0; i < poly.length; i += 1) {
94+
const p0 = poly[i];
95+
const p1 = poly[(i + 1) % poly.length];
96+
const nx = -(p1.y - p0.y);
97+
const ny = p1.x - p0.x;
98+
let aMin = Infinity;
99+
let aMax = -Infinity;
100+
let bMin = Infinity;
101+
let bMax = -Infinity;
102+
for (const v of a) {
103+
const d = v.x * nx + v.y * ny;
104+
aMin = Math.min(aMin, d);
105+
aMax = Math.max(aMax, d);
106+
}
107+
for (const v of b) {
108+
const d = v.x * nx + v.y * ny;
109+
bMin = Math.min(bMin, d);
110+
bMax = Math.max(bMax, d);
111+
}
112+
if (aMax <= bMin || bMax <= aMin) return false;
113+
}
114+
}
115+
return true;
116+
};
117+
118+
// 1. Concourse type stays inside the building that names it. The first
119+
// draft put an 8.5px sub-label 33 units down a 26-unit-tall concourse,
120+
// so it rendered below the building entirely.
121+
for (const c of concourses) {
122+
const g = document.querySelector<SVGGElement>(
123+
`${sel} [data-concourse="${c.id}"]`
124+
);
125+
if (!g) {
126+
issues.push(`concourse ${c.id}: not rendered`);
127+
continue;
128+
}
129+
for (const t of g.querySelectorAll<SVGTextElement>('text')) {
130+
const b = rect(box(t));
131+
if (b.x0 < c.x0 || b.x1 > c.x1 || b.y0 < c.y0 || b.y1 > c.y1) {
132+
issues.push(
133+
`concourse ${c.id}: "${t.textContent}" at ${r2(b.x0)},${r2(
134+
b.y0
135+
)}..${r2(b.x1)},${r2(b.y1)} escapes ${c.x0},${c.y0}..${c.x1},${
136+
c.y1
137+
}`
138+
);
139+
}
140+
}
141+
142+
// 2. The name plate is sized by a font-metric estimate
143+
// (6.6 * label.length + 13), so nothing but this holds it to the
144+
// type it is supposed to knock out. A type change breaks it
145+
// silently otherwise.
146+
const plate = g.querySelector<SVGRectElement>('.ap-conc-plate');
147+
const name = g.querySelector<SVGTextElement>('.ap-conc-label');
148+
if (!plate || !name) {
149+
issues.push(`concourse ${c.id}: missing name plate or label`);
150+
continue;
151+
}
152+
const p = rect(box(plate));
153+
const n = rect(box(name));
154+
if (n.x0 < p.x0 || n.x1 > p.x1 || n.y0 < p.y0 || n.y1 > p.y1) {
155+
issues.push(
156+
`concourse ${c.id}: "${name.textContent}" at ${r2(n.x0)},${r2(
157+
n.y0
158+
)}..${r2(n.x1)},${r2(n.y1)} is not backed by its plate ${r2(
159+
p.x0
160+
)},${r2(p.y0)}..${r2(p.x1)},${r2(p.y1)}`
161+
);
162+
}
163+
}
164+
165+
// 3. Every mark sits inside the stand box it is parked on. Both live in
166+
// the stand's counter-rotated group, so the bboxes share a space.
167+
for (const s of document.querySelectorAll<SVGGElement>(
168+
`${sel} [data-stand]`
169+
)) {
170+
const gate = s.dataset['stand'];
171+
const b = s.querySelector<SVGRectElement>('[data-stand-box]');
172+
const img = s.querySelector<SVGImageElement>('image');
173+
if (!b || !img) {
174+
issues.push(`stand ${gate}: missing box or mark`);
175+
continue;
176+
}
177+
const bb = rect(box(b));
178+
const mm = rect(box(img));
179+
if (mm.x0 < bb.x0 || mm.y0 < bb.y0 || mm.x1 > bb.x1 || mm.y1 > bb.y1) {
180+
issues.push(
181+
`stand ${gate}: mark ${r2(mm.x0)},${r2(mm.y0)}..${r2(mm.x1)},${r2(
182+
mm.y1
183+
)} escapes its box ${r2(bb.x0)},${r2(bb.y0)}..${r2(bb.x1)},${r2(
184+
bb.y1
185+
)}`
186+
);
187+
}
188+
}
189+
190+
// 4. No two text runs anywhere on the plate may overlap — the gate
191+
// numbers over the package labels, generalised to every pair.
192+
const texts: { el: SVGTextElement; q: Pt[] }[] = [];
193+
for (const t of document.querySelectorAll<SVGTextElement>(
194+
`${sel} text`
195+
)) {
196+
const q = quad(t);
197+
if (!q) {
198+
issues.push(`${label(t)} "${t.textContent}" is not rendered`);
199+
continue;
200+
}
201+
texts.push({ el: t, q });
202+
}
203+
for (let i = 0; i < texts.length; i += 1) {
204+
for (let j = i + 1; j < texts.length; j += 1) {
205+
if (quadsOverlap(texts[i].q, texts[j].q)) {
206+
issues.push(
207+
`type collides: "${texts[i].el.textContent}" (${label(
208+
texts[i].el
209+
)}) over "${texts[j].el.textContent}" (${label(texts[j].el)})`
210+
);
211+
}
212+
}
213+
}
214+
215+
// 5. No pavement is drawn through the main terminal. An early draft ran
216+
// taxiway N straight across the building.
217+
for (const pave of document.querySelectorAll<SVGGraphicsElement>(
218+
`${sel} .ap-taxiway, ${sel} .ap-pavement`
219+
)) {
220+
const b = rect(box(pave));
221+
if (
222+
b.x0 < main.x1 &&
223+
b.x1 > main.x0 &&
224+
b.y0 < main.y1 &&
225+
b.y1 > main.y0
226+
) {
227+
issues.push(
228+
`pavement ${label(pave)} at ${r2(b.x0)},${r2(b.y0)}..${r2(
229+
b.x1
230+
)},${r2(b.y1)} crosses the main terminal ${main.x0},${main.y0}..${
231+
main.x1
232+
},${main.y1}`
233+
);
234+
}
235+
}
236+
237+
// 6. Chart furniture and the off-airport row live in the margin, below
238+
// the neat line. The scale bar was once drawn on top of runway 09R.
239+
for (const m of document.querySelectorAll<SVGGraphicsElement>(
240+
`${sel} .ap-furniture, ${sel} .ap-off, ${sel} > image`
241+
)) {
242+
const b = rect(box(m));
243+
if (b.y0 < neatBottom) {
244+
issues.push(
245+
`margin ${label(m)} reaches y=${r2(
246+
b.y0
247+
)}, above the neat line at ${neatBottom}`
248+
);
249+
}
250+
}
251+
252+
return issues;
253+
},
254+
{
255+
sel: PLATE,
256+
concourses: CONCOURSE_BOXES,
257+
main: { x0: MAIN.x0, x1: MAIN.x1, y0: MAIN.y0, y1: MAIN.y1 },
258+
neatBottom: NEAT_BOTTOM,
259+
}
260+
);
261+
}
262+
263+
test.describe('homepage airport diagram', () => {
264+
test('draws every structure and keeps the type set inside the one that owns it', async ({
265+
page,
266+
}) => {
267+
await page.setViewportSize({ width: 1440, height: 900 });
268+
await page.goto('/');
269+
// Count before measuring, and before waiting on the plate to scroll into
270+
// view. Every measurement below walks a NodeList, and a walk over an empty
271+
// list passes: without these an unrendered plate reports no issues at all.
272+
// Counting first also means a missing plate fails saying so, rather than
273+
// timing out on a locator that never resolves.
274+
const plate = page.locator(PLATE);
275+
await expect(plate).toHaveCount(1);
276+
await expect(page.locator(`${PLATE} [data-stand]`)).toHaveCount(
277+
STAND_COUNT
278+
);
279+
await expect(page.locator(`${PLATE} [data-stand-box]`)).toHaveCount(
280+
STAND_COUNT
281+
);
282+
await expect(page.locator(`${PLATE} .ap-callsign`)).toHaveCount(
283+
STAND_COUNT
284+
);
285+
await expect(page.locator(`${PLATE} [data-concourse]`)).toHaveCount(
286+
CONCOURSES.length
287+
);
288+
await expect(page.locator(`${PLATE} .ap-conc-plate`)).toHaveCount(
289+
CONCOURSES.length
290+
);
291+
await expect(page.locator(`${PLATE} [data-main-terminal]`)).toHaveCount(1);
292+
await expect(page.locator(`${PLATE} .ap-taxiway`)).toHaveCount(3);
293+
await expect(page.locator(`${PLATE} .ap-furniture`)).toHaveCount(1);
294+
// The off-airport row is the section's central argument rendered as
295+
// geometry — the five providers sit OUTSIDE the neat line because
296+
// Threadplane never talks to them. Check 6 below measures where they are
297+
// drawn, and a walk over an empty NodeList reports no issues, so without
298+
// these two counts deleting the whole row leaves both suites green.
299+
await expect(page.locator(`${PLATE} .ap-off`)).toHaveCount(1);
300+
await expect(page.locator(`${PLATE} > image`)).toHaveCount(PROVIDERS.length);
301+
302+
await plate.scrollIntoViewIfNeeded();
303+
await expect(plate).toBeVisible();
304+
305+
// Fonts must be loaded before measuring, or a fallback face lies about
306+
// widths — the same trap as the architecture diagram's spec.
307+
await page.evaluate(() => document.fonts.ready);
308+
const issues = await overflowReport(page);
309+
expect(issues, issues.join('\n')).toEqual([]);
310+
});
311+
312+
test('keeps the whole drawing inside its viewBox', async ({ page }) => {
313+
await page.setViewportSize({ width: 1440, height: 900 });
314+
await page.goto('/');
315+
await expect(page.locator(PLATE)).toHaveCount(1);
316+
await page.locator(PLATE).scrollIntoViewIfNeeded();
317+
await page.evaluate(() => document.fonts.ready);
318+
const drawn = await page.evaluate((sel) => {
319+
const svg = document.querySelector<SVGSVGElement>(sel);
320+
if (!svg) throw new Error(`nothing on the page matches ${sel}`);
321+
const b = svg.getBBox();
322+
return {
323+
left: b.x,
324+
top: b.y,
325+
right: b.x + b.width,
326+
bottom: b.y + b.height,
327+
};
328+
}, PLATE);
329+
expect(drawn.left).toBeGreaterThanOrEqual(0);
330+
expect(drawn.top).toBeGreaterThanOrEqual(0);
331+
expect(drawn.right).toBeLessThanOrEqual(VIEW.width);
332+
expect(drawn.bottom).toBeLessThanOrEqual(VIEW.height);
333+
});
334+
335+
test('hands a tablet the gate list rather than a plate at 0.7 scale', async ({ page }) => {
336+
// `.ap-svg` is width:100%/height:auto, so the 1000-unit plate scales with
337+
// its container: at a 768px viewport that container is ~707px, the plate
338+
// renders at 0.71, and callsigns land at 6.0px with gate ids at 5.3px.
339+
// The other two cases here test 1440 and 390 and straddle the hole
340+
// entirely, which is how it survived review. The stack therefore takes
341+
// over at 1023px, not the usual 767px — and never as a sideways scroll,
342+
// which the spec rules out for this band.
343+
await page.setViewportSize({ width: 900, height: 900 });
344+
await page.goto('/');
345+
await page.locator('#compatibility').scrollIntoViewIfNeeded();
346+
347+
await expect(page.locator('.airport-figure')).toBeHidden();
348+
349+
// toBeVisible() is not enough on its own: on desktop the stack is hidden
350+
// by clip-path at 1px square, which Playwright still calls visible. Its
351+
// laid-out width is what says the list is the form a tablet actually gets.
352+
const stack = page.locator('.airport-stack');
353+
await expect(stack).toBeVisible();
354+
const box = await stack.boundingBox();
355+
expect(box, 'the accessible stack is not laid out at all').not.toBeNull();
356+
expect(
357+
box?.width ?? 0,
358+
'the gate list is still clipped to its 1px visually-hidden box at 900px'
359+
).toBeGreaterThan(200);
360+
361+
await expect(stack.locator('.airport-stack-gates li')).toHaveCount(
362+
STAND_COUNT
363+
);
364+
const wide = await page.evaluate(
365+
() => document.documentElement.scrollWidth > window.innerWidth
366+
);
367+
expect(wide, 'no horizontal page scroll on a tablet').toBe(false);
368+
});
369+
370+
test('lists the same gates on a phone instead of scrolling the drawing sideways', async ({
371+
page,
372+
}) => {
373+
await page.setViewportSize({ width: 390, height: 844 });
374+
await page.goto('/');
375+
const stack = page.locator('.airport-stack');
376+
await page.locator('#compatibility').scrollIntoViewIfNeeded();
377+
await expect(stack).toBeVisible();
378+
await expect(page.locator('.airport-figure')).toBeHidden();
379+
await expect(stack.locator('.airport-stack-gates li')).toHaveCount(
380+
STAND_COUNT
381+
);
382+
await expect(stack.locator('.airport-stack-group')).toHaveCount(
383+
CONCOURSES.length
384+
);
385+
const wide = await page.evaluate(
386+
() => document.documentElement.scrollWidth > window.innerWidth
387+
);
388+
expect(wide, 'no horizontal page scroll on a phone').toBe(false);
389+
});
390+
});

0 commit comments

Comments
 (0)