| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237 |
- // @vitest-environment jsdom
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
- import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
- import type { ComponentProps } from 'react'
- import { DEFAULT_READ_MAX_LINES, ReadBlock as LocalizedReadBlock, type ReadBlockLine } from '../src/index.ts'
- import { grammarLoadCount, highlightLines, subscribeGrammarLoaded } from '../src/markdown/highlight.ts'
- import { readBlockLabels } from './labels.client.ts'
- function ReadBlock(props: Omit<ComponentProps<typeof LocalizedReadBlock>, 'labels'>) {
- return <LocalizedReadBlock {...props} labels={readBlockLabels} />
- }
- afterEach(cleanup)
- beforeEach(() => {
- vi.useRealTimers()
- })
- function lines(count: number, first = 1): ReadBlockLine[] {
- return Array.from({ length: count }, (_value, index) => ({ number: first + index, text: `line ${first + index}` }))
- }
- function rowTexts(container: HTMLElement): string[] {
- return [...container.querySelectorAll('[class^="_line_"]')].map(row => row.textContent ?? '')
- }
- 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()
- })
- it('loads a lazy grammar on first use: plain first, highlighted after it registers', async () => {
- // A boot grammar (ts) is ready synchronously; a lazy grammar (python) is
- // not, so the first call renders plain and imports the grammar, and a
- // subscriber fires once it registers, after which the same call highlights.
- let notified = 0
- const stop = subscribeGrammarLoaded(() => { notified += 1 })
- // First touch: grammar not loaded yet, so plain fallback while it imports.
- expect(highlightLines('def f(): pass', 'py')).toBeUndefined()
- // The import + loadLanguageSync resolve on a microtask; wait for the notify.
- await vi.waitFor(() => { expect(notified).toBeGreaterThan(0) })
- expect(grammarLoadCount()).toBeGreaterThan(0)
- const result = highlightLines('def f(): pass', 'py')
- expect(result).not.toBeUndefined()
- // `def` is a python keyword and carries a --shiki-* color once highlighted.
- const keyword = result!.flat().find(span => span.text === 'def')
- expect(keyword?.style?.color).toContain('var(--shiki-')
- stop()
- })
- })
- 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)
- })
- it('hides the copy control for an empty window so it cannot wipe the clipboard', () => {
- // A successful read of an empty file settles to lines: [] with card:'read',
- // so this branch is reachable; copying then would clear the clipboard.
- const view = render(<ReadBlock label="empty.ts" lines={[]} totalLines={0} />)
- expect(view.queryByRole('button', { name: '复制' })).toBeNull()
- })
- })
|