From c9c82489ad1053ba2f9c9632bad04a815f4a9611 Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Sat, 13 Jun 2026 22:05:28 -0700 Subject: [PATCH] Add graph-geometry diagnostic (baseline for the node/edge/label overhaul) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before changing the layout system, make its problems measurable + visible. This report-only diagnostic (npm run test:geometry, 'geometry' project) seeds a controlled scenario — a CLOSE node pair and a FAR node pair sharing the same wide edge label — and measures real rendered geometry from the DOM: - edge attachment: distance from each edge endpoint to the node CENTER (0px today => edges attach to centers, not borders) and how far the endpoint sits inside the card, - label fit: label box width vs the clear span between the two cards, and whether the label box overlaps either card, - minimum length: actual edge length vs what the label needs. Baseline captured on the live stack (1440x900): close pair: centerLen=200 clearSpan=30 labelW=104 -> overflow +74px, overlapsCard=true, endpoints 0px from both centers far pair: centerLen=470 clearSpan=300 labelW=104 -> fits, no overlap Confirms: edges are center-attached, and a short edge can't fit its label so the label overflows onto the cards. Output: test-artifacts/geometry/ {report.json, scenario-full.png, close-pair-centered.png}. Seeds with the [E2E] sentinel + self-heals (sweepTestData before/after), so it leaves the dev DB clean. Re-run after a fix to verify overflow/overlap -> 0 and endpoints move to the border. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 1 + playwright.config.ts | 9 + tests/diagnostics/graph-geometry.spec.ts | 230 +++++++++++++++++++++++ 3 files changed, 240 insertions(+) create mode 100644 tests/diagnostics/graph-geometry.spec.ts diff --git a/package.json b/package.json index b318285..f363cb8 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "test:perf:scale": "playwright test --project=perf-scale --reporter=line && node tests/generate-perf-report.mjs", "report:perf": "node tests/generate-perf-report.mjs", "test:vlm": "playwright test --project=vlm --reporter=line && node tests/generate-vlm-report.mjs", + "test:geometry": "playwright test --project=geometry --reporter=line", "report:vlm": "node tests/generate-vlm-report.mjs", "perf:bundle": "node tests/perf/check-bundle-size.mjs" }, diff --git a/playwright.config.ts b/playwright.config.ts index d0d1716..bfb7c28 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -91,6 +91,15 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'], screenshot: 'on' }, }, + /* Graph-geometry diagnostics: measures node/edge/label geometry from the + * rendered DOM (edge attachment, label fit, overlaps) to SEE layout issues + * before/after a fix. Report-only. Run via `npm run test:geometry`. */ + { + name: 'geometry', + testDir: './tests/diagnostics', + use: { ...devices['Desktop Chrome'], screenshot: 'on' }, + }, + // Commented out until browsers installed with system dependencies // { // name: 'GraphDone-Core/dev-neo4j/firefox', diff --git a/tests/diagnostics/graph-geometry.spec.ts b/tests/diagnostics/graph-geometry.spec.ts new file mode 100644 index 0000000..14e6562 --- /dev/null +++ b/tests/diagnostics/graph-geometry.spec.ts @@ -0,0 +1,230 @@ +import { test, expect, Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { login, TEST_USERS } from '../helpers/auth'; +import { sweepTestData, TEST_GRAPH_PREFIX } from '../helpers/dbHealing'; + +/** + * Graph-geometry diagnostic — measures node/edge/label geometry from the REAL + * rendered DOM so layout problems are visible quantitatively + visually before + * (and after) any fix. Report-only; it never changes app behaviour. + * + * It checks the three things called out for the layout overhaul: + * 1. EDGE ATTACHMENT — do edge line endpoints sit at the node CENTER (buried + * under the card) or on the node BORDER? Measured as the gap between the + * edge endpoint and the node center (≈0 today = center-attached) and how + * far the endpoint is INSIDE the card. + * 2. LABEL FIT — is the edge long enough for its label? i.e. is the label's + * rendered box wider than the clear span between the two cards, and does + * it OVERLAP either card? + * 3. MIN LENGTH — does the actual edge length respect a label-width minimum? + * + * Output: test-artifacts/geometry/{report.json, *.png}. The screenshots feed + * the eye (and the VLM suite). A FRESH controlled scenario is seeded so the + * problems are reproducible regardless of demo data; healing cleans it up. + */ + +const OUT = path.resolve(process.cwd(), 'test-artifacts/geometry'); + +interface EdgeGeom { + type: string; + centerLen: number; // node-center to node-center distance (what the sim uses) + endpointAtSourceCenterPx: number; // |edge (x1,y1) - source center| (≈0 => center-attached) + endpointAtTargetCenterPx: number; + sourceInsetPx: number; // how far the edge endpoint is INSIDE the source card border, along the edge + targetInsetPx: number; + labelW: number; + labelH: number; + clearSpanPx: number; // straight-line gap between the two card borders along the edge + labelOverflowPx: number; // labelW - clearSpan (>0 => label can't fit between cards) + labelOverlapsCard: boolean; // label box intersects either node card rect +} + +async function readGeometry(page: Page): Promise<{ edges: EdgeGeom[] }> { + return page.evaluate(() => { + const rectFor = (nodeG: Element) => { + const bg = nodeG.querySelector('.node-bg') as Element | null; + const r = (bg ?? nodeG).getBoundingClientRect(); + return { cx: r.x + r.width / 2, cy: r.y + r.height / 2, w: r.width, h: r.height }; + }; + // node id -> screen rect/center + const nodeById: Record = {}; + document.querySelectorAll('.graph-container svg .node').forEach((n) => { + const id = (n as any).__data__?.id; + if (id) nodeById[id] = rectFor(n); + }); + + const boxesOverlap = (a: any, b: any) => + Math.abs(a.cx - b.cx) * 2 < a.w + b.w && Math.abs(a.cy - b.cy) * 2 < a.h + b.h; + + // straight gap between two axis-aligned card borders along the connecting line + const clearSpan = (s: any, t: any) => { + const dx = t.cx - s.cx, dy = t.cy - s.cy; + const len = Math.hypot(dx, dy) || 1; + const ux = Math.abs(dx) / len, uy = Math.abs(dy) / len; + const proj = (n: any) => (n.w / 2) * ux + (n.h / 2) * uy; + return Math.max(0, len - proj(s) - proj(t)); + }; + + const edges: any[] = []; + document.querySelectorAll('.graph-container svg .edge').forEach((e) => { + const d = (e as any).__data__; + if (!d?.source || !d?.target) return; + const sId = typeof d.source === 'object' ? d.source.id : d.source; + const tId = typeof d.target === 'object' ? d.target.id : d.target; + const s = nodeById[sId], t = nodeById[tId]; + if (!s || !t) return; + + // The rendered edge endpoints (screen coords) + const le = e as SVGLineElement; + const r = le.getBoundingClientRect(); + // endpoints from attributes mapped to screen via the line's CTM is messy; + // instead compare the edge's own bbox extremes to the node centers. + const x1 = le.x1.baseVal.value, y1 = le.y1.baseVal.value, x2 = le.x2.baseVal.value, y2 = le.y2.baseVal.value; + // map svg-userspace endpoint to screen using the element CTM + const m = le.getScreenCTM(); + const p1 = m ? new DOMPoint(x1, y1).matrixTransform(m) : { x: r.left, y: r.top }; + const p2 = m ? new DOMPoint(x2, y2).matrixTransform(m) : { x: r.right, y: r.bottom }; + + const len = Math.hypot(t.cx - s.cx, t.cy - s.cy); + const ux = (t.cx - s.cx) / (len || 1), uy = (t.cy - s.cy) / (len || 1); + const proj = (n: any) => (n.w / 2) * Math.abs(ux) + (n.h / 2) * Math.abs(uy); + + // label box for this edge + const labelG = document.querySelector(`.edge-label-group`); + let labelW = 0, labelH = 0, labelBox: any = null; + const allLabels = [...document.querySelectorAll('.graph-container svg .edge-label-group')]; + // match label to edge by id when possible + const lg = allLabels.find((g) => (g as any).__data__?.id === d.id) as Element | undefined; + if (lg) { + const lr = lg.getBoundingClientRect(); + labelW = lr.width; labelH = lr.height; + labelBox = { cx: lr.x + lr.width / 2, cy: lr.y + lr.height / 2, w: lr.width, h: lr.height }; + } + + edges.push({ + type: d.type, + centerLen: Math.round(len), + endpointAtSourceCenterPx: Math.round(Math.hypot(p1.x - s.cx, p1.y - s.cy)), + endpointAtTargetCenterPx: Math.round(Math.hypot(p2.x - t.cx, p2.y - t.cy)), + sourceInsetPx: Math.round(proj(s)), + targetInsetPx: Math.round(proj(t)), + labelW: Math.round(labelW), + labelH: Math.round(labelH), + clearSpanPx: Math.round(clearSpan(s, t)), + labelOverflowPx: Math.round(labelW - clearSpan(s, t)), + labelOverlapsCard: labelBox ? (boxesOverlap(labelBox, s) || boxesOverlap(labelBox, t)) : false, + }); + }); + return { edges }; + }); +} + +async function gql(page: Page, query: string, variables?: unknown) { + return page.evaluate(async ({ query, variables }) => { + const token = localStorage.getItem('authToken') ?? ''; + const res = await fetch('/api/graphql', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ query, variables }) }); + return res.json(); + }, { query, variables }); +} + +test.describe('graph geometry diagnostic @geometry', () => { + test.describe.configure({ timeout: 180_000 }); + test.beforeAll(async () => { await sweepTestData('geometry:before'); }); + test.afterAll(async () => { await sweepTestData('geometry:after'); }); + + test('measure edge attachment, label fit and overlaps', async ({ page }) => { + fs.mkdirSync(OUT, { recursive: true }); + await page.setViewportSize({ width: 1440, height: 900 }); + await login(page, TEST_USERS.ADMIN); + await page.waitForTimeout(1500); + + // Seed a controlled scenario: two PINNED pairs sharing the widest edge + // label. One pair is close (label can't fit), one is far (control). + const me = await gql(page, '{ me { id } }'); + const userId = me.data.me.id; + const g = await gql(page, `mutation($i:[GraphCreateInput!]!){createGraphs(input:$i){graphs{id}}}`, + { i: [{ name: `${TEST_GRAPH_PREFIX} Geometry ${Date.now()}`, type: 'PROJECT', status: 'ACTIVE', createdBy: userId, isShared: true }] }); + const graphId = g.data.createGraphs.graphs[0].id; + + // positions are pinned (snapshot-authoritative) so the edges stay the + // length we choose. Close pair: 200px apart (cards ~170 wide => ~30px clear, + // far short of a "Depends On"/"Is Part Of" label). Far pair: 470px apart. + const nodeDefs = [ + { key: 'closeA', x: -100, y: -120 }, { key: 'closeB', x: 100, y: -120 }, + { key: 'farA', x: -235, y: 140 }, { key: 'farB', x: 235, y: 140 }, + ]; + const created = await gql(page, `mutation($i:[WorkItemCreateInput!]!){createWorkItems(input:$i){workItems{id title}}}`, + { i: nodeDefs.map((n) => ({ type: 'TASK', title: n.key, status: 'IN_PROGRESS', priority: 0.5, positionX: n.x, positionY: n.y, positionZ: 0, owner: { connect: { where: { node: { id: userId } } } }, graph: { connect: { where: { node: { id: graphId } } } } })) }); + const ids: Record = {}; + for (const w of created.data.createWorkItems.workItems) ids[w.title] = w.id; + + const mkEdge = (a: string, b: string, type: string) => ({ type, weight: 0.6, source: { connect: { where: { node: { id: ids[a] } } } }, target: { connect: { where: { node: { id: ids[b] } } } } }); + await gql(page, `mutation($i:[EdgeCreateInput!]!){createEdges(input:$i){edges{id}}}`, + { i: [mkEdge('closeA', 'closeB', 'DEPENDS_ON'), mkEdge('farA', 'farB', 'DEPENDS_ON')] }); + + // Open the scenario graph. + await page.evaluate((gid) => { localStorage.setItem('currentGraphId', gid); localStorage.setItem('graphdone.quality.override', 'HIGH'); }, graphId); + await page.reload(); + await page.waitForTimeout(7000); // load + settle (pinned nodes barely move) + + await page.screenshot({ path: path.join(OUT, 'scenario-full.png') }); + + // Center the view on the close pair (graph midpoint (0,-120)) so the label + // overflow is clearly visible, not tucked under the toolbar. + await page.evaluate(() => (window as any).miniMapNavigate?.(0, -120)); + await page.waitForTimeout(1000); + await page.screenshot({ path: path.join(OUT, 'close-pair-centered.png') }); + + const geom = await readGeometry(page); + + // Clipped close-up around the CLOSE pair so the label overflow is obvious, + // using the measured on-screen rects (independent of the app's framing). + const closeRects = await page.evaluate(() => { + const out: Array<{ x: number; y: number; w: number; h: number }> = []; + document.querySelectorAll('.graph-container svg .node').forEach((n) => { + const t = (n as any).__data__?.title; + if (t === 'closeA' || t === 'closeB') { + const r = ((n.querySelector('.node-bg') as Element) ?? n).getBoundingClientRect(); + out.push({ x: r.x, y: r.y, w: r.width, h: r.height }); + } + }); + return out; + }); + if (closeRects.length === 2) { + const margin = 90; + const minX = Math.max(0, Math.min(...closeRects.map((r) => r.x)) - margin); + const minY = Math.max(0, Math.min(...closeRects.map((r) => r.y)) - margin); + const maxX = Math.min(1440, Math.max(...closeRects.map((r) => r.x + r.w)) + margin); + const maxY = Math.min(900, Math.max(...closeRects.map((r) => r.y + r.h)) + margin); + await page.screenshot({ path: path.join(OUT, 'close-pair.png'), clip: { x: minX, y: minY, width: Math.max(50, maxX - minX), height: Math.max(50, maxY - minY) } }).catch(() => {}); + } + + const report = { + generatedAt: new Date().toISOString(), + viewport: '1440x900', + edges: geom.edges, + summary: { + edgeCount: geom.edges.length, + centerAttached: geom.edges.filter((e) => e.endpointAtSourceCenterPx <= 3 && e.endpointAtTargetCenterPx <= 3).length, + labelsOverflowing: geom.edges.filter((e) => e.labelOverflowPx > 0).length, + labelsOverlappingCards: geom.edges.filter((e) => e.labelOverlapsCard).length, + }, + }; + fs.writeFileSync(path.join(OUT, 'report.json'), JSON.stringify(report, null, 2)); + + // eslint-disable-next-line no-console + console.log('[geometry] ' + JSON.stringify(report.summary)); + for (const e of geom.edges) { + // eslint-disable-next-line no-console + console.log(`[geometry] ${e.type}: centerLen=${e.centerLen} clearSpan=${e.clearSpanPx} labelW=${e.labelW} overflow=${e.labelOverflowPx} overlapsCard=${e.labelOverlapsCard} endpoint@srcCenter=${e.endpointAtSourceCenterPx}px endpoint@tgtCenter=${e.endpointAtTargetCenterPx}px`); + } + + // Diagnostic, not a gate: just assert we measured something real. + expect(geom.edges.length, 'measured at least one edge').toBeGreaterThan(0); + + await gql(page, `mutation($id:ID!){deleteEdges(where:{source:{graph:{id:$id}}}){nodesDeleted}}`, { id: graphId }); + await gql(page, `mutation($id:ID!){deleteWorkItems(where:{graph:{id:$id}}){nodesDeleted}}`, { id: graphId }); + await gql(page, `mutation($id:ID!){deleteGraphs(where:{id:$id}){nodesDeleted}}`, { id: graphId }); + }); +});