209 lines
7.1 KiB
TypeScript
209 lines
7.1 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 AionUi (aionui.com)
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import React from 'react';
|
|
import { fireEvent, render, screen } from '@testing-library/react';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import MarkdownView from '@/renderer/components/Markdown';
|
|
|
|
const copyTextMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
|
|
|
vi.mock('@/renderer/components/Markdown/ShadowView', () => ({
|
|
__esModule: true,
|
|
default: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
|
|
}));
|
|
|
|
vi.mock('@/renderer/components/Markdown/CodeBlock', () => ({
|
|
__esModule: true,
|
|
default: ({ children }: { children?: React.ReactNode }) => <code>{children}</code>,
|
|
}));
|
|
|
|
vi.mock('@/renderer/components/media/LocalImageView', () => ({
|
|
__esModule: true,
|
|
default: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
|
|
}));
|
|
|
|
vi.mock('@/renderer/utils/chat/latexDelimiters', () => ({
|
|
convertLatexDelimiters: (text: string) => text,
|
|
}));
|
|
|
|
vi.mock('@/renderer/utils/platform', () => ({
|
|
openExternalUrl: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@/renderer/utils/ui/clipboard', () => ({
|
|
copyText: copyTextMock,
|
|
}));
|
|
|
|
vi.mock('@arco-design/web-react', () => ({
|
|
Button: ({
|
|
children,
|
|
icon,
|
|
...props
|
|
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { icon?: React.ReactNode }) => (
|
|
<button type='button' {...props}>
|
|
{icon}
|
|
{children}
|
|
</button>
|
|
),
|
|
Message: {
|
|
error: vi.fn(),
|
|
},
|
|
Tooltip: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
|
}));
|
|
|
|
vi.mock('@icon-park/react', () => ({
|
|
Copy: () => <span data-testid='copy-icon' />,
|
|
}));
|
|
|
|
vi.mock('react-i18next', () => ({
|
|
useTranslation: () => ({
|
|
t: (key: string, options?: { defaultValue?: string }) => options?.defaultValue ?? key,
|
|
}),
|
|
}));
|
|
|
|
describe('MarkdownView local file links', () => {
|
|
beforeEach(() => {
|
|
copyTextMock.mockClear();
|
|
});
|
|
|
|
it('renders local file links as app controls instead of browser anchors', () => {
|
|
const onLocalFileLink = vi.fn();
|
|
|
|
render(
|
|
<MarkdownView onLocalFileLink={onLocalFileLink}>
|
|
{'[report.xlsx](/C:/Users/Administrator/AppData/Roaming/AionUi/report.xlsx)'}
|
|
</MarkdownView>
|
|
);
|
|
|
|
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'report.xlsx' }));
|
|
expect(onLocalFileLink).toHaveBeenCalledWith(
|
|
'C:/Users/Administrator/AppData/Roaming/AionUi/report.xlsx',
|
|
expect.objectContaining({
|
|
filePath: 'C:/Users/Administrator/AppData/Roaming/AionUi/report.xlsx',
|
|
})
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Copy' }));
|
|
expect(copyTextMock).toHaveBeenCalledWith('C:/Users/Administrator/AppData/Roaming/AionUi/report.xlsx');
|
|
});
|
|
|
|
it('renders line references as file chips and copies the full reference', () => {
|
|
const onLocalFileLink = vi.fn();
|
|
|
|
render(
|
|
<MarkdownView onLocalFileLink={onLocalFileLink}>
|
|
{'[2026-06-19.log](C:/Users/Administrator/AppData/Roaming/AionUi/logs/2026-06-19.log:1421)'}
|
|
</MarkdownView>
|
|
);
|
|
|
|
const fileButton = screen.getByRole('button', { name: /2026-06-19\.log\s+L1421/ });
|
|
fireEvent.click(fileButton);
|
|
|
|
expect(onLocalFileLink).toHaveBeenCalledWith(
|
|
'C:/Users/Administrator/AppData/Roaming/AionUi/logs/2026-06-19.log',
|
|
expect.objectContaining({
|
|
filePath: 'C:/Users/Administrator/AppData/Roaming/AionUi/logs/2026-06-19.log',
|
|
rawReference: 'C:/Users/Administrator/AppData/Roaming/AionUi/logs/2026-06-19.log:1421',
|
|
line: 1421,
|
|
})
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Copy' }));
|
|
expect(copyTextMock).toHaveBeenCalledWith('C:/Users/Administrator/AppData/Roaming/AionUi/logs/2026-06-19.log:1421');
|
|
});
|
|
|
|
it('renders line and column references as file chips and copies the full reference', () => {
|
|
const onLocalFileLink = vi.fn();
|
|
|
|
render(
|
|
<MarkdownView onLocalFileLink={onLocalFileLink}>
|
|
{'[app.log](C:/Users/Administrator/AppData/Roaming/AionUi/logs/app.log:1421:7)'}
|
|
</MarkdownView>
|
|
);
|
|
|
|
const fileButton = screen.getByRole('button', { name: /app\.log\s+L1421:7/ });
|
|
fireEvent.click(fileButton);
|
|
|
|
expect(onLocalFileLink).toHaveBeenCalledWith(
|
|
'C:/Users/Administrator/AppData/Roaming/AionUi/logs/app.log',
|
|
expect.objectContaining({
|
|
filePath: 'C:/Users/Administrator/AppData/Roaming/AionUi/logs/app.log',
|
|
rawReference: 'C:/Users/Administrator/AppData/Roaming/AionUi/logs/app.log:1421:7',
|
|
line: 1421,
|
|
column: 7,
|
|
})
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Copy' }));
|
|
expect(copyTextMock).toHaveBeenCalledWith('C:/Users/Administrator/AppData/Roaming/AionUi/logs/app.log:1421:7');
|
|
});
|
|
|
|
it('renders hash range references as file chips and copies normalized local references', () => {
|
|
const onLocalFileLink = vi.fn();
|
|
|
|
render(
|
|
<MarkdownView onLocalFileLink={onLocalFileLink}>
|
|
{'[user.js 1-260行](/Users/demo/project/user.js#L1-L260)'}
|
|
</MarkdownView>
|
|
);
|
|
|
|
expect(screen.queryByRole('link', { name: /user\.js/ })).not.toBeInTheDocument();
|
|
|
|
const fileButton = screen.getByRole('button', { name: /user\.js 1-260行\s+L1-L260/ });
|
|
fireEvent.click(fileButton);
|
|
|
|
expect(onLocalFileLink).toHaveBeenCalledWith(
|
|
'/Users/demo/project/user.js',
|
|
expect.objectContaining({
|
|
filePath: '/Users/demo/project/user.js',
|
|
rawReference: '/Users/demo/project/user.js#L1-L260',
|
|
line: 1,
|
|
endLine: 260,
|
|
})
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Copy' }));
|
|
expect(copyTextMock).toHaveBeenCalledWith('/Users/demo/project/user.js#L1-L260');
|
|
});
|
|
|
|
it('does not render a no-op open button when no local file handler is provided', () => {
|
|
render(<MarkdownView>{'[report.xlsx](/C:/Users/Administrator/AppData/Roaming/AionUi/report.xlsx)'}</MarkdownView>);
|
|
|
|
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: 'report.xlsx' })).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Copy' }));
|
|
expect(copyTextMock).toHaveBeenCalledWith('C:/Users/Administrator/AppData/Roaming/AionUi/report.xlsx');
|
|
});
|
|
|
|
it('keeps ordinary http links as browser anchors', () => {
|
|
render(<MarkdownView>{'[docs](https://aionui.com/docs)'}</MarkdownView>);
|
|
|
|
const link = screen.getByRole('link', { name: 'docs' });
|
|
expect(link).toHaveAttribute('href', 'https://aionui.com/docs');
|
|
});
|
|
|
|
it('keeps http hash links as browser anchors', () => {
|
|
render(<MarkdownView>{'[docs](https://aionui.com/docs#L10)'}</MarkdownView>);
|
|
|
|
const link = screen.getByRole('link', { name: 'docs' });
|
|
expect(link).toHaveAttribute('href', 'https://aionui.com/docs#L10');
|
|
});
|
|
|
|
it('adds empty alt text to external raw HTML images without alt text', () => {
|
|
const { container } = render(
|
|
<MarkdownView allowHtml>{'<img src="https://example.com/generated.png" />'}</MarkdownView>
|
|
);
|
|
|
|
const image = container.querySelector('img');
|
|
expect(image).toHaveAttribute('src', 'https://example.com/generated.png');
|
|
expect(image).toHaveAttribute('alt', '');
|
|
});
|
|
});
|