@@ -406,3 +406,162 @@ for (const target of TARGETS) {
406406 . toBe ( 3 ) ;
407407 } ) ;
408408}
409+
410+ /**
411+ * Connector / socket vertical-alignment proof (quick-260610-jrk continuation #2).
412+ *
413+ * THE BUG: connection lines anchored ~14px BELOW each socket, at the node BOTTOM,
414+ * instead of on the socket. ROOT CAUSE (DOM-evidence-confirmed): the connection
415+ * `<svg>` was `display:inline` (the SVG default), so the 1px-tall SVG sat on the
416+ * connection element's TEXT BASELINE. With the engine container's default
417+ * line-height that baseline is ~14px below the connection element's top — and the
418+ * connection element IS the area-transform origin, so the offset is in screen space
419+ * and pushes EVERY endpoint ~14px down. The socket positions reported by
420+ * `getDOMSocketPosition` (offsetTop within the node-view) were already correct; the
421+ * inline-SVG baseline was the sole vertical drift. FIX: `display:block` on
422+ * `.rozie-flow-connection__svg` removes the baseline gap (CSS-only, in FlowCanvas's
423+ * scoped `:root {}` engine-DOM block — no script/emitter change).
424+ *
425+ * THE PROOF (load-bearing — must FAIL pre-fix, PASS post-fix): every drawn
426+ * connection path's START and END screen point must sit within tolerance of SOME
427+ * socket center VERTICALLY. Pre-fix worst dy ≈ 13.9px (node bottom); post-fix it
428+ * collapses to «1px (on the socket). The HORIZONTAL offset is NOT asserted tightly:
429+ * `getDOMSocketPosition.calculatePosition` intentionally returns the socket center
430+ * shifted 12px OUTWARD (`position.x + 12 * (side==='input' ? -1 : 1)`), so a correct
431+ * endpoint is ~12px horizontally from the socket center BY DESIGN — only a loose
432+ * sanity bound (≤ 20px) is checked horizontally. Tolerance rationale for the
433+ * vertical proof: cross-target sub-pixel kerning / AA / curvature-handle rounding is
434+ * « 6px, while the bug's node-bottom offset (~14px) is well outside it. Holds on all
435+ * 6 targets — they share the one vanilla render pipe + the same scoped connection CSS.
436+ */
437+ const ALIGN_DY_TOLERANCE_PX = 6 ;
438+ const ALIGN_DX_SANITY_PX = 20 ; // 12px intentional outward offset + AA/rounding slack
439+
440+ for ( const target of TARGETS ) {
441+ const built = existsSync (
442+ resolve ( __dirname , `../dist/${ target } /host/entry.${ target } .html` ) ,
443+ ) ;
444+ const runner = ! built || KNOWN_FAILING . has ( target ) ? test . fixme : test ;
445+ runner ( `rete-flow-align [${ target } ]: connectors sit on the node sockets` , async ( {
446+ page,
447+ } ) => {
448+ await page . goto ( `/?example=FlowCanvas&target=${ target } ` ) ;
449+ const mount = page . getByTestId ( 'rozie-mount' ) ;
450+ await expect ( mount ) . toBeVisible ( ) ;
451+
452+ const canvas = page . locator ( '.rozie-flow-canvas' ) . first ( ) ;
453+ await expect ( canvas ) . toBeVisible ( { timeout : 15_000 } ) ;
454+ await expect
455+ . poll ( async ( ) => page . locator ( '.rozie-flow-node' ) . count ( ) , {
456+ timeout : 15_000 ,
457+ } )
458+ . toBeGreaterThanOrEqual ( 3 ) ;
459+ // both config-array edges (a→b, b→c) drawn before we measure.
460+ await expect
461+ . poll ( async ( ) => page . locator ( '.rozie-flow-connection__path' ) . count ( ) , {
462+ timeout : 10_000 ,
463+ } )
464+ . toBeGreaterThanOrEqual ( 2 ) ;
465+
466+ // Give the watcher-driven redraw a moment to settle after mount/fit.
467+ await page . waitForTimeout ( 1200 ) ;
468+
469+ // For every DRAWN path, compute its START + END screen points (via the path's
470+ // own getPointAtLength + getScreenCTM, so transforms/zoom are accounted for),
471+ // collect every socket's screen-center, and report the worst-case offset of any
472+ // endpoint from its NEAREST socket center. The bug-specific signal is VERTICAL
473+ // (worstDy): pre-fix ~14px (node bottom), post-fix «1px (on the socket). The
474+ // horizontal offset is loose (the lib intentionally shifts the stored position
475+ // 12px outward), so worstDx is only sanity-bounded.
476+ const result = await page . evaluate ( ( ) => {
477+ // Deep query across the document AND every open shadow root (Lit renders the
478+ // canvas + sockets + connections inside a shadow root; plain querySelectorAll
479+ // does NOT pierce shadow DOM, so we recurse). Returns all matches everywhere.
480+ const deepQueryAll = ( selector : string ) : Element [ ] => {
481+ const out : Element [ ] = [ ] ;
482+ const walk = ( root : Document | ShadowRoot ) => {
483+ out . push ( ...Array . from ( root . querySelectorAll ( selector ) ) ) ;
484+ for ( const el of Array . from ( root . querySelectorAll ( '*' ) ) ) {
485+ const sr = ( el as HTMLElement ) . shadowRoot ;
486+ if ( sr ) walk ( sr ) ;
487+ }
488+ } ;
489+ walk ( document ) ;
490+ return out ;
491+ } ;
492+
493+ const sockets = deepQueryAll ( '.rozie-flow-socket' ) . map ( ( s ) => {
494+ const r = ( s as HTMLElement ) . getBoundingClientRect ( ) ;
495+ return { x : r . left + r . width / 2 , y : r . top + r . height / 2 } ;
496+ } ) ;
497+
498+ const paths = deepQueryAll ( '.rozie-flow-connection__path' ) . filter (
499+ ( p ) => ( ( p as SVGPathElement ) . getAttribute ( 'd' ) || '' ) . trim ( ) . length > 0 ,
500+ ) as SVGPathElement [ ] ;
501+
502+ const screenPoint = ( p : SVGPathElement , len : number ) => {
503+ const pt = p . getPointAtLength ( len ) ;
504+ const m = p . getScreenCTM ( ) ;
505+ if ( ! m ) return null ;
506+ return {
507+ x : pt . x * m . a + pt . y * m . c + m . e ,
508+ y : pt . x * m . b + pt . y * m . d + m . f ,
509+ } ;
510+ } ;
511+
512+ let worstDx = 0 ;
513+ let worstDy = 0 ;
514+ const endpoints : Array < { dx : number ; dy : number } > = [ ] ;
515+ for ( const p of paths ) {
516+ const total = p . getTotalLength ( ) ;
517+ const ends = [ screenPoint ( p , 0 ) , screenPoint ( p , total ) ] ;
518+ for ( const e of ends ) {
519+ if ( ! e ) continue ;
520+ // nearest socket center to this endpoint
521+ let best = Infinity ;
522+ let bestDx = Infinity ;
523+ let bestDy = Infinity ;
524+ for ( const s of sockets ) {
525+ const dx = Math . abs ( e . x - s . x ) ;
526+ const dy = Math . abs ( e . y - s . y ) ;
527+ const d = Math . hypot ( dx , dy ) ;
528+ if ( d < best ) {
529+ best = d ;
530+ bestDx = dx ;
531+ bestDy = dy ;
532+ }
533+ }
534+ endpoints . push ( { dx : bestDx , dy : bestDy } ) ;
535+ if ( bestDx > worstDx ) worstDx = bestDx ;
536+ if ( bestDy > worstDy ) worstDy = bestDy ;
537+ }
538+ }
539+ return {
540+ socketCount : sockets . length ,
541+ pathCount : paths . length ,
542+ endpointCount : endpoints . length ,
543+ worstDx,
544+ worstDy,
545+ endpoints,
546+ } ;
547+ } ) ;
548+
549+ // Sanity: we actually measured drawn edges + sockets.
550+ expect ( result . socketCount ) . toBeGreaterThanOrEqual ( 3 ) ;
551+ expect ( result . pathCount ) . toBeGreaterThanOrEqual ( 2 ) ;
552+ expect ( result . endpointCount ) . toBeGreaterThanOrEqual ( 4 ) ;
553+
554+ // THE PROOF (vertical): every endpoint sits on a socket center within tolerance
555+ // VERTICALLY — pre-fix worstDy ~14px (node bottom), post-fix «1px (on the socket).
556+ expect (
557+ result . worstDy ,
558+ `worst vertical endpoint→socket offset ${ result . worstDy . toFixed ( 2 ) } px (tol ${ ALIGN_DY_TOLERANCE_PX } px) — pre-fix ~14px (node bottom); per-endpoint=${ JSON . stringify ( result . endpoints ) } ` ,
559+ ) . toBeLessThanOrEqual ( ALIGN_DY_TOLERANCE_PX ) ;
560+ // SANITY (horizontal): each endpoint terminates near a socket (the lib shifts the
561+ // stored position 12px outward by design, so this is a loose bound, not the proof).
562+ expect (
563+ result . worstDx ,
564+ `worst horizontal endpoint→socket offset ${ result . worstDx . toFixed ( 2 ) } px (sanity ${ ALIGN_DX_SANITY_PX } px; ~12px is the lib's intentional outward offset); per-endpoint=${ JSON . stringify ( result . endpoints ) } ` ,
565+ ) . toBeLessThanOrEqual ( ALIGN_DX_SANITY_PX ) ;
566+ } ) ;
567+ }
0 commit comments