mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-31 16:49:07 +08:00
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:
parent
95ff67e99c
commit
144012b980
@ -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 }) => {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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} />,
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
127
src/renderer/src/pages/home/Markdown/__tests__/Link.test.tsx
Normal file
127
src/renderer/src/pages/home/Markdown/__tests__/Link.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
`;
|
||||
@ -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>
|
||||
`;
|
||||
Loading…
Reference in New Issue
Block a user