Apply the AGENTS.md comments guideline to every .ts under tests/: remove what the code already expresses, keep only what it cannot. Removed: spec-header coverage inventories the describe/test tree already names; comments restating the next line's assertion or its message; helper JSDoc whose body already says it; within-file duplicates; five stale claims (trendChart's "six aggregated turns" against agg.length === 8, agentGraph's "six categories" against CATS' seven, fold-timing's "cheapest call LAST" against a loop that feeds it first, fold-surface's unreachable plain-JSON note, contextView's "two turns" for a one-turn fixture). Kept and condensed: the projection-freeze class, strict wire/state-schema hazards, retention and pricing windows, pairing rules, hostile-input rationale, fixture and expectation-array ground truth, jsdom mechanics, and issue-numbered regressions. Comment lines 2604 -> 2014 (-22%); 85 module headers kept, 30 coverage inventories dropped; every surviving comment line is within the 140-column limit, with 36 wrapped lines joined onto fuller ones. No logic, string, identifier, import or directive changed: a comment-strip gate proves each file's comment-free code is byte-identical to HEAD, and a line-level proof pairs all 845 changed lines with an unchanged code part. pnpm run lint && pnpm run test (106 files / 2061 tests, per-file 100% coverage) && pnpm run build all pass.
178 lines
7.4 KiB
TypeScript
178 lines
7.4 KiB
TypeScript
import { act, createElement as h, type ReactElement } from 'react'
|
||
import assert from 'node:assert/strict'
|
||
import { afterEach, describe, test } from 'vitest'
|
||
import { useHoverTip } from '../../../src/client/components/hoverTip'
|
||
import { flush, mount, query, type Mounted } from '../helpers/kit'
|
||
|
||
function Zone(): ReactElement {
|
||
const { zoneProps, bubble } = useHoverTip()
|
||
return h(
|
||
'div',
|
||
zoneProps,
|
||
h('button', { id: 'a', 'data-lc-tip': 'tip A' }, h('span', { id: 'a-child' }, 'A')),
|
||
h('button', { id: 'b', 'data-lc-tip': 'tip B', 'data-lc-tip-side': 'bottom' }, 'B'),
|
||
h('span', { id: 'plain' }, 'plain'),
|
||
h('span', { id: 'empty', 'data-lc-tip': '' }, 'empty'),
|
||
bubble,
|
||
)
|
||
}
|
||
|
||
const bubble = (): HTMLElement | null => document.body.querySelector('#lc-hovertip-bubble')
|
||
|
||
async function mouseover(target: Element): Promise<void> {
|
||
await act(async () => { target.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })) })
|
||
}
|
||
|
||
async function mouseout(target: Element, relatedTarget: EventTarget | null): Promise<void> {
|
||
await act(async () => { target.dispatchEvent(new MouseEvent('mouseout', { bubbles: true, relatedTarget })) })
|
||
}
|
||
|
||
// React implements onFocus/onBlur (and their captures) over the bubbling focusin/focusout pair, so the specs drive those natively.
|
||
async function focusIn(target: Element): Promise<void> {
|
||
await act(async () => { target.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) })
|
||
}
|
||
|
||
async function blurOut(target: Element, relatedTarget: EventTarget | null): Promise<void> {
|
||
await act(async () => { target.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget })) })
|
||
}
|
||
|
||
/** Pin an anchor's rect (jsdom's are all zero) so the measure pass exercises real geometry. */
|
||
function pinRect(anchor: Element, rect: { left: number; top: number; width: number; height: number }): void {
|
||
const { left, top, width, height } = rect
|
||
Object.defineProperty(anchor, 'getBoundingClientRect', {
|
||
configurable: true,
|
||
value: () => ({ left, top, width, height, bottom: top + height, right: left + width }),
|
||
})
|
||
}
|
||
|
||
afterEach(() => {
|
||
// A leaked portal from a failed test never reaches the next one.
|
||
bubble()?.remove()
|
||
})
|
||
|
||
describe('useHoverTip', () => {
|
||
test('hovering an anchor shows its portaled tip and described-by; leaving hides both', async () => {
|
||
const m = await mount(h(Zone))
|
||
const a = query(m.container, '#a')
|
||
await mouseover(a)
|
||
const el = bubble()
|
||
assert.ok(el !== null, 'the bubble portaled to <body>')
|
||
assert.equal(el.getAttribute('role'), 'tooltip')
|
||
assert.equal(el.textContent, 'tip A')
|
||
assert.ok(el.className.includes('lc-tip') && el.className.includes('lc-hovertip'))
|
||
assert.equal(el.style.opacity, '1', 'the measured pass revealed the bubble')
|
||
// Zero-rect jsdom geometry: side 'top' overflows (top < margin), so the
|
||
// bubble flips below and clamps to the viewport margin.
|
||
assert.equal(el.style.left, '8px')
|
||
assert.equal(el.style.top, '8px')
|
||
assert.equal(a.getAttribute('aria-describedby'), 'lc-hovertip-bubble')
|
||
|
||
await mouseout(a, m.container)
|
||
assert.equal(bubble(), null, 'leaving to a non-anchor hides the bubble')
|
||
assert.equal(a.getAttribute('aria-describedby'), null, 'the described-by goes with it')
|
||
await m.unmount()
|
||
})
|
||
|
||
test('moving across the anchor subtree keeps the tip; switching anchors re-anchors without hiding', async () => {
|
||
const m = await mount(h(Zone))
|
||
const a = query(m.container, '#a')
|
||
const b = query(m.container, '#b')
|
||
await mouseover(a)
|
||
const shown = bubble()?.style.cssText
|
||
|
||
// Moving from the anchor onto its own child, and child events bubbling
|
||
// up: the same anchor keeps tip and measure (no hidden re-flash).
|
||
await mouseover(query(m.container, '#a-child'))
|
||
assert.equal(bubble()?.style.cssText, shown, 'same anchor keeps the measured spot')
|
||
assert.equal(a.getAttribute('aria-describedby'), 'lc-hovertip-bubble')
|
||
|
||
await mouseout(a, b)
|
||
assert.ok(bubble() !== null, 'anchor-to-anchor movement never hides')
|
||
await mouseover(b)
|
||
assert.equal(bubble()?.textContent, 'tip B')
|
||
assert.equal(a.getAttribute('aria-describedby'), null, 'the old anchor released')
|
||
assert.equal(b.getAttribute('aria-describedby'), 'lc-hovertip-bubble')
|
||
await m.unmount()
|
||
})
|
||
|
||
test('focus and blur mirror hover; blur into another anchor keeps the tip', async () => {
|
||
const m = await mount(h(Zone))
|
||
const a = query(m.container, '#a')
|
||
const b = query(m.container, '#b')
|
||
await focusIn(a)
|
||
assert.equal(bubble()?.textContent, 'tip A')
|
||
await blurOut(a, b)
|
||
assert.ok(bubble() !== null, 'focus moving anchor-to-anchor never hides')
|
||
await focusIn(b)
|
||
assert.equal(bubble()?.textContent, 'tip B')
|
||
await blurOut(b, null)
|
||
assert.equal(bubble(), null, 'focus leaving the zone hides')
|
||
await m.unmount()
|
||
})
|
||
|
||
test('a zone scroll hides the tip; non-anchors and empty labels never show one', async () => {
|
||
const m = await mount(h(Zone))
|
||
const a = query(m.container, '#a')
|
||
await mouseover(query(m.container, '#plain'))
|
||
assert.equal(bubble(), null, 'a non-anchor target shows nothing')
|
||
await mouseover(query(m.container, '#empty'))
|
||
assert.equal(bubble(), null, 'an empty data-lc-tip shows nothing')
|
||
|
||
await mouseover(a)
|
||
assert.ok(bubble() !== null)
|
||
await act(async () => {
|
||
query(m.container, '#plain').dispatchEvent(new Event('scroll', { bubbles: false }))
|
||
})
|
||
assert.equal(bubble(), null, 'any zone scroll retracts the bubble')
|
||
assert.equal(a.getAttribute('aria-describedby'), null)
|
||
await m.unmount()
|
||
})
|
||
|
||
test('a viewport resize retracts the tip (every anchor just moved)', async () => {
|
||
const m = await mount(h(Zone))
|
||
const a = query(m.container, '#a')
|
||
await mouseover(a)
|
||
assert.ok(bubble() !== null)
|
||
await act(async () => {
|
||
window.dispatchEvent(new Event('resize'))
|
||
})
|
||
assert.equal(bubble(), null, 'the resize retracted the bubble')
|
||
assert.equal(a.getAttribute('aria-describedby'), null)
|
||
// The listener went with it: a second resize reaches nothing to hide.
|
||
await act(async () => {
|
||
window.dispatchEvent(new Event('resize'))
|
||
})
|
||
assert.equal(bubble(), null)
|
||
await m.unmount()
|
||
})
|
||
|
||
test('the measure pass centers on the anchor, clamps horizontally, and flips a bottom tip that overflows', async () => {
|
||
const m = await mount(h(Zone))
|
||
const b = query(m.container, '#b')
|
||
// Side 'bottom', no overflow: below the anchor, centered (jsdom bubble
|
||
// measures 0×0, so the center lands on the anchor's own center).
|
||
pinRect(b, { left: 100, top: 100, width: 20, height: 20 })
|
||
await mouseover(b)
|
||
assert.equal(bubble()?.style.left, '110px')
|
||
assert.equal(bubble()?.style.top, '126px', 'anchor bottom + gap')
|
||
|
||
// Re-pin beyond the right edge and below the viewport: the left clamp and
|
||
// the bottom flip both engage (jsdom viewport is 1024×768).
|
||
pinRect(b, { left: 2000, top: 740, width: 20, height: 20 })
|
||
await mouseout(b, m.container)
|
||
await mouseover(b)
|
||
assert.equal(bubble()?.style.left, '1016px', 'clamped to innerWidth - margin (width 0)')
|
||
assert.equal(bubble()?.style.top, '734px', 'flipped above the anchor')
|
||
await m.unmount()
|
||
})
|
||
|
||
test('unmounting the zone removes the portaled bubble', async () => {
|
||
const m: Mounted = await mount(h(Zone))
|
||
await mouseover(query(m.container, '#a'))
|
||
assert.ok(bubble() !== null)
|
||
await m.unmount()
|
||
await flush()
|
||
assert.equal(bubble(), null, 'the portal goes down with the zone')
|
||
})
|
||
})
|