browser.spec.ts (2360), trendChart.spec.ts (1629), contextView.spec.ts (1504), and overview.spec.ts (1065) exceeded the 1000-line limit. Each splits along its existing describe seams into same-directory siblings, with the fixtures that more than one sibling needs extracted once into a shared harness. The move is verbatim: test titles, describe blocks, and every assertion line are preserved exactly (2061 tests before and after, no duplicate titles, no skips), and the suite reports the same coverage.
532 lines
28 KiB
TypeScript
532 lines
28 KiB
TypeScript
import { act, createElement as h } from 'react'
|
|
import assert from 'node:assert/strict'
|
|
import { describe, test } from 'vitest'
|
|
import { aggregateByTurn } from '../../../src/client/components/trendChart'
|
|
import type { RequestRecord } from '../../../src/shared/types'
|
|
import { flush, mount, query, queryAll, wheel } from '../helpers/kit'
|
|
import { BAR_CELL, CHART_H, TrendChart, bars, kit, makeSpies, propsOf, req, scrollEvent, scrollTo, viewport, type LayoutEl } from './trendChartHarness'
|
|
|
|
describe('TrendChart adaptive scale (the title-adjacent toggle)', () => {
|
|
/** 30 steps of one category: bar 0 carries the spike, the rest a flat 100 — 25 columns fit the test viewport. */
|
|
function spikedSteps(): RequestRecord[] {
|
|
const out: RequestRecord[] = []
|
|
for (let i = 0; i < 30; i++) {
|
|
const total = i === 0 ? 900 : 100
|
|
out.push(req(i + 1, { turn: 1, step: i, system: total, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total }))
|
|
}
|
|
return out
|
|
}
|
|
|
|
test('rescales to the visible window, follows the scroll, and yields to the whole-log scale when switched off', async () => {
|
|
const reqs = spikedSteps()
|
|
const m = await mount(h(TrendChart, propsOf(reqs, { adaptive: true })))
|
|
await flush()
|
|
const scroll = query<LayoutEl>(m.container, '.lc-chart-scroll')
|
|
// Mount anchors at the newest bars (scrollLeft 80 over scrollWidth 480 / clientWidth 400): the 900 spike at
|
|
// bar 0 is off screen, so the visible 100s scale the axis and the bars fill the chart.
|
|
assert.equal(scroll.scrollLeft, 80)
|
|
assert.equal(query(m.container, '.lc-axis-top').textContent, '100')
|
|
assert.equal(queryAll(bars(m.container)[29], '.lc-bar-stack > div')[0].style.height, `${CHART_H}px`)
|
|
|
|
await scrollTo(scroll, 0)
|
|
assert.equal(query(m.container, '.lc-axis-top').textContent, '900')
|
|
assert.equal(queryAll(bars(m.container)[29], '.lc-bar-stack > div')[0].style.height, `${Math.round(100 / 900 * CHART_H)}px`)
|
|
|
|
// A scroll inside a window whose maxima do not move keeps the same scale (bars 4..28 are all 100).
|
|
await scrollTo(scroll, 64)
|
|
assert.equal(query(m.container, '.lc-axis-top').textContent, '100')
|
|
|
|
// Switched off: the whole-log scale returns even though a visible window is still measured.
|
|
await m.update(h(TrendChart, propsOf(reqs, { adaptive: false })))
|
|
assert.equal(query(m.container, '.lc-axis-top').textContent, '900')
|
|
await m.unmount()
|
|
})
|
|
|
|
test('a column wholly in the 2px gap outside the viewport does not join the window', async () => {
|
|
const m = await mount(h(TrendChart, propsOf(spikedSteps(), { adaptive: true })))
|
|
await flush()
|
|
const scroll = query<LayoutEl>(m.container, '.lc-chart-scroll')
|
|
// scrollLeft 15 lands in the gap after bar 0's 14px column: the spike is off screen and must not scale the axis.
|
|
await scrollTo(scroll, 15)
|
|
assert.equal(query(m.container, '.lc-axis-top').textContent, '100')
|
|
// One pixel earlier its column is still (partly) on screen and takes the scale back.
|
|
await scrollTo(scroll, 13)
|
|
assert.equal(query(m.container, '.lc-axis-top').textContent, '900')
|
|
await m.unmount()
|
|
})
|
|
|
|
test('an empty history or a zero-width viewport keeps the previous scale', async () => {
|
|
const empty = await mount(h(TrendChart, propsOf([], { adaptive: true })))
|
|
assert.equal(query(empty.container, '.lc-axis-top').textContent, '1')
|
|
await empty.unmount()
|
|
|
|
// A hidden pane (zero width) has nothing visible to scale to: the measured scale stands instead of collapsing
|
|
// every bar onto a unit axis.
|
|
const m = await mount(h(TrendChart, propsOf(spikedSteps(), { adaptive: true })))
|
|
await flush()
|
|
const scroll = query<LayoutEl>(m.container, '.lc-chart-scroll')
|
|
scroll.__clientW = 0
|
|
await scrollEvent(scroll)
|
|
assert.equal(query(m.container, '.lc-axis-top').textContent, '100')
|
|
await m.unmount()
|
|
})
|
|
|
|
test('delta mode rescales the diverging arms; an all-zero window keeps the whole-log zero line', async () => {
|
|
const step = (i: number, system: number): RequestRecord =>
|
|
req(i + 1, { turn: 1, step: i, system, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: system })
|
|
// Deltas: bar 1 jumps +500, bars 2..29 grow +10 each, with a -30 dip carried from bar 20 on.
|
|
const reqs = [step(0, 100), step(1, 600)]
|
|
for (let i = 2; i < 30; i++) reqs.push(step(i, 600 + (i - 1) * 10 - (i >= 20 ? 40 : 0)))
|
|
const m = await mount(h(TrendChart, propsOf(reqs, { mode: 'delta', adaptive: true })))
|
|
await flush()
|
|
// Mount anchors right (scrollLeft 80): the visible +10/-30 deltas scale both arms (maxUp 10, maxDown 30),
|
|
// lifting the zero line to 28px off the floor and stretching the +10 segment to 28px.
|
|
assert.equal(query(m.container, '.lc-axis-top').textContent, '+10')
|
|
assert.equal(query(m.container, '.lc-axis-bot').textContent, '-30')
|
|
assert.equal(query(m.container, '.lc-axis-mid').style.top, `${13 + 28}px`)
|
|
assert.equal(queryAll(bars(m.container)[29], '.lc-bar-up > div')[0].style.height, `${Math.round(10 * CHART_H / 40)}px`)
|
|
assert.equal(queryAll(bars(m.container)[20], '.lc-bar-down > div')[0].style.height, `${Math.round(30 * CHART_H / 40)}px`)
|
|
await m.unmount()
|
|
|
|
// Bars 0..24 carry no change at all and the +500 sits at bar 25: the all-zero window has no scale of its own,
|
|
// so the whole-log zero line stands instead of collapsing onto the chart top.
|
|
const flat = [step(0, 100)]
|
|
for (let i = 1; i < 25; i++) flat.push(step(i, 100))
|
|
flat.push(step(25, 600))
|
|
const m2 = await mount(h(TrendChart, propsOf(flat, { mode: 'delta', adaptive: true })))
|
|
await flush()
|
|
await scrollTo(query<LayoutEl>(m2.container, '.lc-chart-scroll'), 0)
|
|
assert.equal(query(m2.container, '.lc-axis-top').textContent, '+500')
|
|
assert.equal(query(m2.container, '.lc-axis-mid').style.top, `${13 + CHART_H}px`)
|
|
await m2.unmount()
|
|
})
|
|
|
|
test('a container resize re-measures the visible window through the observer', async () => {
|
|
const callbacks: (() => void)[] = []
|
|
class FakeResizeObserver {
|
|
constructor(cb: () => void) { callbacks.push(cb) }
|
|
observe(): void {}
|
|
disconnect(): void {}
|
|
}
|
|
const holder = globalThis as { ResizeObserver?: unknown }
|
|
const saved = holder.ResizeObserver
|
|
holder.ResizeObserver = FakeResizeObserver
|
|
try {
|
|
const m = await mount(h(TrendChart, propsOf(spikedSteps(), { adaptive: true })))
|
|
await flush()
|
|
const scroll = query<LayoutEl>(m.container, '.lc-chart-scroll')
|
|
assert.equal(query(m.container, '.lc-axis-top').textContent, '100')
|
|
assert.equal(callbacks.length, 1)
|
|
// The pane widens back to the whole log while the reader sits at the left edge — a resize renders nothing,
|
|
// so only the observer's callback re-measures.
|
|
scroll.__clientW = 480
|
|
scroll.__scrollL = 0
|
|
await act(async () => { callbacks[0]() })
|
|
assert.equal(query(m.container, '.lc-axis-top').textContent, '900')
|
|
await m.unmount()
|
|
} finally {
|
|
holder.ResizeObserver = saved
|
|
}
|
|
})
|
|
|
|
test('the chart scroller cancels a horizontal swipe at either edge (browser history guard)', async () => {
|
|
const reqs: RequestRecord[] = []
|
|
for (let i = 0; i < 40; i++) reqs.push(req(i + 1, { turn: 1, step: i }))
|
|
const m = await mount(h(TrendChart, propsOf(reqs)))
|
|
await flush()
|
|
const scroll = query<LayoutEl>(m.container, '.lc-chart-scroll')
|
|
// Mount anchors at the newest bars: a further rightward swipe is the browser's forward gesture, leftward
|
|
// still scrolls the chart, and a vertical-dominant gesture belongs to the page.
|
|
assert.equal(scroll.scrollLeft, 240)
|
|
assert.equal(wheel(scroll, 30, 0), true, 'right edge cancels the forward swipe')
|
|
assert.equal(wheel(scroll, -30, 0), false, 'leftward still scrolls the chart')
|
|
assert.equal(wheel(scroll, 30, 120), false, 'vertical-dominant gestures stay with the page')
|
|
await scrollTo(scroll, 0)
|
|
assert.equal(wheel(scroll, -30, 0), true, 'left edge cancels the back swipe')
|
|
await scrollTo(scroll, 100)
|
|
assert.equal(wheel(scroll, -30, 0), false, 'mid-chart leftward still scrolls')
|
|
await m.unmount()
|
|
})
|
|
})
|
|
|
|
describe('TrendChart duration overlay (durationCurve)', () => {
|
|
/** The overlay's y for a duration at the whole-log scale (the component's own rounding). */
|
|
const activeMsY = (activeMs: number, maxActiveMs: number): number => Math.round((CHART_H - activeMs * (CHART_H / Math.max(1, maxActiveMs))) * 100) / 100
|
|
|
|
test('off by default: no curve, no right-hand axis', async () => {
|
|
const m = await mount(h(TrendChart, propsOf([req(1, { activeMs: 1000 }), req(2, { activeMs: 2000 })])))
|
|
assert.equal(queryAll(m.container, '.lc-duration').length, 0)
|
|
assert.equal(queryAll(m.container, '.lc-axis-r').length, 0)
|
|
await m.unmount()
|
|
})
|
|
|
|
test('on: the curve rides the bar columns and the right axis carries duration quartiles', async () => {
|
|
const reqs = [req(1, { activeMs: 1000 }), req(2, { activeMs: 2000 }), req(3, { activeMs: 4000 })]
|
|
const m = await mount(h(TrendChart, propsOf(reqs, { durationCurve: true })))
|
|
const svg = query(m.container, '.lc-duration')
|
|
assert.equal(svg.getAttribute('width'), String(3 * BAR_CELL - 2))
|
|
assert.equal(svg.getAttribute('height'), String(CHART_H))
|
|
const lines = queryAll(svg, 'polyline:not(.lc-dur-halo)')
|
|
assert.equal(lines.length, 1, 'one contiguous run of stamped bars')
|
|
assert.equal(
|
|
lines[0].getAttribute('points'),
|
|
`7,${activeMsY(1000, 4000)} 23,${activeMsY(2000, 4000)} 39,${activeMsY(4000, 4000)}`,
|
|
)
|
|
const halos = queryAll(svg, 'polyline.lc-dur-halo')
|
|
assert.equal(halos.length, 1, 'each run paints a card-bg knockout underlay')
|
|
assert.equal(halos[0].getAttribute('points'), lines[0].getAttribute('points'))
|
|
assert.equal(queryAll(svg, 'circle').length, 0)
|
|
const axis = query(m.container, '.lc-axis-r')
|
|
assert.equal(query(axis, '.lc-axis-top').textContent, '4.0s')
|
|
assert.equal(query(axis, '.lc-axis-q3').textContent, '3.0s')
|
|
assert.equal(query(axis, '.lc-axis-mid').textContent, '2.0s')
|
|
assert.equal(query(axis, '.lc-axis-q1').textContent, '1.0s')
|
|
assert.equal(query(axis, '.lc-axis-bot').textContent, '0')
|
|
await m.unmount()
|
|
})
|
|
|
|
test('a bar without ms breaks the line; an isolated single point draws a dot', async () => {
|
|
const reqs = [req(1, { activeMs: 1000 }), req(2), req(3, { activeMs: 2000 }), req(4, { activeMs: 4000 })]
|
|
const m = await mount(h(TrendChart, propsOf(reqs, { durationCurve: true })))
|
|
const svg = query(m.container, '.lc-duration')
|
|
const lines = queryAll(svg, 'polyline:not(.lc-dur-halo)')
|
|
assert.equal(lines.length, 1, 'the trailing pair re-joins after the gap')
|
|
assert.equal(lines[0].getAttribute('points'), `39,${activeMsY(2000, 4000)} 55,${activeMsY(4000, 4000)}`)
|
|
const dots = queryAll(svg, 'circle:not(.lc-dur-halo)')
|
|
assert.equal(dots.length, 1, 'a one-point run would be an invisible polyline')
|
|
assert.equal(dots[0].getAttribute('cx'), '7')
|
|
assert.equal(dots[0].getAttribute('cy'), String(activeMsY(1000, 4000)))
|
|
await m.unmount()
|
|
})
|
|
|
|
test('turn granularity plots each turn\'s summed active time', async () => {
|
|
const agg = aggregateByTurn([
|
|
req(1, { turn: 1, step: 0, activeMs: 1000 }),
|
|
req(2, { turn: 1, step: 1, activeMs: 500 }),
|
|
req(3, { turn: 2, step: 0, activeMs: 2000 }),
|
|
])
|
|
const m = await mount(h(TrendChart, propsOf(agg, { granularity: 'turn', durationCurve: true })))
|
|
const lines = queryAll(query(m.container, '.lc-duration'), 'polyline:not(.lc-dur-halo)')
|
|
assert.equal(lines[0].getAttribute('points'), `7,${activeMsY(1500, 2000)} 23,${activeMsY(2000, 2000)}`)
|
|
assert.equal(query(m.container, '.lc-axis-r .lc-axis-top').textContent, '2.0s')
|
|
await m.unmount()
|
|
})
|
|
|
|
test('delta mode keeps the floor-anchored duration axis; the hover tip appends the duration row', async () => {
|
|
const reqs = [req(1, { activeMs: 1000 }), req(2, { activeMs: 2000 })]
|
|
const m = await mount(h(TrendChart, propsOf(reqs, { mode: 'delta', durationCurve: true, hoveredSeq: 1 })))
|
|
assert.equal(query(m.container, '.lc-axis-r .lc-axis-top').textContent, '2.0s')
|
|
assert.equal(queryAll(query(m.container, '.lc-duration'), 'polyline:not(.lc-dur-halo)').length, 1)
|
|
const rows = queryAll(query(m.container, '.lc-chart-tip'), 'span').map(r => r.textContent)
|
|
assert.equal(rows.length, 3)
|
|
assert.equal(rows[2], kit.t('tip.duration', { n: '1.0s' }))
|
|
await m.update(h(TrendChart, propsOf(reqs, { mode: 'delta', hoveredSeq: 1 })))
|
|
assert.equal(queryAll(query(m.container, '.lc-chart-tip'), 'span').length, 2)
|
|
await m.unmount()
|
|
})
|
|
|
|
test('adaptive scale rescales the curve to the visible window like the bars', async () => {
|
|
const reqs: RequestRecord[] = []
|
|
for (let i = 0; i < 30; i++) reqs.push(req(i + 1, { turn: 1, step: i, activeMs: i === 0 ? 9000 : 1000 }))
|
|
const m = await mount(h(TrendChart, propsOf(reqs, { adaptive: true, durationCurve: true })))
|
|
await flush()
|
|
const scroll = query<LayoutEl>(m.container, '.lc-chart-scroll')
|
|
// Anchored at the newest bars (scrollLeft 80): the 9s spike at bar 0 is off screen.
|
|
assert.equal(query(m.container, '.lc-axis-r .lc-axis-top').textContent, '1.0s')
|
|
await scrollTo(scroll, 0)
|
|
assert.equal(query(m.container, '.lc-axis-r .lc-axis-top').textContent, '9.0s')
|
|
await m.unmount()
|
|
})
|
|
|
|
test('no stamped durations at all: the axis shows the scale floor, the curve draws nothing', async () => {
|
|
const m = await mount(h(TrendChart, propsOf([req(1), req(2)], { durationCurve: true })))
|
|
assert.equal(queryAll(m.container, '.lc-duration').length, 0)
|
|
const axis = query(m.container, '.lc-axis-r')
|
|
assert.equal(query(axis, '.lc-axis-top').textContent, '—')
|
|
assert.equal(query(axis, '.lc-axis-bot').textContent, '0')
|
|
await m.unmount()
|
|
})
|
|
})
|
|
|
|
describe('TrendChart scroll anchoring', () => {
|
|
function manySteps(): RequestRecord[] {
|
|
const out: RequestRecord[] = []
|
|
for (let i = 0; i < 40; i++) {
|
|
out.push(req(i + 1, { turn: 1 + Math.floor(i / 10), step: i % 10 }))
|
|
}
|
|
return out
|
|
}
|
|
|
|
test('the end-anchor sticks only near the end', async () => {
|
|
const reqs = manySteps()
|
|
const { handlers } = makeSpies()
|
|
const m = await mount(h(TrendChart, propsOf(reqs, handlers)))
|
|
await flush()
|
|
const scroll = query<LayoutEl>(m.container, '.lc-chart-scroll')
|
|
|
|
// scrollWidth 640 vs clientWidth 400: mount anchors to the newest (right) edge.
|
|
assert.equal(scroll.scrollLeft, 240)
|
|
await scrollTo(scroll, 100)
|
|
|
|
// An unrelated (selection-only) update mid-scroll does NOT re-anchor (100 + 400 < 640 - 24), and the chart
|
|
// also stays put when the same selection-only update fires NEAR the right edge — only a real data push
|
|
// (a new request appended, or a granularity/focus switch) may follow the newest bar, so a hover/select
|
|
// change never flashes the column.
|
|
await m.update(h(TrendChart, propsOf(reqs, { ...handlers, selectedSeq: 1 })))
|
|
assert.equal(scroll.scrollLeft, 100)
|
|
await scrollTo(scroll, 230)
|
|
await m.update(h(TrendChart, propsOf(reqs, { ...handlers, selectedSeq: 2 })))
|
|
await flush()
|
|
assert.equal(scroll.scrollLeft, 230, 'selection-only update near the end does not stick')
|
|
|
|
// Turn labels re-center within their visible slice: with the reader at scrollLeft 240, T2 is half-clipped
|
|
// at the left → shifted right; T3 is fully visible and centered → no transform; T1 is fully out of view → untouched.
|
|
await scrollTo(scroll, 240)
|
|
const labels = queryAll(m.container, '.lc-turn-label')
|
|
assert.equal(labels.length, 4)
|
|
assert.equal(labels[1].style.transform, 'translateX(40px)')
|
|
assert.equal(labels[2].style.transform, '')
|
|
assert.equal(labels[0].style.transform, '')
|
|
await scrollTo(scroll, 0)
|
|
assert.equal(labels[1].style.transform, '', 'back at the left edge every block centers natively')
|
|
await m.unmount()
|
|
})
|
|
|
|
test('a data push follows the newest bar only when the reader was near the right edge', async () => {
|
|
const reqs = manySteps()
|
|
const { handlers } = makeSpies()
|
|
const m = await mount(h(TrendChart, propsOf(reqs, handlers)))
|
|
await flush()
|
|
const scroll = query<LayoutEl>(m.container, '.lc-chart-scroll')
|
|
// Mount anchors at the right edge (scrollLeft = 240 over scrollWidth 640 / clientWidth 400).
|
|
assert.equal(scroll.scrollLeft, 240)
|
|
|
|
const grown = [...reqs, req(reqs.length + 1, { turn: 1 + Math.floor(reqs.length / 10), step: reqs.length % 10 })]
|
|
await m.update(h(TrendChart, propsOf(grown, handlers)))
|
|
await flush()
|
|
assert.equal(scroll.scrollLeft, 256, 'near-edge reader follows the appended bar')
|
|
|
|
await scrollTo(scroll, 100)
|
|
const grown2 = [...grown, req(grown.length + 1, { turn: 1 + Math.floor(grown.length / 10), step: grown.length % 10 })]
|
|
await m.update(h(TrendChart, propsOf(grown2, handlers)))
|
|
await flush()
|
|
assert.equal(scroll.scrollLeft, 100, 'mid-scroll reader is not yanked back to the newest')
|
|
await m.unmount()
|
|
})
|
|
|
|
test('a granularity switch re-anchors and flips overflow for real (React #185 regression)', async () => {
|
|
const reqs = manySteps()
|
|
const { handlers } = makeSpies()
|
|
const m = await mount(h(TrendChart, propsOf(reqs, { ...handlers, granularity: 'step' })))
|
|
await flush()
|
|
assert.equal(query(m.container, '.lc-chart-scroll').scrollLeft, 240)
|
|
|
|
await m.update(h(TrendChart, propsOf(aggregateByTurn(reqs), { ...handlers, granularity: 'turn' })))
|
|
await flush()
|
|
assert.equal(bars(m.container).length, 4)
|
|
|
|
await m.update(h(TrendChart, propsOf(reqs, { ...handlers, granularity: 'step' })))
|
|
await flush()
|
|
assert.equal(bars(m.container).length, 40)
|
|
assert.equal(query(m.container, '.lc-chart-scroll').scrollLeft, 240)
|
|
await m.unmount()
|
|
})
|
|
|
|
test('focusTurn scroll-centers the target turn bar once; unknown turns still consume the focus', async () => {
|
|
const savedW = viewport.clientW
|
|
viewport.clientW = 30
|
|
try {
|
|
const agg = aggregateByTurn([...manySteps(), ...manySteps().map(r => ({ ...r, seq: r.seq + 40, turn: (r.turn ?? 0) + 4 }))])
|
|
assert.equal(agg.length, 8)
|
|
const { spies, handlers } = makeSpies()
|
|
const m = await mount(h(TrendChart, propsOf(agg, { ...handlers, granularity: 'turn', focusTurn: 2 })))
|
|
await flush()
|
|
const scroll = query<LayoutEl>(m.container, '.lc-chart-scroll')
|
|
assert.ok(spies.focusHandled >= 1)
|
|
assert.equal(scroll.scrollLeft, 8) // 1 * 16 + 7 - 30/2
|
|
|
|
const before = spies.focusHandled
|
|
await m.update(h(TrendChart, propsOf(agg, { ...handlers, granularity: 'turn', focusTurn: 99 })))
|
|
await flush()
|
|
assert.ok(spies.focusHandled > before, 'the focus is consumed even when the turn is absent')
|
|
assert.equal(scroll.scrollLeft, 8, 'no focus target and not near the end → the scroll position holds')
|
|
await m.unmount()
|
|
} finally {
|
|
viewport.clientW = savedW
|
|
}
|
|
})
|
|
|
|
test('turn labels clear their shift for blocks narrower than the measured label', async () => {
|
|
const reqs: RequestRecord[] = []
|
|
for (let i = 0; i < 16; i++) reqs.push(req(i + 1, { turn: 1 + Math.floor(i / 8), step: i % 8 }))
|
|
const { handlers } = makeSpies()
|
|
const m = await mount(h(TrendChart, propsOf(reqs, handlers)))
|
|
const scroll = query<LayoutEl>(m.container, '.lc-chart-scroll')
|
|
const labels = queryAll(m.container, '.lc-turn-label')
|
|
assert.equal(labels.length, 2) // T1 off 0 w 126, T2 off 128 w 126; scrollWidth 256
|
|
|
|
scroll.__clientW = 60
|
|
await scrollTo(scroll, 150)
|
|
// T2 visible slice [150, 210] → center 52 vs block center 63 → shift left; T1 fully out of view → untouched.
|
|
assert.equal(labels[1].style.transform, 'translateX(-11px)')
|
|
assert.equal(labels[0].style.transform, '')
|
|
|
|
// A measured label wider than its block never shifts (block stays put).
|
|
Object.defineProperty(labels[1], 'offsetWidth', { configurable: true, get: () => 200 })
|
|
await scrollTo(scroll, 150)
|
|
assert.equal(labels[1].style.transform, '')
|
|
|
|
// A real measured label: clamped to keep the label inside its block on both sides.
|
|
Object.defineProperty(labels[1], 'offsetWidth', { configurable: true, get: () => 40 })
|
|
await scrollTo(scroll, 196)
|
|
assert.equal(labels[1].style.transform, 'translateX(34px)')
|
|
await scrollTo(scroll, 100)
|
|
assert.equal(labels[1].style.transform, 'translateX(-43px)')
|
|
await scrollTo(scroll, 0)
|
|
assert.equal(labels[1].style.transform, '', 'fully out of view → shift cleared')
|
|
await scrollEvent(scroll)
|
|
assert.equal(labels[1].style.transform, '', 'repeat scroll with unchanged geometry writes nothing')
|
|
await m.unmount()
|
|
})
|
|
|
|
test('overflowing labels render whole and thin out: a label colliding with the kept one hides until it clears', async () => {
|
|
// Turn granularity: three 14px blocks at 16px pitch, all carrying two-digit labels.
|
|
const agg = aggregateByTurn([req(1, { turn: 10 }), req(2, { turn: 11 }), req(3, { turn: 12 })])
|
|
const m = await mount(h(TrendChart, propsOf(agg, { granularity: 'turn' })))
|
|
const scroll = query<LayoutEl>(m.container, '.lc-chart-scroll')
|
|
const labels = queryAll(m.container, '.lc-turn-label')
|
|
assert.deepEqual(labels.map(l => l.textContent), ['10', '11', '12'])
|
|
|
|
// 20px labels over 14px blocks (dx pinned at 0, natively centered): boxes [-3,17], [13,33], [29,49] — 11
|
|
// collides with the kept 10 and hides; 12 clears it (past box + gap) and stays visible.
|
|
for (const l of labels) Object.defineProperty(l, 'offsetWidth', { configurable: true, get: () => 20 })
|
|
await scrollEvent(scroll)
|
|
assert.equal(labels[0].style.visibility, '')
|
|
assert.equal(labels[1].style.visibility, 'hidden')
|
|
assert.equal(labels[2].style.visibility, '')
|
|
|
|
for (const l of labels) Object.defineProperty(l, 'offsetWidth', { configurable: true, get: () => 8 })
|
|
await scrollEvent(scroll)
|
|
assert.equal(labels[1].style.visibility, '')
|
|
await scrollEvent(scroll)
|
|
assert.equal(labels[1].style.visibility, '', 'repeat scroll with unchanged geometry writes nothing')
|
|
await m.unmount()
|
|
})
|
|
|
|
test('the strip shrinks every label to the largest size at which the tightest adjacent pair fits', async () => {
|
|
// One/two-digit turns at the 16px column pitch fit the 10px base (est. 6.5px/digit + 2px gap vs 16px) → no override.
|
|
const single = await mount(h(TrendChart, propsOf(
|
|
aggregateByTurn([req(1, { turn: 1 }), req(2, { turn: 2 }), req(3, { turn: 10 })]),
|
|
{ granularity: 'turn' },
|
|
)))
|
|
assert.equal(query<HTMLElement>(single.container, '.lc-turns').style.fontSize, '')
|
|
await single.unmount()
|
|
|
|
// Three-digit labels over 14px blocks: est. need 21.5px vs 16px pitch → floor(10 * 16/21.5) = 7px for EVERY label.
|
|
const three = await mount(h(TrendChart, propsOf(
|
|
aggregateByTurn([req(1, { turn: 100 }), req(2, { turn: 101 })]),
|
|
{ granularity: 'turn' },
|
|
)))
|
|
assert.equal(query<HTMLElement>(three.container, '.lc-turns').style.fontSize, '7px')
|
|
await three.unmount()
|
|
|
|
// Four-digit pairs clamp at the 6px floor (the measured chain stays the last-resort guard below it).
|
|
const four = await mount(h(TrendChart, propsOf(
|
|
aggregateByTurn([req(1, { turn: 1000 }), req(2, { turn: 1001 })]),
|
|
{ granularity: 'turn' },
|
|
)))
|
|
assert.equal(query<HTMLElement>(four.container, '.lc-turns').style.fontSize, '6px')
|
|
await four.unmount()
|
|
|
|
// Wide step-mode blocks buy room: ten-step turns carry three-digit labels at the base size.
|
|
const wide: RequestRecord[] = []
|
|
for (let i = 0; i < 20; i++) wide.push(req(i + 1, { turn: 100 + Math.floor(i / 10), step: i % 10 }))
|
|
const step = await mount(h(TrendChart, propsOf(wide)))
|
|
assert.equal(query<HTMLElement>(step.container, '.lc-turns').style.fontSize, '')
|
|
await step.unmount()
|
|
})
|
|
})
|
|
|
|
describe('TrendChart hover tip overlay', () => {
|
|
function manySteps(): RequestRecord[] {
|
|
const out: RequestRecord[] = []
|
|
for (let i = 0; i < 40; i++) {
|
|
out.push(req(i + 1, { turn: 1 + Math.floor(i / 10), step: i % 10 }))
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** The regression behind the overlay split: the tooltip used to live INSIDE .lc-chart-scroll, and an absolutely
|
|
* positioned child of a scroller contributes to its scrollable overflow — a several-hundred-px reply preview on a
|
|
* right-edge bar inflated scrollWidth on every hover, flapping the horizontal scrollbar open/closed and jumping the
|
|
* whole card. The tip now lives beside the scroller (positioned by the wrapper), and syncTip glues it to the bar's
|
|
* visible slice on every scroll/commit. */
|
|
test('renders outside the scroller, glues to the visible slice while scrolling, and clamps at both edges', async () => {
|
|
const reqs = manySteps()
|
|
const { handlers } = makeSpies()
|
|
// Mounted WITH the hover set: the wrapper owns the tip from the very first commit (scrollWidth 640 > 400, so the
|
|
// chart mounts pre-scrolled to sl=240).
|
|
const m = await mount(h(TrendChart, propsOf(reqs, { ...handlers, hoveredSeq: 40 })))
|
|
await flush()
|
|
const scroll = query<LayoutEl>(m.container, '.lc-chart-scroll')
|
|
const tip = query(m.container, '.lc-chart-tip')
|
|
assert.equal(tip.parentElement!.className, 'lc-chart-wrap', 'the tip is a sibling of the scroller, not its child')
|
|
assert.equal(query(m.container, '.lc-chart-wrap').contains(tip), true)
|
|
assert.equal(query(m.container, '.lc-chart-wrap').contains(scroll), true)
|
|
|
|
// Newest bar (idx 39 → col 631) fully scrolled into view at the right edge: dx = 631 - 240.
|
|
assert.equal(tip.style.transform, 'translate(391px, 0)')
|
|
|
|
// Glue without clamping: hover bar idx 19 (col 311) — the scroller is still parked at sl=240 from mount.
|
|
await m.update(h(TrendChart, propsOf(reqs, { ...handlers, hoveredSeq: 20 })))
|
|
assert.equal(query(m.container, '.lc-chart-tip').style.transform, 'translate(71px, 0)')
|
|
// Scrolling without any React commit keeps the tip glued: 311 - 100 = 211.
|
|
await scrollTo(scroll, 100)
|
|
assert.equal(query(m.container, '.lc-chart-tip').style.transform, 'translate(211px, 0)')
|
|
// And again deeper: 311 - 211 = 100.
|
|
await scrollTo(scroll, 211)
|
|
assert.equal(query(m.container, '.lc-chart-tip').style.transform, 'translate(100px, 0)')
|
|
|
|
// Left-edge clamp: hovering bar idx 1 (col 23) while parked deep right pushes the raw dx far negative.
|
|
await m.update(h(TrendChart, propsOf(reqs, { ...handlers, hoveredSeq: 2 })))
|
|
await scrollTo(scroll, 240)
|
|
assert.equal(query(m.container, '.lc-chart-tip').style.transform, 'translate(0px, 0)')
|
|
|
|
// Overflow geometry stays honest: the scrollable area is exactly the bars', never the tip's (jsdom cannot prove
|
|
// the browser-level scrollbar parity here — verified against real Chromium separately).
|
|
assert.equal(scroll.scrollWidth, 640)
|
|
await m.unmount()
|
|
})
|
|
|
|
test('real tip widths clamp into the viewport on both sides and center when wider than the viewport', async () => {
|
|
const reqs = manySteps()
|
|
const { handlers } = makeSpies()
|
|
const m = await mount(h(TrendChart, propsOf(reqs, { ...handlers, hoveredSeq: 30 })))
|
|
await flush()
|
|
const scroll = query<LayoutEl>(m.container, '.lc-chart-scroll')
|
|
const tip = query<LayoutEl & { offsetWidth: number }>(m.container, '.lc-chart-tip')
|
|
scroll.__clientW = 160
|
|
Object.defineProperty(tip, 'offsetWidth', { configurable: true, get: () => 120 })
|
|
// idx 29 → col 471. cw 160 / lw 120 → valid center window [60, 100]:
|
|
try {
|
|
// dx 471 far past the right edge → pinned at cw - lw/2.
|
|
await scrollTo(scroll, 0)
|
|
assert.equal(tip.style.transform, 'translate(40px, 0)')
|
|
// dx 91, inside the window → rides raw.
|
|
await scrollTo(scroll, 380)
|
|
assert.equal(tip.style.transform, 'translate(31px, 0)')
|
|
// Parked at the last sl (480): dx -9 → pinned at lw/2.
|
|
await scrollTo(scroll, 480)
|
|
assert.equal(tip.style.transform, 'translate(0px, 0)')
|
|
|
|
// A tip wider than the viewport centers over it, bleeding symmetrically instead of picking a bogus side.
|
|
Object.defineProperty(tip, 'offsetWidth', { configurable: true, get: () => 500 })
|
|
await scrollTo(scroll, 0)
|
|
assert.equal(tip.style.transform, 'translate(-170px, 0)')
|
|
} finally {
|
|
delete scroll.__clientW
|
|
}
|
|
await m.unmount()
|
|
})
|
|
})
|