Merge remote-tracking branch 'origin/master' into worktree/web-remote-markdown-images
# Conflicts: # packages/client/ui-primitives/README.i18n.yaml # packages/client/ui-primitives/README.md # packages/client/ui-primitives/README.zh.md # packages/client/ui-primitives/src/markdown/MarkdownText.tsx # packages/client/ui-primitives/tests/markdown.spec.tsx
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Button, ConnectionBanner, Input, Menu, Modal, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { POINTER_GRACE_MS } from '../src/pointer-grace.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -160,16 +161,77 @@ describe('Menu', () => {
|
||||
expect(onSelect).toHaveBeenCalledWith('del')
|
||||
})
|
||||
|
||||
it('closeOnPointerLeave closes when the pointer leaves the list; default stays open', () => {
|
||||
const onClose = vi.fn()
|
||||
const { rerender } = render(
|
||||
<Menu open closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
||||
fireEvent.pointerLeave(screen.getByRole('menu'))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
rerender(
|
||||
<Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
||||
fireEvent.pointerLeave(screen.getByRole('menu'))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
it('closeOnPointerLeave closes a grace after the pointer leaves trigger and list; default never does', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const onClose = vi.fn()
|
||||
const { rerender } = render(
|
||||
<Menu open closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
||||
const wrapper = screen.getByText('trigger').parentElement as HTMLElement
|
||||
fireEvent.pointerLeave(wrapper)
|
||||
// Still open through the grace: the pointer may be crossing the gap.
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 1) })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
act(() => { vi.advanceTimersByTime(1) })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
rerender(
|
||||
<Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
||||
fireEvent.pointerLeave(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('coming back inside the grace keeps the list open (trigger and list are one region)', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<Menu open closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
||||
const wrapper = screen.getByText('trigger').parentElement as HTMLElement
|
||||
fireEvent.pointerLeave(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 50) })
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('a close from selection disarms the pending grace close', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const onClose = vi.fn()
|
||||
const { rerender } = render(
|
||||
<Menu open closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
||||
const wrapper = screen.getByText('trigger').parentElement as HTMLElement
|
||||
fireEvent.pointerLeave(wrapper)
|
||||
// The owner closes for its own reason (selection/Escape) mid-grace; the
|
||||
// armed timer must not survive to shut a list reopened right after.
|
||||
rerender(
|
||||
<Menu open={false} closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('leaving a closed list arms nothing', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<Menu open={false} closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
||||
fireEvent.pointerLeave(screen.getByText('trigger').parentElement as HTMLElement)
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('a list click does not bubble to the anchor row (portal synthetic-event path)', () => {
|
||||
@@ -324,11 +386,17 @@ describe('Modal', () => {
|
||||
<Modal open={false} onClose={onClose} title="Create new workspace">body</Modal>)
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
rerender(
|
||||
<Modal open onClose={onClose} title="Create new workspace" description="Name it." footer={<button type="button">Create</button>}>
|
||||
<Modal open onClose={onClose} title="Create new workspace" closeLabel="Configure later" description="Name it." contentClassName="scrolling-content" footer={<button type="button">Create</button>}>
|
||||
<input aria-label="name" />
|
||||
</Modal>)
|
||||
expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeDefined()
|
||||
const dialog = screen.getByRole('dialog', { name: 'Create new workspace' })
|
||||
expect(dialog).toBeDefined()
|
||||
// The full-page layer escapes caller stacking contexts but remains in
|
||||
// this document/current WebUI window.
|
||||
expect(dialog.parentElement?.parentElement).toBe(document.body)
|
||||
expect(screen.getByRole('button', { name: 'Configure later' })).toBeDefined()
|
||||
expect(screen.getByText('Name it.')).toBeDefined()
|
||||
expect(screen.getByText('Name it.').parentElement?.className).toContain('scrolling-content')
|
||||
fireEvent.keyDown(document, { key: 'a' })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
@@ -31,6 +31,24 @@ describe('highlightToHtml', () => {
|
||||
expect(highlightToHtml('x', 'cobol')).toBeUndefined()
|
||||
expect(highlightToHtml('x', undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
// Every read-tool language hint whose grammar loads lazily (the boot set —
|
||||
// ts/js/bash/sh/json — is covered above). Touching each one drives its own
|
||||
// dynamic import thunk, so the whole LAZY_GRAMMARS table is exercised.
|
||||
const LAZY_ALIASES = [
|
||||
'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'cs', 'kotlin', 'swift', 'php',
|
||||
'yaml', 'toml', 'ini', 'md', 'mdx', 'html', 'css', 'scss', 'less', 'sql',
|
||||
'xml', 'lua',
|
||||
]
|
||||
|
||||
it('lazily loads every read-card grammar: plain first, highlighted after load', async () => {
|
||||
// First touch returns the plain fallback (undefined) and starts the import.
|
||||
for (const alias of LAZY_ALIASES) expect(highlightToHtml('x', alias)).toBeUndefined()
|
||||
// Once every grammar has registered, the same call highlights.
|
||||
await vi.waitFor(() => {
|
||||
for (const alias of LAZY_ALIASES) expect(highlightToHtml('x', alias)).toContain('shiki')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('CodeBlock', () => {
|
||||
|
||||
182
packages/client/ui-primitives/tests/diff-block.spec.tsx
Normal file
182
packages/client/ui-primitives/tests/diff-block.spec.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
// @vitest-environment jsdom
|
||||
// DiffBlock: the per-file hunk rows (path header, removed block, added block),
|
||||
// the same-file second-hunk gap separator, the `+A -R · N file(s)` footer and
|
||||
// its singular/plural, the head/tail height cap and its expand control, the
|
||||
// empty-diffs null render, and the copy control writing the prefixed diff text
|
||||
// on both the accepted and the refused clipboard paths. writeClipboard's own
|
||||
// return contract is pinned in terminal-block.spec.tsx (the shared seam), so
|
||||
// only its DOM consequence is asserted here.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { DEFAULT_DIFF_MAX_LINES, DiffBlock, type DiffHunk } from '../src/index.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/** The rendered body rows, one string per visible line (CSS-module class prefix). */
|
||||
function bodyRows(container: HTMLElement): string[] {
|
||||
return [...container.querySelectorAll('[class*="_line_"]')].map(row => row.textContent ?? '')
|
||||
}
|
||||
|
||||
/** Only the changed rows (add/del), excluding the path header and gap chrome. */
|
||||
function changeRows(container: HTMLElement): string[] {
|
||||
return [...container.querySelectorAll('[class*="_del_"], [class*="_add_"]')].map(row => row.textContent ?? '')
|
||||
}
|
||||
|
||||
/** `count` numbered added lines as one hunk's newText. */
|
||||
function added(count: number): string {
|
||||
return Array.from({ length: count }, (_v, i) => `line ${i + 1}`).join('\n')
|
||||
}
|
||||
|
||||
describe('DiffBlock structure', () => {
|
||||
it('renders a create as a path header and an added block (no removed side)', () => {
|
||||
const diffs: DiffHunk[] = [{ path: 'notes/new.txt', oldText: null, newText: 'hello\nworld' }]
|
||||
const { container } = render(<DiffBlock diffs={diffs} />)
|
||||
expect(screen.getByText('notes/new.txt')).toBeTruthy()
|
||||
// No removed rows: both change lines are added.
|
||||
expect(changeRows(container)).toEqual(['hello', 'world'])
|
||||
expect(container.querySelectorAll('[class*="_del_"]').length).toBe(0)
|
||||
expect(container.querySelectorAll('[class*="_add_"]').length).toBe(2)
|
||||
})
|
||||
|
||||
it('renders an edit as a removed block above an added block', () => {
|
||||
const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: 'old', newText: 'new' }]
|
||||
const { container } = render(<DiffBlock diffs={diffs} />)
|
||||
expect(container.querySelectorAll('[class*="_del_"]').length).toBe(1)
|
||||
expect(container.querySelectorAll('[class*="_add_"]').length).toBe(1)
|
||||
expect(changeRows(container)).toEqual(['old', 'new'])
|
||||
})
|
||||
|
||||
it('opens a same-file second hunk with a gap instead of repeating the path', () => {
|
||||
const diffs: DiffHunk[] = [
|
||||
{ path: 'a.ts', oldText: 'x', newText: 'y' },
|
||||
{ path: 'a.ts', oldText: 'p', newText: 'q' },
|
||||
]
|
||||
const { container } = render(<DiffBlock diffs={diffs} />)
|
||||
// One path header, one gap row.
|
||||
expect(container.querySelectorAll('[class*="_path_"]').length).toBe(1)
|
||||
expect(container.querySelectorAll('[class*="_gap_"]').length).toBe(1)
|
||||
})
|
||||
|
||||
it('opens a new file with its own path header', () => {
|
||||
const diffs: DiffHunk[] = [
|
||||
{ path: 'a.ts', oldText: 'x', newText: 'y' },
|
||||
{ path: 'b.ts', oldText: 'p', newText: 'q' },
|
||||
]
|
||||
const { container } = render(<DiffBlock diffs={diffs} />)
|
||||
expect(container.querySelectorAll('[class*="_path_"]').length).toBe(2)
|
||||
expect(container.querySelectorAll('[class*="_gap_"]').length).toBe(0)
|
||||
})
|
||||
|
||||
it('renders nothing for empty diffs', () => {
|
||||
const { container } = render(<DiffBlock diffs={[]} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('treats a trailing newline as a terminator, not an extra blank line', () => {
|
||||
// A create whose newText ends in a newline is one added line, not two, and
|
||||
// the footer counts one — the phantom `+ ` empty line the naive split drew.
|
||||
const { container } = render(<DiffBlock diffs={[{ path: 'n.txt', oldText: null, newText: 'hello\n' }]} />)
|
||||
expect(changeRows(container)).toEqual(['hello'])
|
||||
expect(screen.getByText('└ +1 -0 · 1 file')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders a full deletion as removed-only with no phantom added line', () => {
|
||||
// newText '' is zero added lines: an empty string must contribute nothing.
|
||||
const { container } = render(<DiffBlock diffs={[{ path: 'gone.ts', oldText: 'a\nb', newText: '' }]} />)
|
||||
expect(container.querySelectorAll('[class*="_add_"]').length).toBe(0)
|
||||
expect(screen.getByText('└ +0 -2 · 1 file')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps a genuine interior blank line', () => {
|
||||
const { container } = render(<DiffBlock diffs={[{ path: 'a.ts', oldText: null, newText: 'x\n\ny' }]} />)
|
||||
expect(container.querySelectorAll('[class*="_add_"]').length).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DiffBlock footer', () => {
|
||||
it('counts added and removed lines and one file', () => {
|
||||
const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: 'a\nb', newText: 'c' }]
|
||||
render(<DiffBlock diffs={diffs} />)
|
||||
expect(screen.getByText('└ +1 -2 · 1 file')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('pluralizes the distinct-file count', () => {
|
||||
const diffs: DiffHunk[] = [
|
||||
{ path: 'a.ts', oldText: null, newText: 'x' },
|
||||
{ path: 'b.ts', oldText: null, newText: 'y' },
|
||||
]
|
||||
render(<DiffBlock diffs={diffs} />)
|
||||
expect(screen.getByText('└ +2 -0 · 2 files')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DiffBlock height cap', () => {
|
||||
it('shows head and tail with an expand control past the cap, then all lines expanded', () => {
|
||||
// One added line over the default cap forces the collapse.
|
||||
const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: null, newText: added(DEFAULT_DIFF_MAX_LINES) }]
|
||||
// The path header counts as a row, so a body of maxLines added lines plus
|
||||
// the header is one over the cap.
|
||||
const { container } = render(<DiffBlock diffs={diffs} />)
|
||||
const toggle = screen.getByRole('button', { name: /展开其余/ })
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
// Collapsed shows fewer rows than the full body.
|
||||
const collapsedCount = bodyRows(container).length
|
||||
expect(collapsedCount).toBeLessThan(DEFAULT_DIFF_MAX_LINES + 1)
|
||||
fireEvent.click(toggle)
|
||||
expect(screen.getByRole('button', { name: '收起差异' }).getAttribute('aria-expanded')).toBe('true')
|
||||
expect(bodyRows(container).length).toBeGreaterThan(collapsedCount)
|
||||
})
|
||||
|
||||
it('shows no expand control at or under the cap', () => {
|
||||
const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: null, newText: added(4) }]
|
||||
render(<DiffBlock diffs={diffs} maxLines={16} />)
|
||||
expect(screen.queryByRole('button', { name: /展开其余|收起差异/ })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DiffBlock copy', () => {
|
||||
it('copies the prefixed diff text and flips the label on success', async () => {
|
||||
vi.useFakeTimers()
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
const diffs: DiffHunk[] = [
|
||||
{ path: 'a.ts', oldText: 'old', newText: 'new' },
|
||||
{ path: 'a.ts', oldText: 'p', newText: 'q' },
|
||||
]
|
||||
render(<DiffBlock diffs={diffs} />)
|
||||
const copy = screen.getByRole('button', { name: '复制' })
|
||||
await act(async () => { fireEvent.click(copy) })
|
||||
// Path header, del/add prefixes, and the same-file gap all reach the clipboard.
|
||||
expect(writeText).toHaveBeenCalledWith('a.ts\n- old\n+ new\n⋯\n- p\n+ q')
|
||||
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(1000) })
|
||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the label on a refused clipboard write', async () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
|
||||
})
|
||||
render(<DiffBlock diffs={[{ path: 'a.ts', oldText: null, newText: 'x' }]} />)
|
||||
const copy = screen.getByRole('button', { name: '复制' })
|
||||
await act(async () => { fireEvent.click(copy) })
|
||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('ignores a second click while the copied label is showing', async () => {
|
||||
vi.useFakeTimers()
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
render(<DiffBlock diffs={[{ path: 'a.ts', oldText: null, newText: 'x' }]} />)
|
||||
const copy = screen.getByRole('button', { name: '复制' })
|
||||
await act(async () => { fireEvent.click(copy) })
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制成功' })) })
|
||||
expect(writeText).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { HoverCard } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { POINTER_GRACE_MS } from '../src/pointer-grace.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
beforeEach(() => { vi.useFakeTimers() })
|
||||
@@ -16,7 +17,13 @@ function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number
|
||||
})
|
||||
}
|
||||
|
||||
function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) {
|
||||
function mount(props: {
|
||||
openDelayMs?: number
|
||||
disabled?: boolean
|
||||
copyText?: string
|
||||
copyLabel?: string
|
||||
copiedLabel?: string
|
||||
} = {}) {
|
||||
const view = render(
|
||||
<HoverCard anchor={<span>row</span>} content={<div>card body</div>} {...props} />,
|
||||
)
|
||||
@@ -25,6 +32,19 @@ function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) {
|
||||
return { view, anchor, wrapper: anchor.parentElement as HTMLElement }
|
||||
}
|
||||
|
||||
/** Install the async browser clipboard and restore its prior host shape. */
|
||||
function installClipboard(writeText: (text: string) => Promise<void>): () => void {
|
||||
const prior = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
return () => {
|
||||
if (prior === undefined) Reflect.deleteProperty(navigator, 'clipboard')
|
||||
else Object.defineProperty(navigator, 'clipboard', prior)
|
||||
}
|
||||
}
|
||||
|
||||
describe('HoverCard', () => {
|
||||
it('opens after the dwell delay, positioned right of the anchor', () => {
|
||||
const { wrapper } = mount()
|
||||
@@ -54,18 +74,47 @@ describe('HoverCard', () => {
|
||||
expect(screen.queryByText('card body')).toBeNull()
|
||||
})
|
||||
|
||||
it('pointerleave closes an open card immediately; re-enter restarts the dwell', () => {
|
||||
it('pointerleave closes an open card a grace later; re-enter after that restarts the dwell', () => {
|
||||
const { wrapper } = mount()
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.getByText('card body')).toBeTruthy()
|
||||
fireEvent.pointerLeave(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 1) })
|
||||
expect(screen.getByText('card body')).toBeTruthy()
|
||||
act(() => { vi.advanceTimersByTime(1) })
|
||||
expect(screen.queryByText('card body')).toBeNull()
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.getByText('card body')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('reaching the card inside the grace keeps it open without restarting the dwell', () => {
|
||||
// The portaled card is a React child of the wrapper, so the pointer
|
||||
// arriving on it re-enters the wrapper — the gesture the 8px anchor gap
|
||||
// used to make impossible.
|
||||
const { wrapper } = mount()
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
fireEvent.pointerLeave(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 50) })
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) })
|
||||
expect(screen.getByText('card body')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('re-entering while open does not queue a second dwell', () => {
|
||||
const { wrapper } = mount()
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
fireEvent.pointerLeave(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
|
||||
// A dwell restarted by the redundant enter would reopen the card here.
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.queryByText('card body')).toBeNull()
|
||||
})
|
||||
|
||||
it('a press inside the anchor dismisses the card without waiting for disabled', () => {
|
||||
const { wrapper } = mount()
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
@@ -78,6 +127,237 @@ describe('HoverCard', () => {
|
||||
expect(screen.queryByText('card body')).toBeNull()
|
||||
})
|
||||
|
||||
it('a press on the card starts a selection instead of dismissing it', () => {
|
||||
// The card is a React child of the wrapper, so capture-phase presses on
|
||||
// it reach the wrapper's dismissal handler too; they must not close it,
|
||||
// or the first pointerdown of a text-selection drag would kill the card.
|
||||
const { wrapper } = mount()
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
fireEvent.pointerDown(screen.getByText('card body'))
|
||||
// Still mounted after a grace's worth of time: no close was armed either.
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
|
||||
expect(screen.getByText('card body')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps a completed card selection instead of treating its click as copy', async () => {
|
||||
const writeText = vi.fn(async () => {})
|
||||
const restoreClipboard = installClipboard(writeText)
|
||||
const selection = window.getSelection()
|
||||
if (selection === null) throw new Error('jsdom selection API unavailable')
|
||||
try {
|
||||
const { wrapper } = mount({ copyText: 'card body', copyLabel: 'Copy' })
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
const card = screen.getByRole('button', { name: 'Copy: card body' })
|
||||
const selectedText = screen.getByText('card body')
|
||||
const cardRange = document.createRange()
|
||||
cardRange.selectNodeContents(selectedText)
|
||||
selection.addRange(cardRange)
|
||||
await act(async () => { fireEvent.click(card) })
|
||||
expect(writeText).not.toHaveBeenCalled()
|
||||
expect(selection.toString()).toBe('card body')
|
||||
expect(screen.getByText('card body')).toBeTruthy()
|
||||
|
||||
// Firefox supports multiple selection ranges: any range intersecting
|
||||
// this card wins, not only the first.
|
||||
selection.removeAllRanges()
|
||||
const getSelection = vi.spyOn(window, 'getSelection').mockReturnValue({
|
||||
isCollapsed: false,
|
||||
rangeCount: 2,
|
||||
getRangeAt: vi.fn((index: number) => ({
|
||||
intersectsNode: () => index === 1,
|
||||
})),
|
||||
} as unknown as Selection)
|
||||
await act(async () => { fireEvent.click(card) })
|
||||
expect(writeText).not.toHaveBeenCalled()
|
||||
getSelection.mockRestore()
|
||||
|
||||
// A non-collapsed selection elsewhere does not block this card.
|
||||
const anchorRange = document.createRange()
|
||||
anchorRange.selectNodeContents(screen.getByText('row'))
|
||||
selection.addRange(anchorRange)
|
||||
await act(async () => { fireEvent.click(card) })
|
||||
expect(writeText).toHaveBeenCalledWith('card body')
|
||||
} finally {
|
||||
selection.removeAllRanges()
|
||||
restoreClipboard()
|
||||
}
|
||||
})
|
||||
|
||||
it('a press while closed leaves the card closed', () => {
|
||||
mount()
|
||||
fireEvent.pointerDown(screen.getByText('row'))
|
||||
act(() => { vi.advanceTimersByTime(1000) })
|
||||
expect(screen.queryByText('card body')).toBeNull()
|
||||
})
|
||||
|
||||
it('copies its configured value and shows success only for the feedback window', async () => {
|
||||
const writeText = vi.fn(async () => {})
|
||||
const restoreClipboard = installClipboard(writeText)
|
||||
try {
|
||||
const { wrapper } = mount({
|
||||
copyText: '/full/path',
|
||||
copyLabel: 'Copy path',
|
||||
copiedLabel: 'Copied',
|
||||
})
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
const card = screen.getByRole('button', { name: 'Copy path: /full/path' })
|
||||
const status = screen.getByRole('status')
|
||||
expect(status.textContent).toBe('')
|
||||
expect(card.contains(status)).toBe(false)
|
||||
Object.defineProperty(card, 'offsetHeight', { configurable: true, value: 96 })
|
||||
await act(async () => { fireEvent.click(card) })
|
||||
expect(writeText).toHaveBeenCalledWith('/full/path')
|
||||
expect(status.textContent).toBe('Copied')
|
||||
expect(screen.getByRole('button', { name: 'Copy path: /full/path' })).toBe(card)
|
||||
expect(card.style.minHeight).toBe('96px')
|
||||
// Repeated activation while feedback is visible neither rewrites nor
|
||||
// extends the one-second success window.
|
||||
await act(async () => { fireEvent.click(card) })
|
||||
expect(writeText).toHaveBeenCalledOnce()
|
||||
act(() => { vi.advanceTimersByTime(999) })
|
||||
expect(status.textContent).toBe('Copied')
|
||||
act(() => { vi.advanceTimersByTime(1) })
|
||||
expect(screen.getByRole('button', { name: 'Copy path: /full/path' })).toBe(card)
|
||||
expect(card.style.minHeight).toBe('')
|
||||
expect(status.textContent).toBe('')
|
||||
expect(screen.getByText('card body')).toBeTruthy()
|
||||
} finally {
|
||||
restoreClipboard()
|
||||
}
|
||||
})
|
||||
|
||||
it('supports button keys and ignores unrelated keys', async () => {
|
||||
const writeText = vi.fn(async () => {})
|
||||
const restoreClipboard = installClipboard(writeText)
|
||||
try {
|
||||
const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' })
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
const card = screen.getByRole('button')
|
||||
fireEvent.keyDown(card, { key: 'Escape' })
|
||||
expect(writeText).not.toHaveBeenCalled()
|
||||
await act(async () => { fireEvent.keyDown(card, { key: 'Enter' }) })
|
||||
expect(writeText).toHaveBeenCalledOnce()
|
||||
act(() => { vi.advanceTimersByTime(1000) })
|
||||
await act(async () => { fireEvent.keyDown(card, { key: ' ' }) })
|
||||
expect(writeText).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
restoreClipboard()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps its content when the clipboard rejects the write', async () => {
|
||||
const writeText = vi.fn(async () => { throw new Error('denied') })
|
||||
const restoreClipboard = installClipboard(writeText)
|
||||
try {
|
||||
const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' })
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button')) })
|
||||
expect(screen.queryByText('Copied')).toBeNull()
|
||||
expect(screen.getByText('card body')).toBeTruthy()
|
||||
} finally {
|
||||
restoreClipboard()
|
||||
}
|
||||
})
|
||||
|
||||
it('unmount clears copied feedback', async () => {
|
||||
const writeText = vi.fn(async () => {})
|
||||
const restoreClipboard = installClipboard(writeText)
|
||||
try {
|
||||
const { view, wrapper } = mount({ copyText: 'value' })
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button')) })
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
view.unmount()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
} finally {
|
||||
restoreClipboard()
|
||||
}
|
||||
})
|
||||
|
||||
it('clears copied feedback when the card closes', async () => {
|
||||
const writeText = vi.fn(async () => {})
|
||||
const restoreClipboard = installClipboard(writeText)
|
||||
try {
|
||||
const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' })
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button')) })
|
||||
expect(screen.getByRole('status').textContent).toBe('Copied')
|
||||
fireEvent.pointerLeave(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
|
||||
expect(screen.queryByText('Copied')).toBeNull()
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.getByText('card body')).toBeTruthy()
|
||||
} finally {
|
||||
restoreClipboard()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not create copied feedback after an in-flight write unmounts', async () => {
|
||||
let acceptWrite: (() => void) | undefined
|
||||
const writeText = vi.fn(() => new Promise<void>((resolve) => { acceptWrite = resolve }))
|
||||
const restoreClipboard = installClipboard(writeText)
|
||||
try {
|
||||
const { view, wrapper } = mount({ copyText: 'value' })
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(writeText).toHaveBeenCalledOnce()
|
||||
view.unmount()
|
||||
await act(async () => { acceptWrite?.() })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
} finally {
|
||||
restoreClipboard()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not restore copied feedback after an in-flight card closes', async () => {
|
||||
let acceptWrite: (() => void) | undefined
|
||||
const writeText = vi.fn(() => new Promise<void>((resolve) => { acceptWrite = resolve }))
|
||||
const restoreClipboard = installClipboard(writeText)
|
||||
try {
|
||||
const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' })
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
fireEvent.pointerLeave(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
await act(async () => { acceptWrite?.() })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(screen.getByText('card body')).toBeTruthy()
|
||||
} finally {
|
||||
restoreClipboard()
|
||||
}
|
||||
})
|
||||
|
||||
it('coalesces activations while the clipboard write is in flight', async () => {
|
||||
let acceptWrite: (() => void) | undefined
|
||||
const writeText = vi.fn(() => new Promise<void>((resolve) => { acceptWrite = resolve }))
|
||||
const restoreClipboard = installClipboard(writeText)
|
||||
try {
|
||||
const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' })
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
const card = screen.getByRole('button')
|
||||
fireEvent.click(card)
|
||||
fireEvent.click(card)
|
||||
expect(writeText).toHaveBeenCalledOnce()
|
||||
await act(async () => { acceptWrite?.() })
|
||||
expect(screen.getByRole('status').textContent).toBe('Copied')
|
||||
} finally {
|
||||
restoreClipboard()
|
||||
}
|
||||
})
|
||||
|
||||
it('disabled suppresses opening entirely', () => {
|
||||
const { wrapper } = mount({ disabled: true })
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
@@ -135,6 +415,7 @@ describe('HoverCard', () => {
|
||||
expect(card.style.left).toBe('308px')
|
||||
expect(card.style.top).toBe('90px')
|
||||
fireEvent.pointerLeave(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
|
||||
expect(screen.queryByText('card body')).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconApiOutline14, IconFolderClose16, IconSendOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconApiOutline14, IconArchiveOutline20, IconFolderClose16, IconSendOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -14,8 +14,8 @@ const icons = Object.fromEntries(
|
||||
const iconNames = Object.keys(icons)
|
||||
|
||||
describe('ic_ds_ icon set', () => {
|
||||
it('exports the full P-I set (45 deepsuite + 14 figma extracts + the hand-authored sparkle)', () => {
|
||||
expect(iconNames.length).toBe(60)
|
||||
it('exports the full P-I set (45 deepsuite + 15 figma extracts + the hand-authored sparkle)', () => {
|
||||
expect(iconNames.length).toBe(61)
|
||||
})
|
||||
|
||||
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {
|
||||
@@ -36,11 +36,13 @@ describe('ic_ds_ icon set', () => {
|
||||
expect(svg.classList.contains('x')).toBe(true)
|
||||
})
|
||||
|
||||
it('native defaults: 14-glyphs default 14, 16-glyphs default 16', () => {
|
||||
it('each glyph defaults to its own drawn size, not one set-wide default', () => {
|
||||
const api = render(<IconApiOutline14 />)
|
||||
expect(api.container.querySelector('svg')!.getAttribute('width')).toBe('14')
|
||||
const folder = render(<IconFolderClose16 />)
|
||||
expect(folder.container.querySelector('svg')!.getAttribute('width')).toBe('16')
|
||||
const archive = render(<IconArchiveOutline20 />)
|
||||
expect(archive.container.querySelector('svg')!.getAttribute('width')).toBe('20')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -94,6 +94,11 @@ describe('MarkdownText', () => {
|
||||
expect(done.container.querySelector('pre.shiki')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('forwards localized labels to fenced code blocks', () => {
|
||||
render(<MarkdownText text={'```ts\nconst answer = 42\n```'} codeLabels={{ copyLabel: 'Copy code', copiedLabel: 'Copied' }} />)
|
||||
expect(screen.getByRole('button', { name: 'Copy code' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders absolute HTTP(S) images with bounded presentation', () => {
|
||||
const markdown = [
|
||||
'',
|
||||
@@ -147,6 +152,39 @@ describe('MarkdownText', () => {
|
||||
expect(container.querySelectorAll('li')).toHaveLength(2)
|
||||
expect(screen.getByText('**unfinished')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders inline and display TeX through KaTeX without enabling trusted commands', () => {
|
||||
const source = [
|
||||
'Einstein wrote $E = mc^2$.',
|
||||
'',
|
||||
'$$',
|
||||
'\\frac{\\partial \\mathbf{u}}{\\partial t} + (\\mathbf{u} \\cdot \\nabla)\\mathbf{u} = -\\frac{1}{\\rho}\\nabla p',
|
||||
'$$',
|
||||
'',
|
||||
'$\\href{javascript:alert(1)}{unsafe}$',
|
||||
].join('\n')
|
||||
const { container } = render(<MarkdownText text={source} />)
|
||||
|
||||
expect(container.querySelectorAll('.katex')).toHaveLength(3)
|
||||
expect(container.querySelectorAll('.katex-display')).toHaveLength(1)
|
||||
expect(container.querySelector('.katex-display annotation')?.textContent).toContain('\\frac{\\partial \\mathbf{u}}')
|
||||
expect(container.querySelector('a')).toBeNull()
|
||||
})
|
||||
|
||||
it('defers TeX rendering while streaming so incomplete formulas never flash KaTeX errors', () => {
|
||||
const partial = '$$\n\\frac{\\partial \\mathbf{u}}{\\partial'
|
||||
const complete = '$$\n\\frac{\\partial \\mathbf{u}}{\\partial t}\n$$'
|
||||
const live = render(<MarkdownText text={partial} streaming />)
|
||||
|
||||
expect(live.container.querySelector('.katex')).toBeNull()
|
||||
expect(live.container.querySelector('.katex-error')).toBeNull()
|
||||
expect(live.container.textContent).toContain('\\frac{\\partial \\mathbf{u}}{\\partial')
|
||||
|
||||
live.rerender(<MarkdownText text={complete} />)
|
||||
expect(live.container.querySelectorAll('.katex')).toHaveLength(1)
|
||||
expect(live.container.querySelectorAll('.katex-display')).toHaveLength(1)
|
||||
expect(live.container.querySelector('.katex-error')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('JsonBlock', () => {
|
||||
|
||||
241
packages/client/ui-primitives/tests/read-block.spec.tsx
Normal file
241
packages/client/ui-primitives/tests/read-block.spec.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
// @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 `<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()
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
214
packages/client/ui-primitives/tests/search-block.spec.tsx
Normal file
214
packages/client/ui-primitives/tests/search-block.spec.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
// @vitest-environment jsdom
|
||||
// SearchBlock: both kinds (grouped grep matches and a flat glob path list), the
|
||||
// folded truncation summary, the empty arm, per-file collapse/expand, the
|
||||
// head/tail height cap and its expand control, the tail slice restoring its
|
||||
// owning file header, and the copy control writing the whole structured
|
||||
// result 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_SEARCH_MAX_LINES, SearchBlock } from '../src/index.ts'
|
||||
import type { SearchFileGroup } from '../src/index.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/** The rendered result rows, one string per visible row (CSS-module class prefix). */
|
||||
function lines(container: HTMLElement): string[] {
|
||||
return [...container.querySelectorAll('[class^="_line_"]')].map(row => row.textContent ?? '')
|
||||
}
|
||||
|
||||
/** The file-group header rows, one string per header (path + count concatenated). */
|
||||
function fileHeaders(container: HTMLElement): string[] {
|
||||
return [...container.querySelectorAll('[class^="_fileHeader_"]')].map(row => row.textContent ?? '')
|
||||
}
|
||||
|
||||
/** `count` numbered match lines under one file, without a terminating newline. */
|
||||
function group(path: string, count: number, from = 1): SearchFileGroup {
|
||||
return {
|
||||
path,
|
||||
matches: Array.from({ length: count }, (_v, i) => ({ lineNumber: from + i, line: `hit ${from + i}` })),
|
||||
}
|
||||
}
|
||||
|
||||
describe('SearchBlock matches kind', () => {
|
||||
it('renders each file as a header group with its matched lines', () => {
|
||||
const view = render(<SearchBlock kind="matches" truncated={false} total={3} files={[
|
||||
{ path: 'a.ts', matches: [{ lineNumber: 12, line: 'const a = 1' }, { lineNumber: 40, line: 'return a' }] },
|
||||
{ path: 'b.ts', matches: [{ lineNumber: 7, line: 'const b = 2' }] },
|
||||
]} />)
|
||||
expect(fileHeaders(view.container)).toEqual(['a.ts2', 'b.ts1'])
|
||||
expect(lines(view.container)).toEqual(['12: const a = 1', '40: return a', '7: const b = 2'])
|
||||
// The summary counts matches and files, with no folded pre-cap total below the cap.
|
||||
expect(view.getByText('3 处匹配 · 2 个文件')).toBeTruthy()
|
||||
expect(view.queryByText(/显示|共/u)).toBeNull()
|
||||
})
|
||||
|
||||
it('collapses and re-expands a single file group without touching the others', () => {
|
||||
const view = render(<SearchBlock kind="matches" truncated={false} total={3} files={[
|
||||
{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'x' }] },
|
||||
{ path: 'b.ts', matches: [{ lineNumber: 2, line: 'y' }] },
|
||||
]} />)
|
||||
const [headerA] = view.container.querySelectorAll('[class^="_fileHeader_"]')
|
||||
expect(headerA!.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.click(headerA!)
|
||||
// a.ts collapsed: its match row is gone, b.ts's stays.
|
||||
expect(headerA!.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(lines(view.container)).toEqual(['2: y'])
|
||||
fireEvent.click(headerA!)
|
||||
expect(lines(view.container)).toEqual(['1: x', '2: y'])
|
||||
})
|
||||
|
||||
it('folds the pre-cap total into the summary when truncated', () => {
|
||||
const view = render(<SearchBlock kind="matches" truncated total={99} files={[group('a.ts', 2)]} />)
|
||||
expect(view.getByText('显示 2 / 共 99 处匹配 · 1 个文件')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SearchBlock paths kind', () => {
|
||||
it('renders a flat path list with a path-count summary', () => {
|
||||
const view = render(<SearchBlock kind="paths" truncated={false} total={2} paths={['src/a.ts', 'src/b.ts']} />)
|
||||
expect(lines(view.container)).toEqual(['src/a.ts', 'src/b.ts'])
|
||||
expect(view.getByText('2 个路径')).toBeTruthy()
|
||||
// No file-group headers in the paths shape.
|
||||
expect(fileHeaders(view.container)).toEqual([])
|
||||
})
|
||||
|
||||
it('folds the pre-cap total into the paths summary when truncated', () => {
|
||||
const view = render(<SearchBlock kind="paths" truncated total={50} paths={['a', 'b']} />)
|
||||
expect(view.getByText('显示 2 / 共 50 个路径')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SearchBlock empty arm', () => {
|
||||
it('shows the placeholder and no copy control for an empty matches result', () => {
|
||||
const view = render(<SearchBlock kind="matches" truncated={false} total={0} files={[]} />)
|
||||
expect(view.getByText('无结果')).toBeTruthy()
|
||||
expect(view.queryByText('复制')).toBeNull()
|
||||
expect(view.getByText('0 处匹配 · 0 个文件')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the placeholder for an empty paths result', () => {
|
||||
const view = render(<SearchBlock kind="paths" truncated={false} total={0} paths={[]} />)
|
||||
expect(view.getByText('无结果')).toBeTruthy()
|
||||
expect(view.queryByText('复制')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SearchBlock height cap', () => {
|
||||
it('renders every row and no expand control under the cap', () => {
|
||||
const view = render(<SearchBlock kind="paths" truncated={false} total={4}
|
||||
paths={['a', 'b', 'c', 'd']} maxLines={4} />)
|
||||
expect(lines(view.container)).toHaveLength(4)
|
||||
expect(view.container.querySelector('[aria-label^="展开"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('slices head and tail over the cap and expands on click', () => {
|
||||
const paths = Array.from({ length: 10 }, (_v, i) => `p${i + 1}`)
|
||||
const view = render(<SearchBlock kind="paths" truncated={false} total={10} paths={paths} maxLines={4} />)
|
||||
// maxLines 4: head = ceil(4/2) = 2, tail = 2, 6 hidden.
|
||||
expect(lines(view.container)).toEqual(['p1', 'p2', 'p9', 'p10'])
|
||||
const toggle = view.getByRole('button', { name: '展开其余 6 行结果' })
|
||||
expect(toggle.textContent).toBe('… 其余 6 行')
|
||||
fireEvent.click(toggle)
|
||||
expect(lines(view.container)).toHaveLength(10)
|
||||
const collapse = view.getByRole('button', { name: '收起结果' })
|
||||
expect(collapse.textContent).toBe('收起')
|
||||
fireEvent.click(collapse)
|
||||
expect(lines(view.container)).toEqual(['p1', 'p2', 'p9', 'p10'])
|
||||
})
|
||||
|
||||
it('counts a file header as one capped row alongside its matches', () => {
|
||||
// One file with 10 matches → 11 rows (header + 10). Cap 4: head 2, tail 2.
|
||||
const view = render(<SearchBlock kind="matches" truncated={false} total={10}
|
||||
files={[group('a.ts', 10)]} maxLines={4} />)
|
||||
// Head takes the header then the first match; tail takes the last two matches.
|
||||
expect(lines(view.container)).toEqual(['1: hit 1', '9: hit 9', '10: hit 10'])
|
||||
expect(fileHeaders(view.container)).toEqual(['a.ts10'])
|
||||
expect(view.getByRole('button', { name: '展开其余 7 行结果' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders the head slice alone when the cap leaves no tail', () => {
|
||||
const view = render(<SearchBlock kind="paths" truncated={false} total={5}
|
||||
paths={['a', 'b', 'c', 'd', 'e']} maxLines={1} />)
|
||||
expect(lines(view.container)).toEqual(['a'])
|
||||
expect(view.getByRole('button', { name: '展开其余 4 行结果' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('restores the owning file header above a tail slice that begins mid-file', () => {
|
||||
// Two files of 10 matches each → 22 rows. Cap 8: head 4 (a.ts header + 3
|
||||
// matches), tail 4. The tail begins mid-b.ts, so its header is restored —
|
||||
// and, being a row itself, it consumes one tail slot rather than pushing the
|
||||
// card to 9 rows: the tail keeps its last 3 matches, total visible = 8.
|
||||
const view = render(<SearchBlock kind="matches" truncated={false} total={20} maxLines={8} files={[
|
||||
group('a.ts', 10), group('b.ts', 10, 11),
|
||||
]} />)
|
||||
expect(fileHeaders(view.container)).toEqual(['a.ts10', 'b.ts10'])
|
||||
expect(lines(view.container)).toEqual([
|
||||
'1: hit 1', '2: hit 2', '3: hit 3',
|
||||
'18: hit 18', '19: hit 19', '20: hit 20',
|
||||
])
|
||||
// Visible rows hold at maxLines (2 headers + 6 matches = 8), so the hidden
|
||||
// count stays exact: 22 − 8 = 14.
|
||||
expect(view.getByRole('button', { name: '展开其余 14 行结果' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('caps at the documented default when maxLines is absent', () => {
|
||||
const paths = Array.from({ length: DEFAULT_SEARCH_MAX_LINES + 1 }, (_v, i) => `p${i}`)
|
||||
const view = render(<SearchBlock kind="paths" truncated={false} total={paths.length} paths={paths} />)
|
||||
expect(lines(view.container)).toHaveLength(DEFAULT_SEARCH_MAX_LINES)
|
||||
expect(view.getByRole('button', { name: '展开其余 1 行结果' })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SearchBlock copy', () => {
|
||||
it('copies the whole structured matches result, not the collapsed or capped view', async () => {
|
||||
vi.useFakeTimers()
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
const view = render(<SearchBlock kind="matches" truncated total={9} maxLines={2} files={[
|
||||
{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'x' }, { lineNumber: 2, line: 'y' }] },
|
||||
{ path: 'b.ts', matches: [{ lineNumber: 3, line: 'z' }] },
|
||||
]} />)
|
||||
// Collapse a group and leave the cap in place: the clipboard still gets it all.
|
||||
fireEvent.click(view.container.querySelector('[class^="_fileHeader_"]')!)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('a.ts\n1: x\n2: y\n\nb.ts\n3: z')
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
|
||||
// A second click while the ok label shows is a no-op.
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制成功' }))
|
||||
expect(writeText).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('copies the newline-joined path list for the paths shape', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
render(<SearchBlock kind="paths" truncated={false} total={2} paths={['src/a.ts', 'src/b.ts']} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('src/a.ts\nsrc/b.ts')
|
||||
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(<SearchBlock kind="paths" truncated={false} total={1} paths={['a']} />)
|
||||
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 and tags the wrapper with the kind', () => {
|
||||
const view = render(<SearchBlock kind="paths" truncated={false} total={0} paths={[]} className="x" />)
|
||||
expect(view.container.firstElementChild?.classList.contains('x')).toBe(true)
|
||||
expect(view.container.firstElementChild?.getAttribute('data-search')).toBe('paths')
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,37 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('Tooltip', () => {
|
||||
it('can delay pointer hover without delaying keyboard focus', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
render(
|
||||
<Tooltip label="Timing details" delayMs={500}>
|
||||
<button type="button">anchor</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
const anchor = screen.getByText('anchor')
|
||||
fireEvent.mouseEnter(anchor)
|
||||
act(() => { vi.advanceTimersByTime(499) })
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
fireEvent.mouseLeave(anchor)
|
||||
act(() => { vi.advanceTimersByTime(1) })
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
fireEvent.mouseEnter(anchor)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.getByRole('tooltip').textContent).toBe('Timing details')
|
||||
fireEvent.mouseLeave(anchor)
|
||||
fireEvent.focus(anchor)
|
||||
expect(screen.getByRole('tooltip').textContent).toBe('Timing details')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the bubble to the right on hover and hides it on leave', () => {
|
||||
render(
|
||||
<Tooltip label="Open sidebar">
|
||||
|
||||
211
packages/client/ui-primitives/tests/web-block.spec.tsx
Normal file
211
packages/client/ui-primitives/tests/web-block.spec.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
// @vitest-environment jsdom
|
||||
// WebBlock: both kinds of the web card. The search card's answer, its citation
|
||||
// list with the title-or-hostname label fallback and optional snippet/date, the
|
||||
// source-list height cap and its expand control, and the truncated indicator;
|
||||
// the fetch card's linked URL, status, and truncation. Safe-link attributes on
|
||||
// both kinds: an http(s) URL becomes an external anchor (target/rel), any other
|
||||
// URL renders as plain text with no href.
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { DEFAULT_WEB_MAX_SOURCES, WebBlock } from '../src/index.ts'
|
||||
import type { WebSourceView } from '../src/index.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
/** `count` sources with sequential hostnames, so the cap slices read distinctly. */
|
||||
function sources(count: number): WebSourceView[] {
|
||||
return Array.from({ length: count }, (_value, index) => ({
|
||||
url: `https://site-${index}.example.com/page`,
|
||||
title: `Source ${index}`,
|
||||
}))
|
||||
}
|
||||
|
||||
describe('WebBlock search card', () => {
|
||||
it('renders the answer above the citation list', () => {
|
||||
const view = render(<WebBlock kind="search" answer="**Answer** text" sources={sources(2)} truncated={false} />)
|
||||
expect(view.getByText('Answer')).toBeTruthy()
|
||||
expect(view.getByText('Source 0')).toBeTruthy()
|
||||
expect(view.getByText('Source 1')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('omits the answer block when there is no answer', () => {
|
||||
const view = render(<WebBlock kind="search" sources={sources(1)} truncated={false} />)
|
||||
expect(view.container.querySelector('[class^="_answer_"]')).toBeNull()
|
||||
const empty = render(<WebBlock kind="search" answer="" sources={sources(1)} truncated={false} />)
|
||||
expect(empty.container.querySelector('[class^="_answer_"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the empty-state note when a search returns no answer and no sources', () => {
|
||||
const view = render(<WebBlock kind="search" sources={[]} truncated={false} />)
|
||||
expect(view.getByText('未找到结果')).toBeTruthy()
|
||||
// The empty note replaces the source list, not an empty <ol>.
|
||||
expect(view.container.querySelector('ol')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the source list, not the empty note, when a source is present', () => {
|
||||
const view = render(<WebBlock kind="search" sources={sources(1)} truncated={false} />)
|
||||
expect(view.container.querySelector('ol')).toBeTruthy()
|
||||
expect(view.queryByText('未找到结果')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the source list when an empty source list still carries an answer', () => {
|
||||
const view = render(<WebBlock kind="search" answer="Just an answer" sources={[]} truncated={false} />)
|
||||
expect(view.getByText('Just an answer')).toBeTruthy()
|
||||
expect(view.queryByText('未找到结果')).toBeNull()
|
||||
})
|
||||
|
||||
it('labels a source by its title, and by hostname when the title is absent', () => {
|
||||
const view = render(<WebBlock kind="search" truncated={false} sources={[
|
||||
{ url: 'https://example.com/a', title: 'Titled' },
|
||||
{ url: 'https://plain.example.org/b' },
|
||||
{ url: 'https://empty.example.net/c', title: '' },
|
||||
]} />)
|
||||
expect(view.getByText('Titled')).toBeTruthy()
|
||||
// No title / empty title: the hostname labels the link.
|
||||
expect(view.getByText('plain.example.org')).toBeTruthy()
|
||||
expect(view.getByText('empty.example.net')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('labels a source by the raw url when it parses to an empty hostname', () => {
|
||||
// file:/data:/javascript: URLs parse but have no hostname; the label must
|
||||
// fall back to the raw URL so it is never blank (and the link stays plain
|
||||
// text since the protocol is not http(s)).
|
||||
const view = render(<WebBlock kind="search" truncated={false} sources={[
|
||||
{ url: 'file:///etc/passwd' },
|
||||
]} />)
|
||||
expect(view.getByText('file:///etc/passwd')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders a source as a safe external anchor for an http(s) url', () => {
|
||||
const view = render(<WebBlock kind="search" truncated={false} sources={[
|
||||
{ url: 'https://example.com/a', title: 'Titled' },
|
||||
]} />)
|
||||
const anchor = view.getByText('Titled') as HTMLAnchorElement
|
||||
expect(anchor.tagName).toBe('A')
|
||||
expect(anchor.getAttribute('href')).toBe('https://example.com/a')
|
||||
expect(anchor.getAttribute('target')).toBe('_blank')
|
||||
expect(anchor.getAttribute('rel')).toBe('noopener noreferrer')
|
||||
})
|
||||
|
||||
it('renders a non-http url as plain text with no href, and its raw text label when unparseable', () => {
|
||||
const view = render(<WebBlock kind="search" truncated={false} sources={[
|
||||
{ url: 'javascript:alert(1)', title: 'Dangerous' },
|
||||
{ url: 'not a url' },
|
||||
]} />)
|
||||
const unsafe = view.getByText('Dangerous')
|
||||
expect(unsafe.tagName).toBe('SPAN')
|
||||
expect(unsafe.getAttribute('href')).toBeNull()
|
||||
// An unparseable url is not a link and cannot yield a hostname, so its raw
|
||||
// text is the label.
|
||||
const raw = view.getByText('not a url')
|
||||
expect(raw.tagName).toBe('SPAN')
|
||||
})
|
||||
|
||||
it('shows a source snippet and publication date when present, and omits them when absent or empty', () => {
|
||||
const view = render(<WebBlock kind="search" truncated={false} sources={[
|
||||
{ url: 'https://a.example.com', title: 'A', snippet: 'excerpt', publishedAt: '2026-07-01' },
|
||||
{ url: 'https://b.example.com', title: 'B', snippet: '', publishedAt: '' },
|
||||
{ url: 'https://c.example.com', title: 'C' },
|
||||
]} />)
|
||||
expect(view.getByText('excerpt')).toBeTruthy()
|
||||
expect(view.getByText('2026-07-01')).toBeTruthy()
|
||||
// The empty-string and absent arms both draw nothing beyond the link.
|
||||
expect(view.container.querySelectorAll('[class^="_snippet_"]')).toHaveLength(1)
|
||||
expect(view.container.querySelectorAll('[class^="_published_"]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows the truncated indicator only when the list was capped by the tool', () => {
|
||||
const on = render(<WebBlock kind="search" sources={sources(1)} truncated />)
|
||||
expect(on.getByText('来源列表已截断')).toBeTruthy()
|
||||
cleanup()
|
||||
const off = render(<WebBlock kind="search" sources={sources(1)} truncated={false} />)
|
||||
expect(off.queryByText('来源列表已截断')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders every source and no expand control under the cap', () => {
|
||||
const view = render(<WebBlock kind="search" sources={sources(4)} truncated={false} maxSources={4} />)
|
||||
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(4)
|
||||
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
|
||||
})
|
||||
|
||||
it('slices head and tail over the cap and expands on click', () => {
|
||||
const view = render(<WebBlock kind="search" sources={sources(10)} truncated={false} maxSources={4} />)
|
||||
// maxSources 4: head = ceil(4/2) = 2, tail = 4 - 2 = 2, 6 hidden.
|
||||
expect([...view.container.querySelectorAll('[class^="_sourceLink_"]')].map(n => n.textContent))
|
||||
.toEqual(['Source 0', 'Source 1', 'Source 8', 'Source 9'])
|
||||
const toggle = view.getByRole('button', { name: '展开其余 6 条来源' })
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(toggle.textContent).toBe('… 其余 6 条来源')
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(10)
|
||||
const collapse = view.getByRole('button', { name: '收起来源' })
|
||||
expect(collapse.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(collapse.textContent).toBe('收起')
|
||||
|
||||
fireEvent.click(collapse)
|
||||
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('numbers a collapsed tail by each source original position, not its visible slot', () => {
|
||||
// maxSources 4 over 10 sources: the tail is sources 8 and 9, which must read
|
||||
// as citations 9 and 10 (via <li value>), not renumbered 3 and 4.
|
||||
const view = render(<WebBlock kind="search" sources={sources(10)} truncated={false} maxSources={4} />)
|
||||
const items = [...view.container.querySelectorAll('li[class^="_source_"]')]
|
||||
expect(items.map(li => li.getAttribute('value'))).toEqual(['1', '2', '9', '10'])
|
||||
})
|
||||
|
||||
it('keeps the expander out of the ordered-list numbering', () => {
|
||||
// The expander is a marker-less <li>, so it is valid inside <ol> and does not
|
||||
// consume a citation number between the head and tail sources.
|
||||
const view = render(<WebBlock kind="search" sources={sources(10)} truncated={false} maxSources={4} />)
|
||||
const ol = view.container.querySelector('ol')!
|
||||
// Every direct child is an <li> (no bare <button> child — invalid HTML).
|
||||
expect([...ol.children].every(child => child.tagName === 'LI')).toBe(true)
|
||||
})
|
||||
|
||||
it('renders the head slice alone when the cap leaves no tail', () => {
|
||||
const view = render(<WebBlock kind="search" sources={sources(5)} truncated={false} maxSources={1} />)
|
||||
expect([...view.container.querySelectorAll('[class^="_sourceLink_"]')].map(n => n.textContent)).toEqual(['Source 0'])
|
||||
expect(view.getByRole('button', { name: '展开其余 4 条来源' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('caps at the documented default when maxSources is absent', () => {
|
||||
const view = render(<WebBlock kind="search" sources={sources(DEFAULT_WEB_MAX_SOURCES + 1)} truncated={false} />)
|
||||
expect(view.container.querySelectorAll('[class^="_source_"]')).toHaveLength(DEFAULT_WEB_MAX_SOURCES)
|
||||
expect(view.getByRole('button', { name: '展开其余 1 条来源' })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebBlock fetch card', () => {
|
||||
it('renders the fetched url as a safe external anchor and its HTTP status', () => {
|
||||
const view = render(<WebBlock kind="fetch" url="https://example.com/page" statusCode={200} truncated={false} />)
|
||||
const anchor = view.getByText('https://example.com/page') as HTMLAnchorElement
|
||||
expect(anchor.tagName).toBe('A')
|
||||
expect(anchor.getAttribute('href')).toBe('https://example.com/page')
|
||||
expect(anchor.getAttribute('target')).toBe('_blank')
|
||||
expect(anchor.getAttribute('rel')).toBe('noopener noreferrer')
|
||||
expect(view.getByText('HTTP 200')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders a non-http fetch url as plain text with no href', () => {
|
||||
const view = render(<WebBlock kind="fetch" url="file:///etc/passwd" statusCode={200} truncated={false} />)
|
||||
const label = view.getByText('file:///etc/passwd')
|
||||
expect(label.tagName).toBe('SPAN')
|
||||
expect(label.getAttribute('href')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the truncated indicator only when the content was cut', () => {
|
||||
const on = render(<WebBlock kind="fetch" url="https://example.com" statusCode={200} truncated />)
|
||||
expect(on.getByText('内容已截断')).toBeTruthy()
|
||||
cleanup()
|
||||
const off = render(<WebBlock kind="fetch" url="https://example.com" statusCode={200} truncated={false} />)
|
||||
expect(off.queryByText('内容已截断')).toBeNull()
|
||||
})
|
||||
|
||||
it('carries a non-200 status verbatim', () => {
|
||||
const view = render(<WebBlock kind="fetch" url="https://example.com/missing" statusCode={404} truncated={false} />)
|
||||
expect(view.getByText('HTTP 404')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user