Files
dsh-context/tests/client/components/hoverTip.spec.ts
T
bowenliang123 87a2893190 refactor(comments): clean up the test-suite comments to the guideline
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.
2026-10-10 00:50:55 +08:00

178 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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')
})
})