mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-22 21:27:11 +08:00
- 新增共享六语言词典、前端 i18n 运行时与语言设置入口 - 推进连接、驱动、数据网格、查询、AI、Redis、表设计等模块文案本地化 - 补充 raw 边界、SQL/驱动/更新场景测试与 i18n 扫描工具
318 lines
10 KiB
TypeScript
318 lines
10 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
|
|
|
import React from 'react';
|
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import TableDesignerSqlPreview, { resolveSqlChangeHighlights } from './TableDesignerSqlPreview';
|
|
|
|
const tableDesignerColumnI18nKeys = [
|
|
'table_designer.column.name',
|
|
'table_designer.column.type',
|
|
'table_designer.column.primary_key',
|
|
'table_designer.column.auto_increment',
|
|
'table_designer.column.not_null',
|
|
'table_designer.column.default',
|
|
'table_designer.column.comment',
|
|
'table_designer.column.actions',
|
|
'table_designer.tooltip.edit_comment_popup',
|
|
] as const;
|
|
|
|
const sharedI18nDir = new URL('../../../shared/i18n/', import.meta.url);
|
|
const sharedI18nLocaleFiles = [
|
|
'de-DE.json',
|
|
'en-US.json',
|
|
'ja-JP.json',
|
|
'ru-RU.json',
|
|
'zh-CN.json',
|
|
'zh-TW.json',
|
|
] as const;
|
|
|
|
const sliceBetween = (source: string, start: string, end: string): string => {
|
|
const startIndex = source.indexOf(start);
|
|
const endIndex = source.indexOf(end, startIndex + start.length);
|
|
|
|
expect(startIndex).toBeGreaterThanOrEqual(0);
|
|
expect(endIndex).toBeGreaterThan(startIndex);
|
|
|
|
return source.slice(startIndex, endIndex);
|
|
};
|
|
|
|
const mockMonaco = {
|
|
Range: class {
|
|
startLineNumber: number;
|
|
startColumn: number;
|
|
endLineNumber: number;
|
|
endColumn: number;
|
|
|
|
constructor(
|
|
startLineNumber: number,
|
|
startColumn: number,
|
|
endLineNumber: number,
|
|
endColumn: number,
|
|
) {
|
|
this.startLineNumber = startLineNumber;
|
|
this.startColumn = startColumn;
|
|
this.endLineNumber = endLineNumber;
|
|
this.endColumn = endColumn;
|
|
}
|
|
},
|
|
editor: {
|
|
defineTheme: vi.fn(),
|
|
},
|
|
};
|
|
|
|
const mockEditor = {
|
|
deltaDecorations: vi.fn(() => ['decoration-1']),
|
|
getModel: vi.fn(() => ({
|
|
getLineCount: () => 5,
|
|
getLineMaxColumn: (lineNumber: number) => (lineNumber === 1 ? 22 : 80),
|
|
})),
|
|
};
|
|
|
|
vi.mock('@monaco-editor/react', () => ({
|
|
default: ({
|
|
beforeMount,
|
|
defaultLanguage,
|
|
language,
|
|
onMount,
|
|
options,
|
|
theme,
|
|
value,
|
|
}: {
|
|
beforeMount?: (monaco: any) => void;
|
|
defaultLanguage?: string;
|
|
language?: string;
|
|
onMount?: (editor: any, monaco: any) => void;
|
|
options?: Record<string, any>;
|
|
theme?: string;
|
|
value?: string;
|
|
}) => {
|
|
beforeMount?.(mockMonaco);
|
|
onMount?.(mockEditor, mockMonaco);
|
|
return (
|
|
<div
|
|
data-default-language={defaultLanguage}
|
|
data-language={language}
|
|
data-monaco-editor-mock="true"
|
|
data-options={JSON.stringify(options)}
|
|
data-theme={theme}
|
|
>
|
|
{value}
|
|
</div>
|
|
);
|
|
},
|
|
}));
|
|
|
|
describe('TableDesignerSqlPreview', () => {
|
|
beforeEach(() => {
|
|
mockEditor.deltaDecorations.mockClear();
|
|
mockMonaco.editor.defineTheme.mockClear();
|
|
});
|
|
|
|
it('keeps TableDesigner initial column labels in i18n catalogs', () => {
|
|
const source = readFileSync(new URL('./TableDesigner.tsx', import.meta.url), 'utf8');
|
|
const initialColumnsDefinition = sliceBetween(
|
|
source,
|
|
'// Initial Columns Definition',
|
|
'setTableColumns(initialCols);',
|
|
);
|
|
|
|
for (const localeFile of sharedI18nLocaleFiles) {
|
|
const catalog = JSON.parse(readFileSync(new URL(localeFile, sharedI18nDir), 'utf8')) as Record<string, string>;
|
|
for (const key of tableDesignerColumnI18nKeys) {
|
|
expect(catalog[key], `${localeFile} ${key}`).toBeTruthy();
|
|
}
|
|
}
|
|
|
|
for (const key of tableDesignerColumnI18nKeys) {
|
|
expect(initialColumnsDefinition).toContain(`t('${key}'`);
|
|
}
|
|
expect(initialColumnsDefinition).toContain('i18nLanguage');
|
|
|
|
for (const literal of [
|
|
"'名'",
|
|
"'类型'",
|
|
"'主键'",
|
|
"'自增'",
|
|
"'不是 Null'",
|
|
"'不是 NULL'",
|
|
"'默认'",
|
|
"'注释'",
|
|
"'操作'",
|
|
'"弹框编辑注释"',
|
|
"'弹框编辑注释'",
|
|
]) {
|
|
expect(initialColumnsDefinition).not.toContain(literal);
|
|
}
|
|
});
|
|
|
|
it('does not ship corrupted table designer i18n catalog strings', () => {
|
|
const badLocalizedValuePattern = /\?{2,}|\uFFFD|f\?r|verf\?gbar|Schl\?ssel|Zuf\?llige|Schreibgesch\?tzt|OLAP \?|\{\{count\}\} \?/;
|
|
const badValues: string[] = [];
|
|
|
|
for (const localeFile of sharedI18nLocaleFiles) {
|
|
const catalog = JSON.parse(readFileSync(new URL(localeFile, sharedI18nDir), 'utf8')) as Record<string, unknown>;
|
|
|
|
for (const [key, value] of Object.entries(catalog)) {
|
|
if (
|
|
key.startsWith('table_designer.')
|
|
&& typeof value === 'string'
|
|
&& badLocalizedValuePattern.test(value)
|
|
) {
|
|
badValues.push(`${localeFile} ${key}=${value}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
expect(badValues).toEqual([]);
|
|
});
|
|
|
|
it('keeps generated SQL fallback text independent from UI locale', () => {
|
|
const source = readFileSync(new URL('./TableDesigner.tsx', import.meta.url), 'utf8');
|
|
|
|
for (const forbiddenSnippet of [
|
|
"t('table_designer.trigger.template.body_comment'",
|
|
"t('table_designer.trigger.template.enter_create'",
|
|
"t('table_designer.trigger.definition_unavailable'",
|
|
]) {
|
|
expect(source).not.toContain(forbiddenSnippet);
|
|
}
|
|
|
|
for (const forbiddenSqlFallbackPattern of [
|
|
/return\s+result\.sql\s*\|\|[^\n]*t\('table_designer\.message\.index_create_sql_placeholder'/,
|
|
/return\s+result\.sql\s*\|\|[^\n]*t\('table_designer\.message\.index_create_sql_unavailable'/,
|
|
]) {
|
|
expect(source).not.toMatch(forbiddenSqlFallbackPattern);
|
|
}
|
|
});
|
|
|
|
it('keeps TableDesigner load failures localized without translating raw details', () => {
|
|
const source = readFileSync(new URL('./TableDesigner.tsx', import.meta.url), 'utf8');
|
|
|
|
expect(source).toContain("t('table_designer.message.connection_not_found'");
|
|
expect(source).toContain("t('table_designer.message.load_columns_failed', { detail: colsRes.message }");
|
|
expect(source).not.toContain('message.error("Connection not found")');
|
|
expect(source).not.toContain('message.error("Failed to load columns: " + colsRes.message)');
|
|
});
|
|
|
|
it('renders SQL changes in a read-only Monaco SQL editor with explicit syntax highlight theme', () => {
|
|
const markup = renderToStaticMarkup(
|
|
<TableDesignerSqlPreview
|
|
sql={'ALTER TABLE "users"\nRENAME COLUMN "name" TO "display_name";'}
|
|
darkMode={false}
|
|
/>,
|
|
);
|
|
|
|
expect(markup).toContain('data-table-designer-sql-preview="true"');
|
|
expect(markup).toContain('data-monaco-editor-mock="true"');
|
|
expect(markup).toContain('data-default-language="sql"');
|
|
expect(markup).toContain('data-language="sql"');
|
|
expect(markup).toContain('data-theme="gonavi-sql-preview-light"');
|
|
expect(markup).toContain('"readOnly":true');
|
|
expect(markup).toContain('"lineNumbers":"on"');
|
|
expect(markup).not.toContain('"glyphMargin":true');
|
|
expect(markup).toContain('ALTER TABLE');
|
|
expect(markup).toContain('RENAME COLUMN');
|
|
|
|
expect(mockMonaco.editor.defineTheme).toHaveBeenCalledWith(
|
|
'gonavi-sql-preview-light',
|
|
expect.objectContaining({
|
|
base: 'vs',
|
|
inherit: true,
|
|
rules: expect.arrayContaining([
|
|
expect.objectContaining({ token: 'keyword', foreground: expect.any(String) }),
|
|
expect.objectContaining({ token: 'string', foreground: expect.any(String) }),
|
|
expect.objectContaining({ token: 'comment', foreground: expect.any(String) }),
|
|
]),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('detects only SQL change operation lines instead of highlighting the whole SQL block', () => {
|
|
const highlights = resolveSqlChangeHighlights([
|
|
'ALTER TABLE "users"',
|
|
'ADD COLUMN "age" int NULL;',
|
|
'ALTER TABLE "users"',
|
|
'RENAME COLUMN "name" TO "display_name";',
|
|
'-- DuckDB 不支持通过 COMMENT ON COLUMN 持久化字段备注',
|
|
].join('\n'));
|
|
|
|
expect(highlights).toEqual([
|
|
expect.objectContaining({ kind: 'add', lineNumber: 2 }),
|
|
expect.objectContaining({ kind: 'rename', lineNumber: 4 }),
|
|
]);
|
|
});
|
|
|
|
it('detects CREATE INDEX preview lines as create changes', () => {
|
|
const highlights = resolveSqlChangeHighlights(
|
|
'CREATE UNIQUE NONCLUSTERED INDEX [IX_Users_Email] ON [dbo].[Users] ([email]);',
|
|
);
|
|
|
|
expect(highlights).toEqual([
|
|
expect.objectContaining({ kind: 'create', lineNumber: 1, label: '新建索引' }),
|
|
]);
|
|
});
|
|
|
|
it('adds Monaco decorations to changed SQL lines only', () => {
|
|
renderToStaticMarkup(
|
|
<TableDesignerSqlPreview
|
|
sql={[
|
|
'ALTER TABLE "users"',
|
|
'ADD COLUMN "age" int NULL;',
|
|
'ALTER TABLE "users"',
|
|
'DROP COLUMN "legacy_name";',
|
|
].join('\n')}
|
|
/>,
|
|
);
|
|
|
|
expect(mockEditor.deltaDecorations).toHaveBeenCalledWith(
|
|
[],
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
range: expect.objectContaining({ startLineNumber: 2, endLineNumber: 2 }),
|
|
options: expect.objectContaining({
|
|
className: expect.stringContaining('gonavi-sql-preview-change-line-add'),
|
|
isWholeLine: true,
|
|
linesDecorationsClassName: expect.stringContaining('gonavi-sql-preview-change-marker-add'),
|
|
}),
|
|
}),
|
|
expect.objectContaining({
|
|
range: expect.objectContaining({ startLineNumber: 4, endLineNumber: 4 }),
|
|
options: expect.objectContaining({
|
|
className: expect.stringContaining('gonavi-sql-preview-change-line-drop'),
|
|
isWholeLine: true,
|
|
linesDecorationsClassName: expect.stringContaining('gonavi-sql-preview-change-marker-drop'),
|
|
}),
|
|
}),
|
|
]),
|
|
);
|
|
const firstDecorationCall = mockEditor.deltaDecorations.mock.calls[0] as unknown as [unknown, unknown[]];
|
|
expect(firstDecorationCall[1]).toHaveLength(2);
|
|
expect(firstDecorationCall[1]).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
options: expect.not.objectContaining({
|
|
glyphMarginClassName: expect.any(String),
|
|
}),
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('uses the dark SQL preview theme when dark mode is enabled', () => {
|
|
const markup = renderToStaticMarkup(
|
|
<TableDesignerSqlPreview sql="CREATE TABLE users (id int);" darkMode />,
|
|
);
|
|
|
|
expect(markup).toContain('data-theme="gonavi-sql-preview-dark"');
|
|
expect(mockMonaco.editor.defineTheme).toHaveBeenCalledWith(
|
|
'gonavi-sql-preview-dark',
|
|
expect.objectContaining({
|
|
base: 'vs-dark',
|
|
inherit: true,
|
|
}),
|
|
);
|
|
});
|
|
});
|