Files
dsh-context/tests/client/components/donut.spec.ts
T
bowenliang123 ba8d7c7624 refactor(client): unwrap the zero-argument component factories
makeDonut() and makeSliceList() took no arguments, closed over nothing,
and returned a component their callers then held in a per-mount local —
seven call sites paying for a factory that bought no identity stability a
plain module-level export does not already give. Both export the component
directly now; sliceList's inline props type becomes a named interface,
since it was written out twice in the signature.

objectsOf and rpcCallOf lose an export keyword each: whole-tree search finds
no consumer outside the module that defines them.
2026-10-10 02:18:13 +08:00

176 lines
7.1 KiB
TypeScript

import { createElement as h, useState } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { Donut } from '../../../src/client/components/donut'
import type { DonutSegment } from '../../../src/client/components/donut'
import { hover, mount, query, queryAll, text, unhover } from '../helpers/kit'
/** A parent that really holds the hover key, so hovering re-renders the ring. */
function HoverHarness(props: { segments: DonutSegment[] }) {
const [hoverKey, setHoverKey] = useState<string | null>(null)
return h(Donut, { segments: props.segments, centerTop: 'x', hoverKey, onHoverKey: setHoverKey })
}
describe('Donut', () => {
test('segments render one circle each, in order, with accumulating offsets', async () => {
const m = await mount(h(Donut, {
segments: [
{ key: 'a', color: '#ff0000', value: 30 },
{ key: 'b', color: '#00ff00', value: 70 },
],
centerTop: '2h 14m',
centerSub: 'active time',
}))
const circles = queryAll(m.container, 'circle')
assert.equal(circles.length, 2)
const dash = (el: Element): string => el.getAttribute('stroke-dasharray') ?? ''
const offset = (el: Element): number => Number(el.getAttribute('stroke-dashoffset'))
assert.equal(dash(circles[0]), '29.5 70.5')
assert.equal(offset(circles[0]), 124.75)
assert.equal(dash(circles[1]), '69.5 30.5')
assert.equal(offset(circles[1]), 94.75)
assert.equal(query(m.container, '.lc-donut-center b').textContent, '2h 14m')
assert.equal(query(m.container, '.lc-donut-center span').textContent, 'active time')
await m.unmount()
})
test('non-positive and non-finite values are skipped', async () => {
const m = await mount(h(Donut, {
segments: [
{ key: 'a', color: '#ff0000', value: 0 },
{ key: 'b', color: '#00ff00', value: -5 },
{ key: 'c', color: '#0000ff', value: Number.NaN },
{ key: 'd', color: '#ffffff', value: 100 },
],
centerTop: 'x',
}))
const circles = queryAll(m.container, 'circle')
assert.equal(circles.length, 1)
// The color rides inline style (var() values can't live in a stroke attribute);
// jsdom reports the style keyword in its normalized rgb() spelling.
assert.equal(circles[0].style.stroke, 'rgb(255, 255, 255)')
await m.unmount()
})
test('an all-zero total renders the neutral track ring, no segment circles', async () => {
const m = await mount(h(Donut, {
segments: [{ key: 'a', color: '#ff0000', value: 0 }],
centerTop: '—',
}))
assert.equal(queryAll(m.container, 'circle').length, 1)
assert.ok(query(m.container, '.lc-donut-track'))
assert.equal(query(m.container, '.lc-donut-center b').textContent, '—')
assert.equal(queryAll(m.container, '.lc-donut-center span').length, 0)
await m.unmount()
})
test('an empty segment list renders the track too', async () => {
const m = await mount(h(Donut, { segments: [], centerTop: '—' }))
assert.ok(query(m.container, '.lc-donut-track'))
assert.ok(text(m.container).includes('—'))
await m.unmount()
})
})
describe('Donut slice divider', () => {
/** An arc's painted length, in dasharray units (1 unit ≈ 1% of the ring). */
const lenOf = (el: Element): number => Number((el.getAttribute('stroke-dasharray') ?? '').split(' ')[0])
/** The arc's start position along the ring: dashoffset counts backwards, so 0 units = 3 o'clock. */
const startOf = (el: Element): number => (100 - Number(el.getAttribute('stroke-dashoffset')) + 100) % 100
test('every seam between neighbouring slices opens by the same hairline', async () => {
const m = await mount(h(Donut, {
segments: [
{ key: 'a', color: '#ff0000', value: 30 },
{ key: 'b', color: '#00ff00', value: 70 },
],
centerTop: 'x',
}))
const arcs = queryAll(m.container, '.lc-donut-seg')
// Half a gap off each end of both arcs, and both cuts land on the SAME
// seam: the ring shows one 0.5-unit opening there, not two.
assert.ok(Math.abs(lenOf(arcs[0]) - 29.5) < 1e-9)
for (let i = 0; i < arcs.length; i++) {
const end = startOf(arcs[i]) + lenOf(arcs[i])
const gap = (startOf(arcs[(i + 1) % arcs.length]) - end + 100) % 100
assert.ok(Math.abs(gap - 0.5) < 1e-9, `seam ${i} opened ${gap}`)
}
await m.unmount()
})
test('a lone slice keeps a full ring, with no divider nick', async () => {
const m = await mount(h(Donut, {
segments: [{ key: 'a', color: '#ff0000', value: 42 }],
centerTop: 'x',
}))
assert.equal(lenOf(query(m.container, '.lc-donut-seg')), 100)
await m.unmount()
})
test('a sliver thinner than the gap is cut by a quarter per end, never swallowed', async () => {
const m = await mount(h(Donut, {
segments: [
{ key: 'tiny', color: '#ff0000', value: 0.2 },
{ key: 'rest', color: '#00ff00', value: 99.8 },
],
centerTop: 'x',
}))
const [tiny, rest] = queryAll(m.container, '.lc-donut-seg')
assert.ok(Math.abs(lenOf(tiny) - 0.1) < 1e-9)
assert.ok(Math.abs(lenOf(rest) - 99.3) < 1e-9)
await m.unmount()
})
})
describe('Donut entrance sweep', () => {
test('segments carry the sweep-in stagger slot in paint order', async () => {
const m = await mount(h(Donut, {
segments: [
{ key: 'a', color: '#ff0000', value: 30 },
{ key: 'b', color: '#00ff00', value: 70 },
],
centerTop: 'x',
}))
const arcs = queryAll(m.container, '.lc-donut-seg')
assert.equal((arcs[0] as HTMLElement).style.getPropertyValue('--lc-i'), '0')
assert.equal((arcs[1] as HTMLElement).style.getPropertyValue('--lc-i'), '1')
await m.unmount()
})
})
describe('Donut hover link', () => {
const SEGS: DonutSegment[] = [
{ key: 'a', color: '#ff0000', value: 30 },
{ key: 'b', color: '#00ff00', value: 70 },
]
test('hovering an arc dims the ring and lights it; leaving the ring clears both', async () => {
const m = await mount(h(HoverHarness, { segments: SEGS }))
const arcs = queryAll(m.container, '.lc-donut-seg')
const cls = (el: Element): string => el.getAttribute('class') ?? ''
await hover(arcs[0])
assert.ok(query(m.container, '.lc-donut').className.includes('lc-donut-dim'))
assert.ok(cls(arcs[0]).includes('lc-donut-seg-on'))
assert.ok(!cls(arcs[1]).includes('lc-donut-seg-on'))
await unhover(query(m.container, '.lc-donut'))
assert.ok(!query(m.container, '.lc-donut').className.includes('lc-donut-dim'))
assert.ok(!cls(arcs[0]).includes('lc-donut-seg-on'))
await m.unmount()
})
test('a hover key with no painted arc leaves the ring at rest', async () => {
const m = await mount(h(Donut, { segments: SEGS, centerTop: 'x', hoverKey: 'ghost' }))
assert.ok(!query(m.container, '.lc-donut').className.includes('lc-donut-dim'))
await m.unmount()
})
test('hover events without a relay stay inert', async () => {
const m = await mount(h(Donut, { segments: SEGS, centerTop: 'x' }))
await hover(query(m.container, '.lc-donut-seg'))
await unhover(query(m.container, '.lc-donut'))
assert.ok(!query(m.container, '.lc-donut').className.includes('lc-donut-dim'))
await m.unmount()
})
})