feat(web): render read tool output as a line-numbered code card
Consume the card:'read' result view (path, numbered lines, totalLines, lang) the read backend PR added. ReadBlock (ui-primitives) draws a per-line gutter with each line's own file number, shiki highlighting via a new highlightLines returning per-line token arrays, a 显示 X / Y 行 window note, a height cap matching TerminalBlock, and a copy control. read-card-model is the single resultView derivation; a keyed ReadRow registers under read with the card resident under its path-link summary. The generic fallback and the details panel are read-aware. Fixture gains a windowed read turn for the built-boot snapshot.
This commit is contained in:
215
packages/client/ui-primitives/tests/read-block.spec.tsx
Normal file
215
packages/client/ui-primitives/tests/read-block.spec.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
// @vitest-environment jsdom
|
||||
// ReadBlock + the highlightLines token path: the banner (label, language, the
|
||||
// "showing N of M" note only when the read is a window, copy control), the
|
||||
// gutter-numbered rows keeping the file's own line numbers, the shiki per-line
|
||||
// highlighting resolved to css-variables token spans with an identical-geometry
|
||||
// plain fallback for an unknown/absent language, the head/tail height cap and
|
||||
// its expand control, and the copy control writing the raw window text on both
|
||||
// the accepted and refused clipboard paths.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { DEFAULT_READ_MAX_LINES, ReadBlock, type ReadBlockLine } from '../src/index.ts'
|
||||
import { highlightLines } from '../src/markdown/highlight.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/** `count` lines starting at `first`, each with distinct text. */
|
||||
function lines(count: number, first = 1): ReadBlockLine[] {
|
||||
return Array.from({ length: count }, (_value, index) => ({ number: first + index, text: `line ${first + index}` }))
|
||||
}
|
||||
|
||||
/** The rendered rows as `<gutter><content>` strings (CSS-module class prefix). */
|
||||
function rowTexts(container: HTMLElement): string[] {
|
||||
return [...container.querySelectorAll('[class^="_line_"]')].map(row => row.textContent ?? '')
|
||||
}
|
||||
|
||||
/** The gutter numbers of the rendered rows, in order. */
|
||||
function gutters(container: HTMLElement): string[] {
|
||||
return [...container.querySelectorAll('[class^="_gutter_"]')].map(cell => cell.textContent ?? '')
|
||||
}
|
||||
|
||||
describe('highlightLines', () => {
|
||||
it('tokenizes a registered grammar into per-line css-variables runs', () => {
|
||||
const result = highlightLines('const x = 1\n// c', 'ts')
|
||||
expect(result).not.toBeUndefined()
|
||||
expect(result).toHaveLength(2)
|
||||
// The keyword run carries a color style through a --shiki-* custom property.
|
||||
const keyword = result![0]!.find(span => span.text === 'const')
|
||||
expect(keyword?.style?.color).toContain('var(--shiki-')
|
||||
// Whitespace between tokens is a run of its own; the comment is line two.
|
||||
expect(result![0]!.map(span => span.text).join('')).toBe('const x = 1')
|
||||
expect(result![1]!.map(span => span.text).join('')).toBe('// c')
|
||||
})
|
||||
|
||||
it('colors every run through a --shiki-* custom property', () => {
|
||||
// The css-variables theme colors even the whitespace run (as the foreground
|
||||
// token), so every run is a styled span; the plain fallback is the whole
|
||||
// unknown-language path, not a per-run one.
|
||||
const result = highlightLines('const x = 1', 'ts')
|
||||
for (const span of result!) for (const run of span) expect(run.style.color).toContain('var(--shiki-')
|
||||
})
|
||||
|
||||
it('drops the trailing terminator line so the run count matches the source lines', () => {
|
||||
// `a\n` tokenizes to two lines in shiki (the second empty); the caller's own
|
||||
// line array has one entry, so the terminator line is dropped.
|
||||
const result = highlightLines('const a = 1\n', 'ts')
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps a genuinely blank final line when the source ends in two newlines', () => {
|
||||
const result = highlightLines('a\n\n', 'ts')
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result![1]).toEqual([])
|
||||
})
|
||||
|
||||
it('returns undefined for an unknown or absent language', () => {
|
||||
expect(highlightLines('x', 'cobol')).toBeUndefined()
|
||||
expect(highlightLines('x', undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ReadBlock rows', () => {
|
||||
it('renders one gutter-numbered row per line, keeping the file line numbers', () => {
|
||||
const view = render(<ReadBlock label="a.ts" lines={lines(3, 41)} totalLines={3} />)
|
||||
expect(gutters(view.container)).toEqual(['41', '42', '43'])
|
||||
expect(rowTexts(view.container)).toEqual(['41line 41', '42line 42', '43line 43'])
|
||||
})
|
||||
|
||||
it('highlights the content for a known language into token spans', () => {
|
||||
const view = render(
|
||||
<ReadBlock label="a.ts" lang="ts" lines={[{ number: 1, text: 'const a = 1' }]} totalLines={1} />,
|
||||
)
|
||||
const content = view.container.querySelector('[class^="_content_"]')
|
||||
expect(content?.querySelectorAll('span[style]').length).toBeGreaterThan(1)
|
||||
expect(content?.textContent).toBe('const a = 1')
|
||||
})
|
||||
|
||||
it('renders the content as bare text with no span wrappers for an unknown language', () => {
|
||||
const view = render(
|
||||
<ReadBlock label="a.cob" lang="cobol" lines={[{ number: 1, text: 'IDENT DIVISION.' }]} totalLines={1} />,
|
||||
)
|
||||
const content = view.container.querySelector('[class^="_content_"]')
|
||||
expect(content?.querySelectorAll('span').length).toBe(0)
|
||||
expect(content?.textContent).toBe('IDENT DIVISION.')
|
||||
})
|
||||
|
||||
it('renders bare text when no language is given', () => {
|
||||
const view = render(<ReadBlock label="x" lines={[{ number: 1, text: 'plain' }]} totalLines={1} />)
|
||||
const content = view.container.querySelector('[class^="_content_"]')
|
||||
expect(content?.querySelectorAll('span').length).toBe(0)
|
||||
expect(view.getByText('plain')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ReadBlock banner', () => {
|
||||
it('shows the label, the language, and the count note when the read is a window', () => {
|
||||
const view = render(<ReadBlock label="src/a.ts" lang="ts" lines={lines(3, 41)} totalLines={180} />)
|
||||
expect(view.getByText('src/a.ts')).toBeTruthy()
|
||||
expect(view.getByText('ts')).toBeTruthy()
|
||||
expect(view.getByText('显示 3 / 180 行')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('omits the count note when the window is the whole file', () => {
|
||||
const view = render(<ReadBlock label="a.ts" lines={lines(3)} totalLines={3} />)
|
||||
expect(view.queryByText(/显示/u)).toBeNull()
|
||||
})
|
||||
|
||||
it('draws an empty label and empty language when neither is given', () => {
|
||||
const view = render(<ReadBlock lines={lines(1)} totalLines={1} />)
|
||||
expect(view.container.querySelector('[class^="_label_"]')?.textContent).toBe('')
|
||||
expect(view.container.querySelector('[class^="_lang_"]')?.textContent).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ReadBlock height cap', () => {
|
||||
it('renders every line and no expand control under the cap', () => {
|
||||
const view = render(<ReadBlock label="a" lines={lines(4)} totalLines={4} maxLines={4} />)
|
||||
expect(rowTexts(view.container)).toHaveLength(4)
|
||||
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
|
||||
})
|
||||
|
||||
it('slices head and tail over the cap and expands on click', () => {
|
||||
const view = render(<ReadBlock label="a" lines={lines(10)} totalLines={10} maxLines={4} />)
|
||||
// maxLines 4: head = ceil(4/2) = 2, tail = 4 - 2 = 2, 6 hidden.
|
||||
expect(gutters(view.container)).toEqual(['1', '2', '9', '10'])
|
||||
const toggle = view.getByRole('button', { name: '展开其余 6 行' })
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(toggle.textContent).toBe('… 其余 6 行')
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(rowTexts(view.container)).toHaveLength(10)
|
||||
const collapse = view.getByRole('button', { name: '收起内容' })
|
||||
expect(collapse.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(collapse.textContent).toBe('收起')
|
||||
|
||||
fireEvent.click(collapse)
|
||||
expect(gutters(view.container)).toEqual(['1', '2', '9', '10'])
|
||||
})
|
||||
|
||||
it('renders the head slice alone when the cap leaves no tail', () => {
|
||||
const view = render(<ReadBlock label="a" lines={lines(5)} totalLines={5} maxLines={1} />)
|
||||
expect(gutters(view.container)).toEqual(['1'])
|
||||
expect(view.getByRole('button', { name: '展开其余 4 行' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('caps at the documented default when maxLines is absent', () => {
|
||||
const view = render(
|
||||
<ReadBlock label="a" lines={lines(DEFAULT_READ_MAX_LINES + 1)} totalLines={DEFAULT_READ_MAX_LINES + 1} />,
|
||||
)
|
||||
expect(rowTexts(view.container)).toHaveLength(DEFAULT_READ_MAX_LINES)
|
||||
expect(view.getByRole('button', { name: '展开其余 1 行' })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ReadBlock copy', () => {
|
||||
it('copies the raw window text, joined by newlines, never the gutter numbers', async () => {
|
||||
vi.useFakeTimers()
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
render(<ReadBlock label="a" lines={lines(3, 41)} totalLines={180} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('line 41\nline 42\nline 43')
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
|
||||
// While the ok label is showing, further clicks are no-ops.
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制成功' }))
|
||||
expect(writeText).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('copies the whole window while the height cap hides its middle', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
render(<ReadBlock label="a" lines={lines(10)} totalLines={10} maxLines={4} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith(lines(10).map(line => line.text).join('\n'))
|
||||
expect(await screen.findByRole('button', { name: '复制成功' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not claim success when the host refuses the write', async () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
|
||||
})
|
||||
render(<ReadBlock label="a" lines={lines(1)} totalLines={1} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
|
||||
})
|
||||
|
||||
it('merges className onto the wrapper', () => {
|
||||
const view = render(<ReadBlock className="x" label="a" lines={lines(1)} totalLines={1} />)
|
||||
expect(view.container.firstElementChild?.classList.contains('x')).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user