import { fireEvent, render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' import Sidebar from '../../src/components/layout/Sidebar' import { useSessionStore, type Session } from '../../src/stores/sessionStore' import { useTabStore } from '../../src/stores/tabStore' import { unlockProForTests } from '../helpers/entitlement' import { mockDesktopApi } from '../setup' function makeSession(overrides: Partial & Pick): Session { return { host: `${overrides.id}.example.com`, port: 22, username: 'root', authType: 'key', ...overrides, } } function rowOf(name: string): HTMLElement { const row = screen.getByText(name).closest('[data-session-row]') if (!row) throw new Error(`session row not found: ${name}`) return row as HTMLElement } function mockElementFromPoint(target: Element) { const original = document.elementFromPoint Object.defineProperty(document, 'elementFromPoint', { configurable: true, value: vi.fn(() => target), }) return () => { Object.defineProperty(document, 'elementFromPoint', { configurable: true, value: original, }) } } /** Pointer-drag a session row onto `dropOn` (WKWebView-safe path, not HTML5 DnD). */ function pointerDrag(fromName: string, dropOn: Element) { const source = rowOf(fromName) const restore = mockElementFromPoint(dropOn) fireEvent.pointerDown(source, { button: 0, pointerId: 1, clientX: 10, clientY: 10 }) fireEvent.pointerMove(source, { pointerId: 1, clientX: 30, clientY: 40 }) fireEvent.pointerUp(source, { button: 0, pointerId: 1, clientX: 30, clientY: 40 }) restore() } describe('sidebar session manual order', () => { beforeEach(() => { vi.clearAllMocks() unlockProForTests() window.opsxApi = mockDesktopApi useSessionStore.setState({ sessions: [] }) useTabStore.setState({ tabs: [{ id: 'welcome', title: '欢迎', type: 'welcome', isActive: true }], activeTabId: 'welcome', }) }) function loadSessions(sessions: Session[]) { vi.mocked(mockDesktopApi.sessionGetAll).mockResolvedValueOnce({ success: true, sessions }) } function renderSidebar() { render() } it('renders groups in manual order with legacy rows kept after ordered ones', async () => { loadSessions([ makeSession({ id: 'pre-jenkins', name: 'pre-jenkins', sortOrder: 2 }), makeSession({ id: 'pre-monitor', name: 'pre-monitor', sortOrder: 0 }), makeSession({ id: 'legacy-host', name: 'legacy-host' }), ]) renderSidebar() await screen.findByText('pre-monitor') const group = document.getElementById('session-group-default')! const names = within(group) .getAllByText(/pre-jenkins|pre-monitor|legacy-host/) .map((node) => node.textContent) expect(names).toEqual(['pre-monitor', 'pre-jenkins', 'legacy-host']) }) it('persists a within-group drag as one batch reorder', async () => { loadSessions([ makeSession({ id: 'a', name: 'alpha', sortOrder: 0 }), makeSession({ id: 'b', name: 'bravo', sortOrder: 1 }), makeSession({ id: 'c', name: 'charlie', sortOrder: 2 }), ]) renderSidebar() await screen.findByText('alpha') // jsdom rows report a zero rect, so a drop resolves to "insert after". pointerDrag('charlie', rowOf('alpha')) await vi.waitFor(() => { expect(mockDesktopApi.sessionReorder).toHaveBeenCalledWith([ { id: 'a', sortOrder: 0 }, { id: 'c', sortOrder: 1 }, { id: 'b', sortOrder: 2 }, ]) }) // The store keeps its element order; the persisted sortOrders define display order. const order = new Map(useSessionStore.getState().sessions.map((session) => [session.id, session.sortOrder])) expect(order.get('a')).toBe(0) expect(order.get('c')).toBe(1) expect(order.get('b')).toBe(2) }) it('moves a session into another group via its header', async () => { loadSessions([ makeSession({ id: 'a', name: 'alpha', sortOrder: 0 }), makeSession({ id: 'b', name: 'bravo', sortOrder: 1, group: 'prod' }), ]) renderSidebar() await screen.findByText('alpha') pointerDrag('alpha', screen.getByText('prod')) await vi.waitFor(() => { expect(mockDesktopApi.sessionReorder).toHaveBeenCalledWith([ { id: 'b', sortOrder: 0 }, { id: 'a', sortOrder: 1, group: 'prod' }, ]) }) expect(useSessionStore.getState().sessions.find((session) => session.id === 'a')?.group).toBe('prod') }) it('inserts a session among hosts already in another group', async () => { loadSessions([ makeSession({ id: 'a', name: 'alpha', sortOrder: 0 }), makeSession({ id: 'b', name: 'bravo', sortOrder: 0, group: 'test' }), makeSession({ id: 'c', name: 'charlie', sortOrder: 1, group: 'test' }), ]) renderSidebar() await screen.findByText('alpha') fireEvent.click(screen.getByText('test')) await screen.findByText('bravo') pointerDrag('alpha', rowOf('bravo')) await vi.waitFor(() => { expect(mockDesktopApi.sessionReorder).toHaveBeenCalledWith([ { id: 'b', sortOrder: 0 }, { id: 'a', sortOrder: 1, group: 'test' }, { id: 'c', sortOrder: 2 }, ]) }) expect(useSessionStore.getState().sessions.find((session) => session.id === 'a')?.group).toBe('test') }) it('sorts a group by name from the group context menu', async () => { const user = userEvent.setup() loadSessions([ makeSession({ id: 'b', name: 'bravo' }), makeSession({ id: 'g', name: 'gamma' }), makeSession({ id: 'a', name: 'alpha' }), ]) renderSidebar() await screen.findByText('bravo') await user.pointer([{ keys: '[MouseRight>]', target: screen.getByText('默认分组') }, { keys: '[/MouseRight]' }]) await user.click(screen.getByRole('button', { name: '按名称排序' })) await vi.waitFor(() => { expect(mockDesktopApi.sessionReorder).toHaveBeenCalledWith([ { id: 'a', sortOrder: 0 }, { id: 'b', sortOrder: 1 }, { id: 'g', sortOrder: 2 }, ]) }) }) it('skips the reorder write when the drop changes nothing', async () => { loadSessions([ makeSession({ id: 'a', name: 'alpha', sortOrder: 0 }), makeSession({ id: 'b', name: 'bravo', sortOrder: 1 }), ]) renderSidebar() await screen.findByText('alpha') // Dropping bravo after itself-equivalent position (after bravo) is a no-op. pointerDrag('bravo', rowOf('bravo')) await Promise.resolve() expect(mockDesktopApi.sessionReorder).not.toHaveBeenCalled() }) })