test: link and hyperlink (#9638)

* test: add tests for Link

* test: add tests for HyperLink

* refactor: use zod for citation data

* refactor: update import
This commit is contained in:
one 2025-08-29 00:37:14 +08:00 committed by GitHub
parent 95ff67e99c
commit 144012b980
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 316 additions and 17 deletions

View File

@ -2,14 +2,17 @@ import Favicon from '@renderer/components/Icons/FallbackFavicon'
import { Tooltip } from 'antd'
import React, { memo, useCallback, useMemo } from 'react'
import styled from 'styled-components'
import { z } from 'zod'
export const CitationSchema = z.object({
url: z.string().url(),
title: z.string().optional(),
content: z.string().optional()
})
interface CitationTooltipProps {
children: React.ReactNode
citation: {
url: string
title?: string
content?: string
}
citation: z.infer<typeof CitationSchema>
}
const CitationTooltip: React.FC<CitationTooltipProps> = ({ children, citation }) => {

View File

@ -1,20 +1,23 @@
import { parseJSON } from '@renderer/utils/json'
import { findCitationInChildren } from '@renderer/utils/markdown'
import { isEmpty, omit } from 'lodash'
import React from 'react'
import React, { useMemo } from 'react'
import type { Node } from 'unist'
import CitationTooltip from './CitationTooltip'
import CitationTooltip, { CitationSchema } from './CitationTooltip'
import Hyperlink from './Hyperlink'
interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
node?: Omit<Node, 'type'>
citationData?: {
url: string
title?: string
content?: string
}
}
const Link: React.FC<LinkProps> = (props) => {
const citationData = useMemo(() => {
const raw = parseJSON(findCitationInChildren(props.children))
const parsed = CitationSchema.safeParse(raw)
return parsed.success ? parsed.data : null
}, [props.children])
// 处理内部链接
if (props.href?.startsWith('#')) {
return <span className="link">{props.children}</span>
@ -29,9 +32,9 @@ const Link: React.FC<LinkProps> = (props) => {
})
// 如果是引用链接并且有引用数据则使用CitationTooltip
if (isCitation && props.citationData) {
if (isCitation && citationData) {
return (
<CitationTooltip citation={props.citationData}>
<CitationTooltip citation={citationData}>
<a
{...omit(props, ['node', 'citationData'])}
href={isEmpty(props.href) ? undefined : props.href}

View File

@ -8,9 +8,8 @@ import MarkdownShadowDOMRenderer from '@renderer/components/MarkdownShadowDOMRen
import { useSettings } from '@renderer/hooks/useSettings'
import { useSmoothStream } from '@renderer/hooks/useSmoothStream'
import type { MainTextMessageBlock, ThinkingMessageBlock, TranslationMessageBlock } from '@renderer/types/newMessage'
import { parseJSON } from '@renderer/utils'
import { removeSvgEmptyLines } from '@renderer/utils/formats'
import { findCitationInChildren, processLatexBrackets } from '@renderer/utils/markdown'
import { processLatexBrackets } from '@renderer/utils/markdown'
import { isEmpty } from 'lodash'
import { type FC, memo, useCallback, useEffect, useMemo, useState } from 'react'
import { useRef } from 'react'
@ -127,7 +126,7 @@ const Markdown: FC<Props> = ({ block, postProcess }) => {
const components = useMemo(() => {
return {
a: (props: any) => <Link {...props} citationData={parseJSON(findCitationInChildren(props.children))} />,
a: (props: any) => <Link {...props} />,
code: (props: any) => <CodeBlock {...props} blockId={block.id} />,
table: (props: any) => <Table {...props} blockId={block.id} />,
img: (props: any) => <ImageViewer style={{ maxWidth: 500, maxHeight: 500 }} {...props} />,

View File

@ -0,0 +1,98 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Hyperlink from '../Hyperlink'
// 3.1: 使用 vi.hoisted 集中管理模拟
const mocks = vi.hoisted(() => ({
Popover: ({ children, content, arrow, placement, color, styles }: any) => (
<div
data-testid="popover"
data-arrow={String(arrow)}
data-placement={placement}
data-color={color}
data-styles={JSON.stringify(styles)}>
<div data-testid="popover-content">{content}</div>
<div data-testid="popover-children">{children}</div>
</div>
),
Favicon: ({ hostname, alt }: { hostname: string; alt: string }) => (
<img data-testid="favicon" data-hostname={hostname} alt={alt} />
)
}))
vi.mock('antd', () => ({
Popover: mocks.Popover
}))
vi.mock('@renderer/components/Icons/FallbackFavicon', () => ({
default: mocks.Favicon
}))
describe('Hyperlink', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should match snapshot for normal url', () => {
const { container } = render(
<Hyperlink href="https://example.com/path%20with%20space">
<span>Child</span>
</Hyperlink>
)
expect(container).toMatchSnapshot()
})
it('should return children directly when href is empty', () => {
render(
<Hyperlink href="">
<span>Only Child</span>
</Hyperlink>
)
expect(screen.queryByTestId('popover')).toBeNull()
expect(screen.getByText('Only Child')).toBeInTheDocument()
})
it('should decode href and show favicon when hostname exists', () => {
render(
<Hyperlink href="https://domain.com/a%20b">
<span>child</span>
</Hyperlink>
)
// Popover wrapper exists
const popover = screen.getByTestId('popover')
expect(popover).toBeInTheDocument()
expect(popover).toHaveAttribute('data-arrow', 'false')
expect(popover).toHaveAttribute('data-placement', 'top')
// Content includes decoded url text and favicon with hostname
expect(screen.getByTestId('favicon')).toHaveAttribute('data-hostname', 'domain.com')
expect(screen.getByTestId('favicon')).toHaveAttribute('alt', 'https://domain.com/a b')
expect(screen.getByTestId('popover-content')).toHaveTextContent('https://domain.com/a b')
})
it('should not render favicon when URL parsing fails (invalid url)', () => {
render(
<Hyperlink href="not%2Furl">
<span>child</span>
</Hyperlink>
)
// decodeURIComponent succeeds => "not/url" is displayed
expect(screen.queryByTestId('favicon')).toBeNull()
expect(screen.getByTestId('popover-content')).toHaveTextContent('not/url')
})
it('should not render favicon for non-http(s) scheme without hostname (mailto:)', () => {
render(
<Hyperlink href="mailto:test%40example.com">
<span>child</span>
</Hyperlink>
)
// Decoded to mailto:test@example.com, hostname is empty => no favicon
expect(screen.queryByTestId('favicon')).toBeNull()
expect(screen.getByTestId('popover-content')).toHaveTextContent('mailto:test@example.com')
})
})

View File

@ -0,0 +1,127 @@
import { fireEvent, render, screen } from '@testing-library/react'
import React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Link from '../Link'
const mocks = vi.hoisted(() => ({
parseJSON: vi.fn(),
findCitationInChildren: vi.fn(),
CitationTooltip: ({ children }: { children: React.ReactNode }) => (
<div data-testid="citation-tooltip">{children}</div>
),
CitationSchema: {
safeParse: vi.fn((input: any) => ({ success: !!input, data: input }))
},
Hyperlink: ({ children, href }: { children: React.ReactNode; href: string }) => (
<div data-testid="hyperlink" data-href={href}>
{children}
</div>
)
}))
vi.mock('@renderer/utils/json', () => ({
parseJSON: mocks.parseJSON
}))
vi.mock('@renderer/utils/markdown', () => ({
findCitationInChildren: mocks.findCitationInChildren
}))
vi.mock('../CitationTooltip', () => ({
default: mocks.CitationTooltip,
CitationSchema: mocks.CitationSchema
}))
vi.mock('../Hyperlink', () => ({
default: mocks.Hyperlink
}))
describe('Link', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should match snapshot', () => {
const { container } = render(<Link href="https://example.com">Example</Link>)
expect(container).toMatchSnapshot()
})
it('should render internal anchor as span.link and no <a>', () => {
const { container } = render(<Link href="#section-1">Go to section</Link>)
expect(container.querySelector('span.link')).not.toBeNull()
expect(container.querySelector('a')).toBeNull()
expect(screen.getByText('Go to section')).toBeInTheDocument()
})
it('should wrap with CitationTooltip when children include <sup> and citation data exists', () => {
mocks.findCitationInChildren.mockReturnValue('{"title":"ref"}')
mocks.parseJSON.mockReturnValue({ title: 'ref' })
const onParentClick = vi.fn()
const { container } = render(
<div onClick={onParentClick}>
<Link href="https://example.com">
<span>ref</span>
<sup>1</sup>
</Link>
</div>
)
expect(screen.getByTestId('citation-tooltip')).toBeInTheDocument()
const anchor = container.querySelector('a') as HTMLAnchorElement
expect(anchor).not.toBeNull()
expect(anchor.getAttribute('target')).toBe('_blank')
expect(anchor.getAttribute('rel')).toBe('noreferrer')
fireEvent.click(anchor)
expect(onParentClick).not.toHaveBeenCalled()
})
it('should fall back to Hyperlink when <sup> exists but citation data is null', () => {
mocks.findCitationInChildren.mockReturnValue('{"title":"ref"}')
mocks.parseJSON.mockReturnValue(null)
render(
<Link href="https://example.com">
<span>text</span>
<sup>1</sup>
</Link>
)
expect(screen.getByTestId('hyperlink')).toBeInTheDocument()
expect(screen.queryByTestId('citation-tooltip')).toBeNull()
})
it('should render normal external link inside Hyperlink when not a citation', () => {
mocks.findCitationInChildren.mockReturnValue(undefined)
mocks.parseJSON.mockReturnValue(undefined)
const { container } = render(<Link href="https://domain.com/path">Open</Link>)
const wrapper = screen.getByTestId('hyperlink')
expect(wrapper).toBeInTheDocument()
expect(wrapper).toHaveAttribute('data-href', 'https://domain.com/path')
const anchor = container.querySelector('a') as HTMLAnchorElement
expect(anchor.getAttribute('href')).toBe('https://domain.com/path')
expect(anchor.getAttribute('target')).toBe('_blank')
expect(anchor.getAttribute('rel')).toBe('noreferrer')
})
it('should omit empty href for citation link (no href attribute when href="")', () => {
mocks.findCitationInChildren.mockReturnValue('{"title":"ref"}')
mocks.parseJSON.mockReturnValue({ title: 'ref' })
const { container } = render(
<Link href="">
text<sup>2</sup>
</Link>
)
const anchor = container.querySelector('a') as HTMLAnchorElement
expect(anchor).not.toBeNull()
expect(anchor.hasAttribute('href')).toBe(false)
})
})

View File

@ -0,0 +1,51 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Hyperlink > should match snapshot for normal url 1`] = `
.c0 {
color: var(--color-text);
display: flex;
align-items: center;
gap: 8px;
}
.c0 span {
max-width: min(400px,70vw);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
<div>
<div
data-arrow="false"
data-color="var(--color-background)"
data-placement="top"
data-styles="{"body":{"border":"1px solid var(--color-border)","padding":"12px","borderRadius":"8px"}}"
data-testid="popover"
>
<div
data-testid="popover-content"
>
<div
class="c0"
>
<img
alt="https://example.com/path with space"
data-hostname="example.com"
data-testid="favicon"
/>
<span>
https://example.com/path with space
</span>
</div>
</div>
<div
data-testid="popover-children"
>
<span>
Child
</span>
</div>
</div>
</div>
`;

View File

@ -0,0 +1,18 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Link > should match snapshot 1`] = `
<div>
<div
data-href="https://example.com"
data-testid="hyperlink"
>
<a
href="https://example.com"
rel="noreferrer"
target="_blank"
>
Example
</a>
</div>
</div>
`;