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; theme?: string; value?: string; }) => { beforeMount?.(mockMonaco); onMount?.(mockEditor, mockMonaco); return (
{value}
); }, })); 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; 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; 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; 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( , ); 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 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( , ); 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( , ); 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, }), ); }); });