diff --git a/src/renderer/src/pages/home/Markdown/CitationTooltip.tsx b/src/renderer/src/pages/home/Markdown/CitationTooltip.tsx index febbd3264f..2e496dd7ee 100644 --- a/src/renderer/src/pages/home/Markdown/CitationTooltip.tsx +++ b/src/renderer/src/pages/home/Markdown/CitationTooltip.tsx @@ -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 } const CitationTooltip: React.FC = ({ children, citation }) => { diff --git a/src/renderer/src/pages/home/Markdown/Link.tsx b/src/renderer/src/pages/home/Markdown/Link.tsx index 5dbac883f3..9acf38634b 100644 --- a/src/renderer/src/pages/home/Markdown/Link.tsx +++ b/src/renderer/src/pages/home/Markdown/Link.tsx @@ -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 { node?: Omit - citationData?: { - url: string - title?: string - content?: string - } } const Link: React.FC = (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 {props.children} @@ -29,9 +32,9 @@ const Link: React.FC = (props) => { }) // 如果是引用链接并且有引用数据,则使用CitationTooltip - if (isCitation && props.citationData) { + if (isCitation && citationData) { return ( - + = ({ block, postProcess }) => { const components = useMemo(() => { return { - a: (props: any) => , + a: (props: any) => , code: (props: any) => , table: (props: any) => , img: (props: any) => , diff --git a/src/renderer/src/pages/home/Markdown/__tests__/Hyperlink.test.tsx b/src/renderer/src/pages/home/Markdown/__tests__/Hyperlink.test.tsx new file mode 100644 index 0000000000..bd232b3454 --- /dev/null +++ b/src/renderer/src/pages/home/Markdown/__tests__/Hyperlink.test.tsx @@ -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) => ( +
+
{content}
+
{children}
+
+ ), + Favicon: ({ hostname, alt }: { hostname: string; alt: string }) => ( + {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( + + Child + + ) + expect(container).toMatchSnapshot() + }) + + it('should return children directly when href is empty', () => { + render( + + Only Child + + ) + expect(screen.queryByTestId('popover')).toBeNull() + expect(screen.getByText('Only Child')).toBeInTheDocument() + }) + + it('should decode href and show favicon when hostname exists', () => { + render( + + child + + ) + + // 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( + + child + + ) + + // 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( + + child + + ) + + // 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') + }) +}) diff --git a/src/renderer/src/pages/home/Markdown/__tests__/Link.test.tsx b/src/renderer/src/pages/home/Markdown/__tests__/Link.test.tsx new file mode 100644 index 0000000000..51b0de87ff --- /dev/null +++ b/src/renderer/src/pages/home/Markdown/__tests__/Link.test.tsx @@ -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 }) => ( +
{children}
+ ), + CitationSchema: { + safeParse: vi.fn((input: any) => ({ success: !!input, data: input })) + }, + Hyperlink: ({ children, href }: { children: React.ReactNode; href: string }) => ( +
+ {children} +
+ ) +})) + +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(Example) + expect(container).toMatchSnapshot() + }) + + it('should render internal anchor as span.link and no ', () => { + const { container } = render(Go to section) + 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 and citation data exists', () => { + mocks.findCitationInChildren.mockReturnValue('{"title":"ref"}') + mocks.parseJSON.mockReturnValue({ title: 'ref' }) + + const onParentClick = vi.fn() + const { container } = render( +
+ + ref + 1 + +
+ ) + + 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 exists but citation data is null', () => { + mocks.findCitationInChildren.mockReturnValue('{"title":"ref"}') + mocks.parseJSON.mockReturnValue(null) + + render( + + text + 1 + + ) + + 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(Open) + + 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( + + text2 + + ) + + const anchor = container.querySelector('a') as HTMLAnchorElement + expect(anchor).not.toBeNull() + expect(anchor.hasAttribute('href')).toBe(false) + }) +}) diff --git a/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/Hyperlink.test.tsx.snap b/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/Hyperlink.test.tsx.snap new file mode 100644 index 0000000000..1dad29914b --- /dev/null +++ b/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/Hyperlink.test.tsx.snap @@ -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; +} + +
+
+
+
+ https://example.com/path with space + + https://example.com/path with space + +
+
+
+ + Child + +
+
+
+`; diff --git a/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/Link.test.tsx.snap b/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/Link.test.tsx.snap new file mode 100644 index 0000000000..35d8ee75e3 --- /dev/null +++ b/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/Link.test.tsx.snap @@ -0,0 +1,18 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Link > should match snapshot 1`] = ` +
+`;