Files
MyGoNavi/frontend/src/components/TableDesignerSqlPreview.test.tsx
2026-06-22 15:12:42 +08:00

368 lines
12 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 tableDesignerSqlPreviewChangeKeys = [
'table_designer.sql_preview.change.add',
'table_designer.sql_preview.change.comment',
'table_designer.sql_preview.change.constraint',
'table_designer.sql_preview.change.create',
'table_designer.sql_preview.change.create_index',
'table_designer.sql_preview.change.drop',
'table_designer.sql_preview.change.modify',
'table_designer.sql_preview.change.rename',
] 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 SQL preview change labels in i18n catalogs without shipping Chinese source labels', () => {
const source = readFileSync(new URL('./TableDesignerSqlPreview.tsx', import.meta.url), 'utf8');
for (const localeFile of sharedI18nLocaleFiles) {
const catalog = JSON.parse(readFileSync(new URL(localeFile, sharedI18nDir), 'utf8')) as Record<string, string>;
for (const key of tableDesignerSqlPreviewChangeKeys) {
expect(catalog[key], `${localeFile} ${key}`).toBeTruthy();
}
}
for (const literal of [
'重命名变更',
'新增变更',
'删除变更',
'字段属性变更',
'约束变更',
'备注变更',
'新建索引',
'新建表结构',
]) {
expect(source).not.toContain(literal);
}
});
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('&quot;readOnly&quot;:true');
expect(markup).toContain('&quot;lineNumbers&quot;:&quot;on&quot;');
expect(markup).not.toContain('&quot;glyphMargin&quot;: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 translate = (key: string) => `translated:${key}`;
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'), translate);
expect(highlights).toEqual([
expect.objectContaining({
kind: 'add',
lineNumber: 2,
label: 'translated:table_designer.sql_preview.change.add',
}),
expect.objectContaining({
kind: 'rename',
lineNumber: 4,
label: 'translated:table_designer.sql_preview.change.rename',
}),
]);
});
it('detects CREATE INDEX preview lines as create changes', () => {
const translate = (key: string) => `translated:${key}`;
const highlights = resolveSqlChangeHighlights(
'CREATE UNIQUE NONCLUSTERED INDEX [IX_Users_Email] ON [dbo].[Users] ([email]);',
translate,
);
expect(highlights).toEqual([
expect.objectContaining({
kind: 'create',
lineNumber: 1,
label: 'translated:table_designer.sql_preview.change.create_index',
}),
]);
});
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,
}),
);
});
});