fix(web): preserve whale accessibility and update animation tests
This commit is contained in:
1 parent
e4aa508e35
commit
167fea0723
4 files changed
+126
-68
No files matched your search
@@ -65,39 +65,58 @@ it('shows completed paragraph first lines across blank lines with a right-edge f
|
||||
const reasoning = page.locator('[data-variant="think"][data-state="running"]')
|
||||
await expandOwningTurnProcess(page, reasoning)
|
||||
await reasoning.waitFor()
|
||||
const whale = page.locator('[data-chat-running] svg')
|
||||
const animatedWhale = whale.locator('path:has(animate)')
|
||||
const restingWhale = whale.locator('path:not(:has(animate))')
|
||||
await animatedWhale.locator('animate').waitFor({ state: 'attached' })
|
||||
const positions = await animatedWhale.evaluate(async (element) => {
|
||||
const path = element as SVGPathElement
|
||||
const svg = path.ownerSVGElement!
|
||||
const time = svg.getCurrentTime()
|
||||
svg.pauseAnimations()
|
||||
try {
|
||||
const positions: number[][] = []
|
||||
for (const at of [0.3, 0.9, 3.3]) {
|
||||
svg.setCurrentTime(at)
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => { resolve() }))
|
||||
const point = path.getPointAtLength(path.getTotalLength() * 0.4)
|
||||
positions.push([point.x, point.y])
|
||||
}
|
||||
return positions
|
||||
} finally {
|
||||
svg.setCurrentTime(time)
|
||||
svg.unpauseAnimations()
|
||||
}
|
||||
})
|
||||
expect(positions[1]).not.toEqual(positions[0])
|
||||
expect(positions[2]).toEqual(positions[0])
|
||||
expect(await animatedWhale.evaluate(element => getComputedStyle(element).display)).not.toBe('none')
|
||||
expect(await restingWhale.evaluate(element => getComputedStyle(element).display)).toBe('none')
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
await expect.poll(() => animatedWhale.evaluate(element => getComputedStyle(element).display)).toBe('none')
|
||||
await expect.poll(() => restingWhale.evaluate(element => getComputedStyle(element).display)).not.toBe('none')
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' })
|
||||
await expect.poll(() => animatedWhale.evaluate(element => getComputedStyle(element).display)).not.toBe('none')
|
||||
await expect.poll(() => restingWhale.evaluate(element => getComputedStyle(element).display)).toBe('none')
|
||||
const whale = page.locator('[data-chat-running] span[aria-hidden="true"]:has(> svg)')
|
||||
const animatedWhale = whale.locator(':scope > span')
|
||||
const restingWhale = whale.locator(':scope > svg')
|
||||
await whale.waitFor()
|
||||
expect(await whale.locator('animate').count()).toBe(0)
|
||||
const expectStaticWhale = async () => {
|
||||
await expect.poll(() => animatedWhale.isVisible()).toBe(false)
|
||||
await expect.poll(() => restingWhale.isVisible()).toBe(true)
|
||||
expect(await animatedWhale.evaluate(element => getComputedStyle(element).maskImage)).toBe('none')
|
||||
const colors = await restingWhale.locator('path').evaluate((path) => {
|
||||
const probe = document.createElement('span')
|
||||
probe.style.cssText = 'color: Canvas; forced-color-adjust: none'
|
||||
document.body.append(probe)
|
||||
try {
|
||||
const style = getComputedStyle(path)
|
||||
return {
|
||||
stroke: style.stroke.toLowerCase() === 'currentcolor' ? style.color : style.stroke,
|
||||
canvas: getComputedStyle(probe).color,
|
||||
length: (path as SVGPathElement).getTotalLength(),
|
||||
}
|
||||
} finally { probe.remove() }
|
||||
})
|
||||
expect(colors.stroke).not.toBe('none')
|
||||
expect(colors.stroke).not.toBe('transparent')
|
||||
expect(colors.stroke).not.toBe(colors.canvas)
|
||||
expect(colors.length).toBeGreaterThan(0)
|
||||
}
|
||||
try {
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference', forcedColors: 'none' })
|
||||
await expect.poll(() => animatedWhale.isVisible()).toBe(true)
|
||||
expect(await animatedWhale.evaluate(element => getComputedStyle(element).maskMode)).toBe('alpha')
|
||||
const imageSize = await animatedWhale.evaluate(async (element) => {
|
||||
const source = getComputedStyle(element).maskImage.match(/^url\("?(data:image\/png;base64,[A-Za-z0-9+/=]+)"?\)$/)?.[1]
|
||||
if (source === undefined) throw new Error('Running whale mask must use the bundled PNG data URL')
|
||||
const image = new Image()
|
||||
image.src = source
|
||||
await image.decode()
|
||||
return [image.naturalWidth, image.naturalHeight]
|
||||
})
|
||||
expect(imageSize).toEqual([28, 28])
|
||||
expect(await restingWhale.isVisible()).toBe(false)
|
||||
const firstWhaleFrame = await whale.screenshot({ animations: 'allow' })
|
||||
await expect.poll(async () => !(await whale.screenshot({ animations: 'allow' })).equals(firstWhaleFrame), {
|
||||
timeout: 5000,
|
||||
}).toBe(true)
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' })
|
||||
await expectStaticWhale()
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference', forcedColors: 'active' })
|
||||
await expectStaticWhale()
|
||||
} finally {
|
||||
await page.emulateMedia({ reducedMotion: null, forcedColors: null })
|
||||
}
|
||||
expect(await reasoning.getAttribute('data-preview')).toBeNull()
|
||||
|
||||
first.proceed.resolve(undefined)
|
||||
@@ -120,6 +139,7 @@ it('shows completed paragraph first lines across blank lines with a right-edge f
|
||||
third.proceed.resolve(undefined)
|
||||
await settled
|
||||
await page.getByText('Done', { exact: true }).waitFor()
|
||||
await whale.waitFor({ state: 'detached' })
|
||||
expect(console.pageErrors).toEqual([])
|
||||
expect(console.warnings).toEqual([])
|
||||
} finally {
|
||||
|
||||
@@ -160,32 +160,35 @@
|
||||
flex: none;
|
||||
width: calc(14px + var(--dsh-content-font-delta, 0px));
|
||||
height: calc(14px + var(--dsh-content-font-delta, 0px));
|
||||
/* The fixed footprint contains the animation's layout and paint. */
|
||||
contain: strict;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.runningWhaleAnimated {
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: currentColor;
|
||||
mask: url('./[email protected]') center / 100% 100% no-repeat;
|
||||
mask-mode: alpha;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.runningWhaleStill {
|
||||
display: none;
|
||||
display: initial;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.runningWhaleAnimated {
|
||||
display: none;
|
||||
mask-image: none;
|
||||
will-change: auto;
|
||||
}
|
||||
@supports (mask-mode: alpha) and (mask-image: url('')) {
|
||||
@media (prefers-reduced-motion: no-preference) and (forced-colors: none) {
|
||||
.runningWhaleAnimated {
|
||||
display: block;
|
||||
background: currentColor;
|
||||
mask: url('./[email protected]') center / 100% 100% no-repeat;
|
||||
mask-mode: alpha;
|
||||
/* Keep animated-mask repaints on an icon-sized composited layer. */
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.runningWhaleStill {
|
||||
display: initial;
|
||||
.runningWhaleStill {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4131,8 +4131,12 @@ describe('ChatView', () => {
|
||||
expect(column.lastElementChild).toBe(status)
|
||||
expect(within(status as HTMLElement).getByRole('status').textContent).toBe('深度求索中')
|
||||
expect(status?.lastElementChild?.textContent).toBe('深度求索中')
|
||||
expect(status?.querySelector('svg')?.parentElement?.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(status?.querySelector('animate')).not.toBeNull()
|
||||
const icon = status?.querySelector('svg')?.parentElement
|
||||
expect(icon?.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(icon?.firstElementChild?.tagName).toBe('SPAN')
|
||||
expect(icon?.querySelectorAll('path')).toHaveLength(1)
|
||||
expect(icon?.getAttribute('style')).toBeNull()
|
||||
expect(status?.querySelector('animate')).toBeNull()
|
||||
act(() => { h.setSession({ running: false }) })
|
||||
expect(column.querySelector('[data-chat-running]')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { RunningWhaleTail } from '../src/client/chat/RunningWhaleTail.tsx'
|
||||
@@ -7,26 +9,55 @@ import { RunningWhaleTail } from '../src/client/chat/RunningWhaleTail.tsx'
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('RunningWhaleTail', () => {
|
||||
it('renders matching animated and resting contours for the stylesheet to switch', () => {
|
||||
it('renders a decorative mask seat and static SVG without inline styles', () => {
|
||||
const view = render(<RunningWhaleTail />)
|
||||
expect(view.container.firstElementChild?.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(view.container.querySelector('svg')?.getAttribute('viewBox')).toBe('0 0 16 16')
|
||||
const contours = view.container.querySelectorAll('path')
|
||||
expect(contours).toHaveLength(2)
|
||||
expect(contours[1]?.getAttribute('d')).toBe(contours[0]?.getAttribute('d'))
|
||||
const animation = view.container.querySelector('animate')
|
||||
expect(animation?.getAttribute('attributeName')).toBe('d')
|
||||
expect(animation?.getAttribute('begin')).toBe('0.3s')
|
||||
expect(animation?.getAttribute('dur')).toBe('3s')
|
||||
expect(animation?.getAttribute('repeatCount')).toBe('indefinite')
|
||||
const paths = animation!.getAttribute('values')!.split(';')
|
||||
const times = animation!.getAttribute('keyTimes')!.split(';').map(Number)
|
||||
expect(paths.length).toBeGreaterThan(1)
|
||||
expect(paths).toHaveLength(times.length)
|
||||
expect(paths[0]).toBe(view.container.querySelector('path')?.getAttribute('d'))
|
||||
expect(paths.at(-1)).toBe(paths[0])
|
||||
expect(times[0]).toBe(0)
|
||||
expect(times.at(-1)).toBe(1)
|
||||
expect(times.slice(1).every((time, i) => time > times[i]!)).toBe(true)
|
||||
const icon = view.container.firstElementChild!
|
||||
expect(icon.tagName).toBe('SPAN')
|
||||
expect(icon.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(icon.children).toHaveLength(2)
|
||||
expect(icon.firstElementChild?.tagName).toBe('SPAN')
|
||||
expect(icon.firstElementChild?.childElementCount).toBe(0)
|
||||
const svg = icon.querySelector('svg')!
|
||||
expect(icon.lastElementChild).toBe(svg)
|
||||
expect(svg.getAttribute('viewBox')).toBe('0 0 16 16')
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(1)
|
||||
expect(svg.querySelector('path')?.getAttribute('d')).toMatch(/^M/)
|
||||
expect(svg.querySelector('path')?.getAttribute('stroke')).toBe('currentColor')
|
||||
expect(view.container.querySelector('animate')).toBeNull()
|
||||
expect(view.container.querySelector('[style]')).toBeNull()
|
||||
})
|
||||
|
||||
it('ships a 28px alpha APNG with sixty 50ms frames and infinite playback', () => {
|
||||
const png = readFileSync(resolve(import.meta.dirname, '../src/client/chat/[email protected]'))
|
||||
expect([...png.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10])
|
||||
const chunks: { type: string; data: Buffer }[] = []
|
||||
for (let offset = 8; offset < png.length;) {
|
||||
const length = png.readUInt32BE(offset)
|
||||
const end = offset + 12 + length
|
||||
expect(end).toBeLessThanOrEqual(png.length)
|
||||
chunks.push({ type: png.toString('ascii', offset + 4, offset + 8), data: png.subarray(offset + 8, end - 4) })
|
||||
offset = end
|
||||
}
|
||||
expect(chunks.at(-1)?.type).toBe('IEND')
|
||||
const headers = chunks.filter(chunk => chunk.type === 'IHDR')
|
||||
expect(headers).toHaveLength(1)
|
||||
const header = headers[0]!.data
|
||||
expect([header.readUInt32BE(0), header.readUInt32BE(4), header[8], header[9]]).toEqual([28, 28, 8, 4])
|
||||
const animations = chunks.filter(chunk => chunk.type === 'acTL')
|
||||
expect(animations).toHaveLength(1)
|
||||
expect([animations[0]!.data.readUInt32BE(0), animations[0]!.data.readUInt32BE(4)]).toEqual([60, 0])
|
||||
const frames = chunks.filter(chunk => chunk.type === 'fcTL')
|
||||
expect(frames).toHaveLength(60)
|
||||
let duration = 0
|
||||
for (const { data } of frames) {
|
||||
expect(data).toHaveLength(26)
|
||||
expect(data.readUInt32BE(4)).toBeGreaterThan(0)
|
||||
expect(data.readUInt32BE(8)).toBeGreaterThan(0)
|
||||
expect(data.readUInt32BE(4) + data.readUInt32BE(12)).toBeLessThanOrEqual(28)
|
||||
expect(data.readUInt32BE(8) + data.readUInt32BE(16)).toBeLessThanOrEqual(28)
|
||||
expect([data.readUInt16BE(20), data.readUInt16BE(22)]).toEqual([1, 20])
|
||||
duration += data.readUInt16BE(20) / data.readUInt16BE(22)
|
||||
}
|
||||
expect(duration).toBeCloseTo(3)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user