import React from 'react'; import { readFileSync } from 'node:fs'; import { renderToStaticMarkup } from 'react-dom/server'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { TAB_WORKBENCH_CLASS_NAME, resolveTabHoverOpen, resolveTabHoverTitle, shouldShowV2ConnectionLabel, TabHoverInfo, stopTabHoverDragPropagation, } from './TabManager'; import { setCurrentLanguage } from '../i18n'; import type { TabData } from '../types'; import { buildTabDisplayModel } from '../utils/tabDisplay'; const stripSourceComments = (source: string): string => source .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/^\s*\/\/.*$/gm, ''); afterEach(() => { setCurrentLanguage('zh-CN'); }); describe('TabManager hover info', () => { it('memoizes the tab workbench so parent-only modal state does not repaint open tabs', () => { const source = readFileSync(new URL('./TabManager.tsx', import.meta.url), 'utf8'); expect(source).toContain('const TabManager: React.FC = React.memo(() => {'); }); it('keeps the tab workbench as a full-height flex child in legacy and v2 UI', () => { const source = readFileSync(new URL('./TabManager.tsx', import.meta.url), 'utf8'); expect(TAB_WORKBENCH_CLASS_NAME).toBe('tab-workbench'); expect(source).toContain("className={`${TAB_WORKBENCH_CLASS_NAME}${isV2Ui ? ' gn-v2-tab-workbench' : ''}`}"); expect(source).toContain('.${TAB_WORKBENCH_CLASS_NAME} {'); expect(source).toMatch(/\.\$\{TAB_WORKBENCH_CLASS_NAME\} \{[\s\S]*height: 100%;[\s\S]*flex: 1 1 auto;[\s\S]*min-height: 0;[\s\S]*display: flex;[\s\S]*flex-direction: column;[\s\S]*overflow: hidden;/); }); it('renders en-US v2 tab hover context for table tabs while keeping raw values raw', () => { setCurrentLanguage('en-US'); const tab: TabData = { id: 'conn-1-main-users', title: 'users', type: 'table', connectionId: 'conn-1', tableName: '客户表', }; const markup = renderToStaticMarkup( , ); expect(markup).toContain('data-tab-hover-info="true"'); expect(markup).toContain('[开发240] 表概览'); expect(markup).toContain('Type'); expect(markup).toContain('Table data'); expect(markup).toContain('Connection'); expect(markup).toContain('Unbound connection'); expect(markup).toContain('Host'); expect(markup).toContain('Not configured'); expect(markup).toContain('Database'); expect(markup).toContain('Not specified'); expect(markup).toContain('Object'); expect(markup).toContain('客户表'); expect(markup).not.toContain('类型'); expect(markup).not.toContain('表数据'); expect(markup).not.toContain('未绑定连接'); expect(markup).not.toContain('未配置'); expect(markup).not.toContain('数据库'); expect(markup).not.toContain('未指定'); expect(markup).not.toContain('对象'); }); it('renders db identity for redis tabs without a database name', () => { setCurrentLanguage('en-US'); const tab: TabData = { id: 'redis-keys-conn-1-db2', title: 'db2', type: 'redis-keys', connectionId: 'conn-1', redisDB: 2, }; const markup = renderToStaticMarkup( , ); expect(markup).toContain('Redis[缓存 | 10.0.0.8] db2'); expect(markup).not.toContain('REDIS'); expect(markup).toContain('Redis Key'); expect(markup).toContain('Not specified'); expect(markup).toContain('db2'); }); it('keeps compact v2 tab kind badges catalog-backed instead of raw source abbreviations', () => { const source = readFileSync(new URL('./TabManager.tsx', import.meta.url), 'utf8'); const getTabKindLabelSource = source.slice( source.indexOf('const getTabKindLabel = (tab: TabData): string => {'), source.indexOf('export const TAB_WORKBENCH_CLASS_NAME'), ); [ 'query', 'table', 'design', 'table_overview', 'redis', 'jvm', 'trigger', 'materialized_view', 'view', 'event', 'routine', 'fallback', ].forEach((name) => { expect(getTabKindLabelSource).toContain(`t('tab_manager.kind_badge.${name}')`); }); expect(getTabKindLabelSource).not.toMatch(/return '(TABLE|DESIGN|DB|REDIS|JVM|TRG|MV|VIEW|EVT|FUNC|TAB)'/); }); it('keeps v2 hover title focused on the tab object instead of appending secondary display fields', () => { const tab: TabData = { id: 'overview-1', title: '表概览 - front_end_sys', type: 'table-overview', connectionId: 'conn-1', dbName: 'front_end_sys', }; const displayModel = buildTabDisplayModel(tab, { id: 'conn-1', name: '开发240', config: { type: 'mysql', host: '192.168.1.240', port: 3306, user: 'root', database: 'front_end_sys', }, }, { layout: 'double', primaryElements: ['object', 'kind'], secondaryElements: ['connection', 'database'], }); expect(displayModel.fullTitle).toContain('[开发240]'); expect(resolveTabHoverTitle(displayModel, displayModel.fullTitle)).toBe('表概览 - front_end_sys'); }); it('stops hover card pointer events from reaching tab drag listeners without blocking text selection', () => { const event = { preventDefault: vi.fn(), stopPropagation: vi.fn(), } as unknown as React.SyntheticEvent; stopTabHoverDragPropagation(event); expect(event.preventDefault).not.toHaveBeenCalled(); expect(event.stopPropagation).toHaveBeenCalledTimes(1); }); it('keeps tab hover hidden while the tab context menu is open', () => { expect(resolveTabHoverOpen(true, false)).toBe(true); expect(resolveTabHoverOpen(true, true)).toBe(false); expect(resolveTabHoverOpen(false, true)).toBe(false); }); it('opens tab display settings from the v2 tab context menu', () => { const source = readFileSync(new URL('./TabManager.tsx', import.meta.url), 'utf8'); expect(source).toContain("new CustomEvent('gonavi:open-tab-display-settings')"); expect(source).toContain("if (typeof window === 'undefined')"); }); it('hides the v2 gray connection suffix when the title already carries the same prefix', () => { expect(shouldShowV2ConnectionLabel('[本地] videos', '本地')).toBe(false); expect(shouldShowV2ConnectionLabel('[缓存 | 10.0.0.8] db2', '缓存')).toBe(false); expect(shouldShowV2ConnectionLabel('新建查询', '本地')).toBe(true); }); it('does not render the v2 leading icon or connection accent dot in tab labels', () => { const source = readFileSync(new URL('./TabManager.tsx', import.meta.url), 'utf8'); expect(source).not.toContain('gn-v2-tab-kind-icon'); expect(source).not.toContain('tab-connection-accent'); expect(source).not.toContain('has-connection-accent'); expect(source).not.toContain('resolveConnectionAccentColor'); }); it('keeps v2 TabManager shell copy in the i18n catalog instead of hardcoded Chinese', () => { const source = stripSourceComments(readFileSync(new URL('./TabManager.tsx', import.meta.url), 'utf8')); [ '类型', '表数据', '连接', '未绑定连接', '未配置', '数据库', '未指定', '对象', 'SQL 查询', '表设计', '表概览', 'Redis 命令', 'Redis 监控', 'JVM 概览', 'JVM 资源', 'JVM 审计', 'JVM 诊断', 'JVM 监控', '触发器', '物化视图', '视图', '事件', '函数 / 过程', '标签页', '关闭其他页', '关闭左侧', '关闭右侧', '关闭所有', '关闭 ', 'GoNavi 起始工作台', '快捷工作流', '连接、查询和分析从同一个工作台开始。', '选择数据源、打开查询编辑器,或把上下文交给 AI 面板继续处理。', '打开 AI', '配置数据源', 'URI、SSH、代理和驱动集中设置', '启动 SQL 工作区', '按当前上下文打开查询编辑器', '进入 AI 辅助', '解释 SQL、生成查询、梳理结果', ].forEach((text) => { expect(source).not.toContain(text); }); }); it('subscribes TabManager and memoized localized tab items to languagePreference', () => { const source = readFileSync(new URL('./TabManager.tsx', import.meta.url), 'utf8'); expect(source).toMatch(/const languagePreference = useStore\(\(?state\)? => state\.languagePreference\);/); expect(source).toMatch(/const items = useMemo\([\s\S]*\), \[[^\]]*languagePreference[^\]]*\]\);/); }); it('renders tab labels from appearance tab display settings', () => { const source = readFileSync(new URL('./TabManager.tsx', import.meta.url), 'utf8'); expect(source).toContain('buildTabDisplayModel(tab, connection, appearance.tabDisplay)'); expect(source).toContain('displayModel={displayModel}'); expect(source).toContain('displayModel.primaryParts.map(renderV2TabDisplayPart)'); expect(source).toContain("if (part.key === 'kind')"); expect(source).toContain('className="gn-v2-tab-kind"'); expect(source).toContain('hasDoubleLineTabLabel'); expect(source).toContain('gn-v2-main-tabs-double'); expect(source).toContain('showSecondaryLine'); expect(source).toContain('gn-v2-tab-label-secondary'); expect(source).toContain('gn-v2-tab-label-rich'); expect(source).toContain('gn-v2-tab-label-double'); expect(source).toContain('gn-v2-tab-label-main tab-title-text'); expect(source).toContain("key: 'tab-display-settings'"); expect(source).toContain('label: \'标签设置\''); expect(source).toContain('icon: '); expect(source).toContain('onClick: openTabDisplaySettings'); expect(source).toContain("rootClassName={isV2Ui ? 'gn-v2-tab-context-menu-popup' : undefined}"); expect(source).not.toContain('gn-v2-main-tabs-rich'); }); it('wires hover card tab-switch and drag-blocking handlers with selectable text styles', () => { const source = readFileSync(new URL('./TabManager.tsx', import.meta.url), 'utf8'); expect(source).toContain('onPointerDown={stopTabHoverDragPropagation}'); expect(source).toContain('onPointerUp={stopTabHoverDragPropagation}'); expect(source).toContain('onPointerDownCapture={stopTabHoverDragPropagation}'); expect(source).toContain('onMouseDown={stopTabHoverDragPropagation}'); expect(source).toContain('onMouseUp={stopTabHoverDragPropagation}'); expect(source).toContain('onClick={stopTabHoverDragPropagation}'); expect(source).toContain('onClickCapture={stopTabHoverDragPropagation}'); expect(source).toContain('onTouchStart={stopTabHoverDragPropagation}'); expect(source).toContain('onTouchEnd={stopTabHoverDragPropagation}'); expect(source).toContain('setIsHoverInfoOpen(false);'); expect(source).toContain('setIsTabMenuOpen(true);'); expect(source).toContain('open={resolveTabHoverOpen(isHoverInfoOpen, isTabMenuOpen)}'); expect(source).toContain('onOpenChange={handleHoverInfoOpenChange}'); expect(source).toContain('onOpenChange={handleTabMenuOpenChange}'); expect(source).toContain('mouseEnterDelay={1.2}'); expect(source).toMatch(/\.gn-v2-tab-hover-card \{[^}]*cursor: text;[^}]*user-select: text;/s); expect(source).toContain("--gn-v2-tab-hover-grid-columns: 56px minmax(0, 1fr);"); expect(source).toMatch(/\.gn-v2-tab-hover-head \{[^}]*display: grid;[^}]*grid-template-columns: var\(--gn-v2-tab-hover-grid-columns\);/s); expect(source).toMatch(/\.gn-v2-tab-hover-head > strong \{[^}]*overflow-wrap: anywhere;[^}]*white-space: normal;/s); expect(source).toMatch(/\.gn-v2-tab-hover-row \{[^}]*grid-template-columns: var\(--gn-v2-tab-hover-grid-columns\);/s); expect(source).toMatch(/\.gn-v2-tab-hover-card \* \{[^}]*user-select: text;/s); }); it('guards closing opened SQL file tabs with save confirmation', () => { const source = readFileSync(new URL('./TabManager.tsx', import.meta.url), 'utf8'); expect(source).toContain('ReadSQLFile(filePath)'); expect(source).toContain('isSQLFileMissingReadResult(res)'); expect(source).toContain('isSQLFileMissingErrorMessage(errorMessage)'); expect(source).toContain("title: '关闭已丢失的 SQL 文件标签?'"); expect(source).toContain('关闭后将丢弃标签内的本地草稿'); expect(source).toContain('confirmDirtyTabsOrClose();'); expect(source).toContain("getSQLFileTabDraft(tab.id, String(tab.query ?? ''))"); expect(source).toContain('hasSQLFileTabUnsavedChanges({ ...tab, query: draft }, normalizeSQLFileReadContent(res.data))'); expect(source).toContain("title: '保存 SQL 文件修改?'"); expect(source).toContain("okText: '保存并关闭'"); expect(source).toContain('不保存'); expect(source).toContain('WriteSQLFile(filePath, draft)'); expect(source).toContain('clearSQLFileTabDraft(tab.id)'); expect(source).toContain('closeTabsWithSQLFilePrompt([id], () => closeTab(id))'); expect(source).toContain('closeTabsWithSQLFilePrompt(getCloseOtherTabIds(tabs, tab.id), () => closeOtherTabs(tab.id))'); expect(source).toContain('closeTabsWithSQLFilePrompt(getCloseTabsToLeftIds(tabs, tab.id), () => closeTabsToLeft(tab.id))'); expect(source).toContain('closeTabsWithSQLFilePrompt(getCloseTabsToRightIds(tabs, tab.id), () => closeTabsToRight(tab.id))'); expect(source).toContain('closeTabsWithSQLFilePrompt(tabs.map((item) => item.id), () => closeAllTabs())'); }); });