Adds two suites asserting the in-house engine == @floating-ui (flag stays OFF; no removal, no migration). Together they exercise the whole pipeline and caught 9 real bugs in the P1 DOM-read layer; all fixed and re-verified. Suites: - engine.test.ts — 232 synthetic math cases: runMiddleware vs @floating-ui/core over identical synthetic rects. Full 12-placement × 7-edge matrix (full chain), flip-at-edges, shift (main/cross × limiter), arrow (+ alignmentOffset), size, hide, extremes (oversized/zero-size/fractional/negative-padding/huge-offset), and scroll/scale/rtl variants. Pixel-identical. - engine-dom.svelte.test.ts — 60 real-DOM cases (chromium/client project): BOTH systems' computePosition on the SAME real elements — the only suite that exercises rects.ts + clipping.ts. Nested scroll, transformed + CSS-scaled offsetParent, fixed strategy, scrolled page, individual-transform containing block, static-table-cell offsetParent, position:fixed escaping a scroll container, arrow + size. Bugs fixed (independently confirmed by the floating-engine-parity-audit workflow, 25 agents): - rects.ts getRectRelativeToOffsetParent: inverted scroll/offset signs (+ missing htmlOffset) — broke every scrolled page / positioned offsetParent. - rects.ts convert: early-returned for a window offsetParent (=== win instead of === documentElement) leaving viewportDelta = 0, plus inverted signs, plus the offsetParent rect must be RAW (includeScale:false) and scale must be applied. - overflow.ts: scale the element rect by offsetScale (rect·scale + viewportDelta) so CSS-scaled offsetParents detect overflow correctly; synthetic platform.convert updated to model rect·scale too. - rects.ts isContainingBlock: add translate/scale/rotate, gate filter/backdrop behind !isWebKit(), drop container-type (matches shipped floating-ui), widen the willChange regex. New isWebKit()/isTableElement() helpers. - rects.ts getOffsetParent: skip static td/th, not just table. - clipping.ts: getClippingElementAncestors (+ hasFixedPositionAncestor) — drop the body and any overflow ancestor the positioned element escapes via a fixed/absolute containing block; getViewportRect gates the visual-viewport offset on isWebKit(); getOverflowAncestors includes win.visualViewport for auto-update zoom tracking. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>menubar-v4-safe
parent
ef4447e78e
commit
a2500e9e31
@ -0,0 +1,443 @@
|
||||
// Real-DOM parity: runs BOTH positioning systems — our own `computePosition`
|
||||
// (engine/) and `@floating-ui/dom`'s — against the SAME real elements in a real
|
||||
// browser (chromium, via the `client` vitest project) and asserts identical
|
||||
// coords / placement. Unlike engine.test.ts (synthetic rects), this exercises the
|
||||
// DOM-read layer — rects.ts (offsetParent + scale-aware rects + viewport
|
||||
// conversion) and clipping.ts (the overflow-ancestor walk) — in the limit
|
||||
// scenarios that layer exists for: nested scroll containers, transformed
|
||||
// ancestors, the fixed strategy, a scrolled page, and positioned offsetParents.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { createActiveDom } from '$adom'
|
||||
|
||||
import { computePosition as ownComputePosition } from './compute'
|
||||
import {
|
||||
arrow as ownArrow,
|
||||
flip as ownFlip,
|
||||
offset as ownOffset,
|
||||
shift as ownShift,
|
||||
size as ownSize
|
||||
} from './middleware'
|
||||
import {
|
||||
arrow as fuiArrow,
|
||||
computePosition as fuiComputePosition,
|
||||
flip as fuiFlip,
|
||||
offset as fuiOffset,
|
||||
shift as fuiShift,
|
||||
size as fuiSize,
|
||||
type Middleware as FuiMiddleware
|
||||
} from '@floating-ui/dom'
|
||||
import type { Middleware } from './types'
|
||||
import type { Placement, Strategy } from '../placement'
|
||||
|
||||
const dom = createActiveDom()
|
||||
const trash: HTMLElement[] = []
|
||||
|
||||
function box(styles: Partial<CSSStyleDeclaration>, parent: HTMLElement = document.body): HTMLDivElement {
|
||||
const d = document.createElement('div')
|
||||
Object.assign(d.style, styles)
|
||||
parent.appendChild(d)
|
||||
if (parent === document.body) trash.push(d)
|
||||
return d
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.scrollTo(0, 0)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const el of trash) el.remove()
|
||||
trash.length = 0
|
||||
window.scrollTo(0, 0)
|
||||
})
|
||||
|
||||
// Run both engines on the same elements + equivalent middleware specs.
|
||||
type Built = { own: Middleware[]; fui: FuiMiddleware[] }
|
||||
|
||||
async function bothPositions(
|
||||
anchor: HTMLElement,
|
||||
floating: HTMLElement,
|
||||
mw: Built,
|
||||
placement: Placement,
|
||||
strategy: Strategy = 'absolute'
|
||||
) {
|
||||
const own = await ownComputePosition(anchor, floating, {
|
||||
placement,
|
||||
strategy,
|
||||
middleware: mw.own,
|
||||
dom
|
||||
})
|
||||
const fui = await fuiComputePosition(anchor, floating, {
|
||||
placement,
|
||||
strategy,
|
||||
middleware: mw.fui
|
||||
})
|
||||
return { own, fui }
|
||||
}
|
||||
|
||||
// Coords match to within a sub-pixel tolerance (both read the same DOM via
|
||||
// getBoundingClientRect; any divergence > this is a real rects/clipping bug).
|
||||
const TOL = 0.5
|
||||
|
||||
function assertSame(label: string, own: { x: number; y: number; placement: string }, fui: { x: number; y: number; placement: string }) {
|
||||
expect(own.placement, `${label} · placement`).toBe(fui.placement)
|
||||
expect(Math.abs(own.x - fui.x), `${label} · x (own=${own.x} fui=${fui.x})`).toBeLessThanOrEqual(TOL)
|
||||
expect(Math.abs(own.y - fui.y), `${label} · y (own=${own.y} fui=${fui.y})`).toBeLessThanOrEqual(TOL)
|
||||
}
|
||||
|
||||
const PLACEMENTS: Placement[] = [
|
||||
'top',
|
||||
'top-start',
|
||||
'top-end',
|
||||
'right',
|
||||
'right-start',
|
||||
'right-end',
|
||||
'bottom',
|
||||
'bottom-start',
|
||||
'bottom-end',
|
||||
'left',
|
||||
'left-start',
|
||||
'left-end'
|
||||
]
|
||||
|
||||
describe('real-DOM parity — plain anchor in body, all placements', () => {
|
||||
for (const placement of PLACEMENTS) {
|
||||
it(placement, async () => {
|
||||
const anchor = box({
|
||||
position: 'absolute',
|
||||
left: '420px',
|
||||
top: '300px',
|
||||
width: '90px',
|
||||
height: '34px'
|
||||
})
|
||||
const floating = box({ position: 'absolute', width: '200px', height: '120px' })
|
||||
const mw: Built = {
|
||||
own: [ownOffset({ mainAxis: 8 }), ownFlip(), ownShift({ padding: 6 })],
|
||||
fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 6 })]
|
||||
}
|
||||
const { own, fui } = await bothPositions(anchor, floating, mw, placement)
|
||||
assertSame(`plain ${placement}`, own, fui)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('real-DOM parity — flip/shift forced at viewport edges', () => {
|
||||
const SPOTS = [
|
||||
{ name: 'top-left', left: 2, top: 2 },
|
||||
{ name: 'top-right', left: -1, top: 2, fromRight: true },
|
||||
{ name: 'bottom-left', left: 2, top: -1, fromBottom: true },
|
||||
{ name: 'bottom-right', fromRight: true, fromBottom: true }
|
||||
]
|
||||
for (const placement of ['bottom', 'top', 'right', 'left'] as Placement[]) {
|
||||
for (const spot of SPOTS) {
|
||||
it(`${placement} @ ${spot.name}`, async () => {
|
||||
const styles: Partial<CSSStyleDeclaration> = {
|
||||
position: 'fixed',
|
||||
width: '90px',
|
||||
height: '34px'
|
||||
}
|
||||
if (spot.fromRight) styles.right = '2px'
|
||||
else styles.left = `${spot.left}px`
|
||||
if (spot.fromBottom) styles.bottom = '2px'
|
||||
else styles.top = `${spot.top}px`
|
||||
const anchor = box(styles)
|
||||
const floating = box({ position: 'absolute', width: '220px', height: '150px' })
|
||||
const mw: Built = {
|
||||
own: [ownOffset({ mainAxis: 8 }), ownFlip(), ownShift({ padding: 4 })],
|
||||
fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 4 })]
|
||||
}
|
||||
const { own, fui } = await bothPositions(anchor, floating, mw, placement)
|
||||
assertSame(`edge ${placement}/${spot.name}`, own, fui)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('real-DOM parity — anchor inside a nested scroll container (clipping walk)', () => {
|
||||
for (const placement of ['bottom', 'top', 'right', 'left'] as Placement[]) {
|
||||
for (const scroll of [0, 60, 140]) {
|
||||
it(`${placement} scrollTop=${scroll}`, async () => {
|
||||
const scroller = box({
|
||||
position: 'absolute',
|
||||
left: '120px',
|
||||
top: '120px',
|
||||
width: '320px',
|
||||
height: '220px',
|
||||
overflow: 'auto',
|
||||
border: '1px solid'
|
||||
})
|
||||
const tall = box({ height: '900px', width: '600px', position: 'relative' }, scroller)
|
||||
const anchor = box(
|
||||
{ position: 'absolute', left: '60px', top: '300px', width: '90px', height: '34px' },
|
||||
tall
|
||||
)
|
||||
const floating = box({ position: 'absolute', width: '200px', height: '120px' })
|
||||
scroller.scrollTop = scroll
|
||||
const mw: Built = {
|
||||
own: [ownOffset({ mainAxis: 8 }), ownFlip(), ownShift({ padding: 4 })],
|
||||
fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 4 })]
|
||||
}
|
||||
const { own, fui } = await bothPositions(anchor, floating, mw, placement)
|
||||
assertSame(`scroll ${placement}/${scroll}`, own, fui)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('real-DOM parity — transformed ancestor (containing block + offsetParent)', () => {
|
||||
for (const placement of ['bottom', 'top-start', 'right-end'] as Placement[]) {
|
||||
it(placement, async () => {
|
||||
// A transform makes the wrapper a containing block AND the offsetParent
|
||||
// for the absolutely-positioned floating element nested in it.
|
||||
const wrapper = box({
|
||||
position: 'relative',
|
||||
transform: 'translateZ(0) scale(1)',
|
||||
left: '200px',
|
||||
top: '180px',
|
||||
width: '400px',
|
||||
height: '300px',
|
||||
border: '1px solid'
|
||||
})
|
||||
const anchor = box(
|
||||
{ position: 'absolute', left: '120px', top: '120px', width: '90px', height: '34px' },
|
||||
wrapper
|
||||
)
|
||||
const floating = box({ position: 'absolute', width: '200px', height: '120px' }, wrapper)
|
||||
const mw: Built = {
|
||||
own: [ownOffset({ mainAxis: 8 }), ownFlip(), ownShift({ padding: 6 })],
|
||||
fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 6 })]
|
||||
}
|
||||
const { own, fui } = await bothPositions(anchor, floating, mw, placement)
|
||||
assertSame(`transform ${placement}`, own, fui)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('real-DOM parity — CSS-scaled offsetParent', () => {
|
||||
for (const placement of ['bottom', 'top-start', 'right'] as Placement[]) {
|
||||
it(`scale(1.25) ${placement}`, async () => {
|
||||
const wrapper = box({
|
||||
position: 'relative',
|
||||
transform: 'scale(1.25)',
|
||||
transformOrigin: 'top left',
|
||||
left: '160px',
|
||||
top: '140px',
|
||||
width: '420px',
|
||||
height: '320px',
|
||||
border: '1px solid'
|
||||
})
|
||||
const anchor = box(
|
||||
{ position: 'absolute', left: '110px', top: '120px', width: '90px', height: '34px' },
|
||||
wrapper
|
||||
)
|
||||
const floating = box({ position: 'absolute', width: '200px', height: '120px' }, wrapper)
|
||||
const mw: Built = {
|
||||
own: [ownOffset({ mainAxis: 8 }), ownFlip(), ownShift({ padding: 6 })],
|
||||
fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 6 })]
|
||||
}
|
||||
const { own, fui } = await bothPositions(anchor, floating, mw, placement)
|
||||
assertSame(`scaled ${placement}`, own, fui)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('real-DOM parity — fixed strategy', () => {
|
||||
for (const placement of ['bottom', 'top', 'right-start'] as Placement[]) {
|
||||
it(placement, async () => {
|
||||
const anchor = box({
|
||||
position: 'fixed',
|
||||
left: '500px',
|
||||
top: '360px',
|
||||
width: '90px',
|
||||
height: '34px'
|
||||
})
|
||||
const floating = box({ position: 'fixed', width: '200px', height: '120px' })
|
||||
const mw: Built = {
|
||||
own: [ownOffset({ mainAxis: 8 }), ownFlip(), ownShift({ padding: 6 })],
|
||||
fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 6 })]
|
||||
}
|
||||
const { own, fui } = await bothPositions(anchor, floating, mw, placement, 'fixed')
|
||||
assertSame(`fixed ${placement}`, own, fui)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('real-DOM parity — scrolled page', () => {
|
||||
for (const placement of ['bottom', 'top'] as Placement[]) {
|
||||
it(placement, async () => {
|
||||
const spacer = box({ height: '2000px', width: '1px', position: 'absolute', top: '0', left: '0' })
|
||||
const anchor = box({
|
||||
position: 'absolute',
|
||||
left: '420px',
|
||||
top: '1200px',
|
||||
width: '90px',
|
||||
height: '34px'
|
||||
})
|
||||
const floating = box({ position: 'absolute', width: '200px', height: '120px' })
|
||||
void spacer
|
||||
window.scrollTo(0, 1000)
|
||||
const mw: Built = {
|
||||
own: [ownOffset({ mainAxis: 8 }), ownFlip(), ownShift({ padding: 6 })],
|
||||
fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 6 })]
|
||||
}
|
||||
const { own, fui } = await bothPositions(anchor, floating, mw, placement)
|
||||
assertSame(`scrolled ${placement}`, own, fui)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ── Audit-confirmed edge cases (offsetParent + clipping ancestor walk) ────────
|
||||
|
||||
describe('real-DOM parity — individual-transform containing block (fixed)', () => {
|
||||
for (const prop of [
|
||||
{ key: 'translate', value: '40px 20px' },
|
||||
{ key: 'scale', value: '1.2' },
|
||||
{ key: 'rotate', value: '6deg' }
|
||||
]) {
|
||||
it(`${prop.key}: ${prop.value}`, async () => {
|
||||
// An individual transform (NOT `transform:`) makes the wrapper the
|
||||
// containing block for the fixed floating element — so it becomes the
|
||||
// offset basis. The engine must recognise translate/scale/rotate.
|
||||
const wrapper = box({
|
||||
position: 'relative',
|
||||
left: '220px',
|
||||
top: '180px',
|
||||
width: '420px',
|
||||
height: '320px',
|
||||
border: '1px solid'
|
||||
})
|
||||
wrapper.style.setProperty(prop.key, prop.value)
|
||||
const anchor = box(
|
||||
{ position: 'absolute', left: '120px', top: '120px', width: '90px', height: '34px' },
|
||||
wrapper
|
||||
)
|
||||
const floating = box({ position: 'fixed', width: '200px', height: '120px' }, wrapper)
|
||||
const mw: Built = {
|
||||
own: [ownOffset({ mainAxis: 8 }), ownFlip(), ownShift({ padding: 6 })],
|
||||
fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 6 })]
|
||||
}
|
||||
const { own, fui } = await bothPositions(anchor, floating, mw, 'bottom', 'fixed')
|
||||
assertSame(`indiv ${prop.key}`, own, fui)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('real-DOM parity — anchor inside a static table cell (offsetParent skip)', () => {
|
||||
it('td offsetParent skipped to positioned ancestor', async () => {
|
||||
const outer = box({
|
||||
position: 'relative',
|
||||
left: '160px',
|
||||
top: '160px',
|
||||
width: '500px',
|
||||
height: '300px',
|
||||
border: '1px solid'
|
||||
})
|
||||
const table = document.createElement('table')
|
||||
const tbody = document.createElement('tbody')
|
||||
const tr = document.createElement('tr')
|
||||
const td = document.createElement('td')
|
||||
td.style.padding = '40px'
|
||||
table.appendChild(tbody)
|
||||
tbody.appendChild(tr)
|
||||
tr.appendChild(td)
|
||||
outer.appendChild(table)
|
||||
const anchor = box(
|
||||
{ position: 'absolute', left: '30px', top: '30px', width: '90px', height: '34px' },
|
||||
td
|
||||
)
|
||||
const floating = box({ position: 'absolute', width: '200px', height: '120px' }, td)
|
||||
const mw: Built = {
|
||||
own: [ownOffset({ mainAxis: 8 }), ownFlip(), ownShift({ padding: 6 })],
|
||||
fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 6 })]
|
||||
}
|
||||
const { own, fui } = await bothPositions(anchor, floating, mw, 'bottom')
|
||||
assertSame('table-cell', own, fui)
|
||||
})
|
||||
})
|
||||
|
||||
describe('real-DOM parity — fixed floating escaping a scroll container (clip drop)', () => {
|
||||
for (const placement of ['bottom', 'top', 'right'] as Placement[]) {
|
||||
it(`${placement} not clipped to the scroller`, async () => {
|
||||
const scroller = box({
|
||||
position: 'relative',
|
||||
left: '120px',
|
||||
top: '120px',
|
||||
width: '300px',
|
||||
height: '200px',
|
||||
overflow: 'auto',
|
||||
border: '1px solid'
|
||||
})
|
||||
const tall = box({ height: '700px', width: '500px', position: 'relative' }, scroller)
|
||||
const anchor = box(
|
||||
{ position: 'absolute', left: '40px', top: '90px', width: '90px', height: '34px' },
|
||||
tall
|
||||
)
|
||||
// Fixed → escapes the scroll container's clip; the engine must drop the
|
||||
// scroller from the clipping-ancestor walk (else it flips/shifts early).
|
||||
const floating = box({ position: 'fixed', width: '200px', height: '140px' }, scroller)
|
||||
const mw: Built = {
|
||||
own: [ownOffset({ mainAxis: 8 }), ownFlip(), ownShift({ padding: 6 })],
|
||||
fui: [fuiOffset({ mainAxis: 8 }), fuiFlip(), fuiShift({ padding: 6 })]
|
||||
}
|
||||
const { own, fui } = await bothPositions(anchor, floating, mw, placement, 'fixed')
|
||||
assertSame(`fixed-escape ${placement}`, own, fui)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('real-DOM parity — arrow + size in real layout', () => {
|
||||
it('arrow centering (bottom) matches', async () => {
|
||||
const anchor = box({
|
||||
position: 'absolute',
|
||||
left: '420px',
|
||||
top: '300px',
|
||||
width: '120px',
|
||||
height: '36px'
|
||||
})
|
||||
const floating = box({ position: 'absolute', width: '220px', height: '140px' })
|
||||
const arrowEl = box({ position: 'absolute', width: '12px', height: '12px' }, floating)
|
||||
const { own, fui } = await bothPositions(
|
||||
anchor,
|
||||
floating,
|
||||
{
|
||||
own: [ownOffset({ mainAxis: 10 }), ownArrow({ element: arrowEl, padding: 6 })],
|
||||
fui: [fuiOffset({ mainAxis: 10 }), fuiArrow({ element: arrowEl, padding: 6 })]
|
||||
},
|
||||
'bottom'
|
||||
)
|
||||
assertSame('arrow bottom', own, fui)
|
||||
const oa = own.middlewareData.arrow as { x?: number }
|
||||
const fa = fui.middlewareData.arrow as { x?: number }
|
||||
expect(Math.abs((oa.x ?? 0) - (fa.x ?? 0)), 'arrow.x').toBeLessThanOrEqual(TOL)
|
||||
})
|
||||
|
||||
it('size available space (bottom, tall floating) matches', async () => {
|
||||
const anchor = box({
|
||||
position: 'absolute',
|
||||
left: '420px',
|
||||
top: '560px',
|
||||
width: '90px',
|
||||
height: '34px'
|
||||
})
|
||||
const floating = box({ position: 'absolute', width: '200px', height: '400px' })
|
||||
let ownAH = -1
|
||||
let fuiAH = -1
|
||||
await bothPositions(
|
||||
anchor,
|
||||
floating,
|
||||
{
|
||||
own: [
|
||||
ownOffset({ mainAxis: 8 }),
|
||||
ownSize({ padding: 0, apply: ({ availableHeight }) => (ownAH = availableHeight) })
|
||||
],
|
||||
fui: [
|
||||
fuiOffset({ mainAxis: 8 }),
|
||||
fuiSize({ padding: 0, apply: ({ availableHeight }) => (fuiAH = availableHeight) })
|
||||
]
|
||||
},
|
||||
'bottom'
|
||||
)
|
||||
expect(Math.abs(ownAH - fuiAH), `availableHeight (own=${ownAH} fui=${fuiAH})`).toBeLessThanOrEqual(TOL)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in new issue