// @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 { grammarLoadCount, highlightLines, subscribeGrammarLoaded } 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 `` 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() }) 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() 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( , ) 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( , ) 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() 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() 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() expect(view.queryByText(/显示/u)).toBeNull() }) it('draws an empty label and empty language when neither is given', () => { const view = render() 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() 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() // 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() 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( , ) 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() 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() 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() 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() 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() expect(view.queryByRole('button', { name: '复制' })).toBeNull() }) })