mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-10 08:43:34 +08:00
- 表导入与数据库导入统一提供遇错停止和继续策略,并跟随主题变量 - 增加能力校验、高级解析选项、字节进度、停止状态与持久任务历史 - 防止重复启动和运行中面板卸载,完善六语言语句统计及回归测试
350 lines
11 KiB
TypeScript
350 lines
11 KiB
TypeScript
import React from 'react';
|
|
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { SQLFileExecutionState } from './useSQLFileExecutionRunner';
|
|
import { setCurrentLanguage } from '../i18n';
|
|
import DatabaseImportExecutionPanel from './DatabaseImportExecutionPanel';
|
|
|
|
const createRunnerState = (
|
|
overrides: Partial<SQLFileExecutionState> = {},
|
|
): SQLFileExecutionState => ({
|
|
jobId: '',
|
|
title: '',
|
|
filePath: '',
|
|
fileSizeMB: '',
|
|
startedAt: 0,
|
|
finishedAt: 0,
|
|
status: 'idle',
|
|
stage: '',
|
|
executed: 0,
|
|
failed: 0,
|
|
total: 0,
|
|
percent: 0,
|
|
bytesRead: 0,
|
|
totalBytes: 0,
|
|
bytesPerSecond: 0,
|
|
etaSeconds: 0,
|
|
currentSQL: '',
|
|
message: '',
|
|
...overrides,
|
|
});
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
importDatabaseSQL: vi.fn(),
|
|
cancelSQLFileExecution: vi.fn(),
|
|
run: vi.fn(),
|
|
cancel: vi.fn(),
|
|
reset: vi.fn(),
|
|
modalConfirm: vi.fn(),
|
|
state: null as SQLFileExecutionState | null,
|
|
isRunning: false,
|
|
lastRunOptions: null as null | {
|
|
run: (jobId: string) => Promise<any>;
|
|
cancel?: (jobId: string) => void | Promise<void>;
|
|
},
|
|
}));
|
|
|
|
vi.mock('./common/ResizableDraggableModal', () => ({
|
|
default: { confirm: mocks.modalConfirm },
|
|
}));
|
|
|
|
vi.mock('../../wailsjs/go/app/App', () => ({
|
|
ImportDatabaseSQL: mocks.importDatabaseSQL,
|
|
CancelSQLFileExecution: mocks.cancelSQLFileExecution,
|
|
}));
|
|
|
|
vi.mock('./useSQLFileExecutionRunner', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('./useSQLFileExecutionRunner')>();
|
|
return {
|
|
...actual,
|
|
useSQLFileExecutionRunner: () => ({
|
|
state: mocks.state,
|
|
reset: mocks.reset,
|
|
cancelExecution: mocks.cancel,
|
|
runSQLFileExecutionWithProgress: mocks.run,
|
|
isRunning: mocks.isRunning,
|
|
}),
|
|
};
|
|
});
|
|
|
|
vi.mock('antd', async () => {
|
|
const React = await import('react');
|
|
const Alert = (props: Record<string, unknown>) => React.createElement('mock-alert', props);
|
|
const Button = ({ children, ...props }: any) => <button {...props}>{children}</button>;
|
|
const Progress = (props: Record<string, unknown>) => React.createElement('mock-progress', props);
|
|
const Paragraph = ({ children, ...props }: any) => <p {...props}>{children}</p>;
|
|
const Text = ({ children, ...props }: any) => <span {...props}>{children}</span>;
|
|
const Title = ({ children, ...props }: any) => <h3 {...props}>{children}</h3>;
|
|
return {
|
|
Alert,
|
|
Button,
|
|
Progress,
|
|
Typography: { Paragraph, Text, Title },
|
|
};
|
|
});
|
|
|
|
vi.mock('@ant-design/icons', () => ({
|
|
PlayCircleOutlined: () => React.createElement('mock-icon', { name: 'play' }),
|
|
ReloadOutlined: () => React.createElement('mock-icon', { name: 'reload' }),
|
|
StopOutlined: () => React.createElement('mock-icon', { name: 'stop' }),
|
|
}));
|
|
|
|
const renderPanel = async ({
|
|
continueOnError = false,
|
|
onRunningChange = vi.fn(),
|
|
}: {
|
|
continueOnError?: boolean;
|
|
onRunningChange?: ReturnType<typeof vi.fn>;
|
|
} = {}) => {
|
|
let renderer!: ReactTestRenderer;
|
|
await act(async () => {
|
|
renderer = create(
|
|
<DatabaseImportExecutionPanel
|
|
connectionConfig={{ type: 'mysql', host: 'localhost', port: 3306 }}
|
|
dbName="app"
|
|
filePath="/tmp/database.sql"
|
|
fileSizeMB="12.5"
|
|
darkMode={false}
|
|
continueOnError={continueOnError}
|
|
onRunningChange={onRunningChange}
|
|
/>,
|
|
);
|
|
await Promise.resolve();
|
|
});
|
|
return renderer;
|
|
};
|
|
|
|
describe('DatabaseImportExecutionPanel', () => {
|
|
beforeEach(() => {
|
|
setCurrentLanguage('en-US');
|
|
mocks.state = createRunnerState();
|
|
mocks.isRunning = false;
|
|
mocks.lastRunOptions = null;
|
|
mocks.importDatabaseSQL.mockReset();
|
|
mocks.cancelSQLFileExecution.mockReset();
|
|
mocks.reset.mockReset();
|
|
mocks.modalConfirm.mockReset();
|
|
mocks.run.mockReset();
|
|
mocks.cancel.mockReset();
|
|
mocks.run.mockImplementation(async (options: any) => {
|
|
mocks.lastRunOptions = options;
|
|
return options.run('database-import-job-1');
|
|
});
|
|
mocks.cancel.mockImplementation(async () => {
|
|
await mocks.lastRunOptions?.cancel?.('database-import-job-1');
|
|
});
|
|
});
|
|
|
|
it('uses shared theme tokens for its inset execution surface', async () => {
|
|
const renderer = await renderPanel();
|
|
const statusCard = renderer.root.findByProps({
|
|
'data-database-import-status-card': 'true',
|
|
});
|
|
|
|
expect(statusCard.props.style.background).toContain('var(--gn-bg-panel-2');
|
|
expect(statusCard.props.style.border).toContain('var(--gn-br-1');
|
|
});
|
|
|
|
it('waits for an explicit start action and reports the full RPC lifetime as running', async () => {
|
|
let resolveImport!: (value: { success: boolean; message: string }) => void;
|
|
mocks.importDatabaseSQL.mockReturnValue(new Promise((resolve) => {
|
|
resolveImport = resolve;
|
|
}));
|
|
const onRunningChange = vi.fn();
|
|
const renderer = await renderPanel({ onRunningChange });
|
|
|
|
expect(mocks.importDatabaseSQL).not.toHaveBeenCalled();
|
|
const startButton = renderer.root.findByProps({
|
|
'data-database-import-start-action': 'true',
|
|
});
|
|
|
|
await act(async () => {
|
|
startButton.props.onClick();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(mocks.importDatabaseSQL).toHaveBeenCalledWith(
|
|
expect.objectContaining({ type: 'mysql' }),
|
|
'app',
|
|
'/tmp/database.sql',
|
|
'database-import-job-1',
|
|
false,
|
|
);
|
|
expect(onRunningChange).toHaveBeenLastCalledWith(true);
|
|
expect(renderer.root.findAllByProps({
|
|
'data-database-import-cancel-action': 'true',
|
|
}).length).toBeGreaterThan(0);
|
|
await act(async () => {
|
|
resolveImport({ success: true, message: 'done' });
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
expect(onRunningChange).toHaveBeenLastCalledWith(false);
|
|
});
|
|
|
|
it('passes an explicit continue-on-error choice to the database import RPC', async () => {
|
|
mocks.importDatabaseSQL.mockResolvedValue({
|
|
success: false,
|
|
data: { completed: true, failed: 1 },
|
|
message: 'completed with errors',
|
|
});
|
|
const renderer = await renderPanel({ continueOnError: true });
|
|
await act(async () => {
|
|
renderer.root.findByProps({
|
|
'data-database-import-start-action': 'true',
|
|
}).props.onClick();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(mocks.importDatabaseSQL).toHaveBeenCalledWith(
|
|
expect.objectContaining({ type: 'mysql' }),
|
|
'app',
|
|
'/tmp/database.sql',
|
|
'database-import-job-1',
|
|
true,
|
|
);
|
|
});
|
|
|
|
it('cancels the active SQL import with the runner job id', async () => {
|
|
let resolveImport!: (value: { success: boolean; message: string }) => void;
|
|
mocks.importDatabaseSQL.mockReturnValue(new Promise((resolve) => {
|
|
resolveImport = resolve;
|
|
}));
|
|
mocks.cancelSQLFileExecution.mockResolvedValue({ success: true });
|
|
const renderer = await renderPanel();
|
|
|
|
await act(async () => {
|
|
renderer.root.findByProps({
|
|
'data-database-import-start-action': 'true',
|
|
}).props.onClick();
|
|
await Promise.resolve();
|
|
});
|
|
await act(async () => {
|
|
renderer.root.findByProps({
|
|
'data-database-import-cancel-action': 'true',
|
|
}).props.onClick();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(mocks.cancelSQLFileExecution).toHaveBeenCalledWith('database-import-job-1');
|
|
|
|
await act(async () => {
|
|
resolveImport({ success: false, message: 'cancelled' });
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
['done', 'success', 'completed'],
|
|
['error', 'error', 'failed'],
|
|
['cancelled', 'warning', 'cancelled'],
|
|
] as const)('renders %s progress and result state', async (status, alertType, message) => {
|
|
mocks.state = createRunnerState({
|
|
jobId: 'database-import-job-1',
|
|
status,
|
|
stage: status,
|
|
filePath: '/tmp/database.sql',
|
|
executed: 24,
|
|
failed: status === 'error' ? 1 : 0,
|
|
total: 25,
|
|
percent: status === 'done' ? 100 : 96,
|
|
currentSQL: 'CREATE TABLE demo(id INT)',
|
|
message,
|
|
});
|
|
const renderer = await renderPanel();
|
|
|
|
expect(renderer.root.findByProps({
|
|
'data-database-import-progress': 'true',
|
|
}).props.percent).toBe(status === 'done' ? 100 : 96);
|
|
const resultAlert = renderer.root.findByProps({
|
|
'data-database-import-result': 'true',
|
|
});
|
|
expect(resultAlert.props.type).toBe(alertType);
|
|
expect(resultAlert.props.message.props.children).toBe(message);
|
|
expect(renderer.root.findAllByProps({
|
|
'data-database-import-current-sql': 'true',
|
|
})).toHaveLength(1);
|
|
});
|
|
|
|
it('renders a completed continue-on-error run as a warning instead of a fatal failure', async () => {
|
|
mocks.state = createRunnerState({
|
|
jobId: 'database-import-job-1',
|
|
status: 'done',
|
|
stage: 'done',
|
|
filePath: '/tmp/database.sql',
|
|
executed: 24,
|
|
failed: 1,
|
|
total: 25,
|
|
percent: 100,
|
|
message: 'completed with errors',
|
|
});
|
|
const renderer = await renderPanel();
|
|
|
|
expect(renderer.root.findByProps({
|
|
'data-database-import-result': 'true',
|
|
}).props.type).toBe('warning');
|
|
expect(renderer.root.findByProps({
|
|
'data-database-import-progress': 'true',
|
|
}).props).toMatchObject({
|
|
percent: 100,
|
|
status: 'normal',
|
|
strokeColor: 'var(--gn-warn, #faad14)',
|
|
});
|
|
});
|
|
|
|
it('renders byte progress, throughput and ETA for a large SQL source', async () => {
|
|
mocks.state = createRunnerState({
|
|
jobId: 'database-import-job-1',
|
|
status: 'running',
|
|
stage: 'preflight',
|
|
bytesRead: 10 * 1024 * 1024,
|
|
totalBytes: 20 * 1024 * 1024,
|
|
bytesPerSecond: 1024 * 1024,
|
|
etaSeconds: 10,
|
|
percent: 50,
|
|
});
|
|
const renderer = await renderPanel();
|
|
|
|
const metrics = renderer.root.findByProps({
|
|
'data-database-import-transfer-metrics': 'true',
|
|
});
|
|
expect(String(metrics.props.children)).toContain('10.0 MB / 20.0 MB');
|
|
expect(String(metrics.props.children)).toContain('1.0 MB/s');
|
|
expect(String(metrics.props.children)).toContain('10s');
|
|
expect(renderer.root.findByProps({
|
|
'data-database-import-stage': 'true',
|
|
}).props.children).toBe('Running preflight checks');
|
|
});
|
|
|
|
it('requires a second confirmation before rerunning the whole SQL file', async () => {
|
|
mocks.state = createRunnerState({
|
|
jobId: 'database-import-job-1',
|
|
status: 'done',
|
|
stage: 'done',
|
|
filePath: '/tmp/database.sql',
|
|
percent: 100,
|
|
});
|
|
const renderer = await renderPanel();
|
|
|
|
await act(async () => {
|
|
renderer.root.findByProps({
|
|
'data-database-import-start-action': 'true',
|
|
}).props.onClick();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(mocks.modalConfirm).toHaveBeenCalledTimes(1);
|
|
expect(mocks.run).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
await mocks.modalConfirm.mock.calls[0][0].onOk();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(mocks.run).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|