Files
open-design/apps/web/tests/components/ProjectSearchModal.keyboard.test.tsx
T
lefarcenandGitHub d40d264f74 fix: derive workspace authority from project bindings (#6989)
* fix: derive workspace authority from project bindings

* fix: preserve preview navigation without workspace params

* fix: gate chat sends on authoritative history

* fix: preserve derived auth and preview paths

* fix: retain conversation during authority refresh

* fix: clear transcripts across authority changes

* fix: clear conversations after access revocation
2026-08-18 03:16:58 +00:00

180 lines
5.9 KiB
TypeScript

// @vitest-environment jsdom
// The ⌘K palette is reached and driven from the keyboard, so ↑/↓ must move the
// highlight and Enter must open the highlighted project — all without moving
// focus off the search input, which would stall typing.
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
buildProjectSearchCatalog,
ProjectSearchModal,
} from '../../src/components/ProjectSearchModal';
import { I18nProvider } from '../../src/i18n';
import type { Project } from '../../src/types';
import type { WorkspaceCollabContext } from '@open-design/contracts';
afterEach(() => cleanup());
function project(id: string, name: string, updatedAt: number): Project {
return {
id,
name,
createdAt: updatedAt,
updatedAt,
metadata: {},
} as unknown as Project;
}
// Deliberately unsorted: the palette ranks by recency, so the rendered order
// (and therefore what ↑/↓ walk through) is newest-first, not input order.
const PROJECTS = [
project('older', 'Older deck', 1_000),
project('newest', 'Newest deck', 3_000),
project('middle', 'Middle deck', 2_000),
];
const WORKSPACE_CONTEXT = {
workspaceId: 'workspace-team',
workspaceType: 'team',
workspaceMemberId: 'member-1',
role: 'member',
memberStatus: 'active',
lifecycleState: 'active',
permissions: {
canShareProjects: false,
canWriteSyncedFiles: false,
},
} as WorkspaceCollabContext;
function renderPalette(
onOpenProject = vi.fn(),
onClose = vi.fn(),
projects = PROJECTS,
workspaceContext: WorkspaceCollabContext | null = null,
) {
render(
<I18nProvider>
<ProjectSearchModal
projects={projects}
workspaceContext={workspaceContext}
onOpenProject={onOpenProject}
onClose={onClose}
/>
</I18nProvider>,
);
return { onOpenProject, onClose };
}
function activeName(): string | undefined {
const active = document.querySelector('.project-search-results .project-search-item.is-active');
return active?.querySelector('.project-search-item-name')?.textContent?.trim();
}
describe('ProjectSearchModal keyboard navigation', () => {
it('searches personal drafts together with shared workspace projects', () => {
const personalProject = project('personal-white-shoes', '白色慢跑鞋棚拍商品图', 4_000);
const sharedProject = project('shared-blue-shoes', '共享蓝色跑鞋', 3_000);
const projects = buildProjectSearchCatalog([personalProject], [sharedProject]);
renderPalette(vi.fn(), vi.fn(), projects);
fireEvent.change(screen.getByTestId('project-search-input'), {
target: { value: '白色' },
});
expect(screen.getByTestId('project-search-item-personal-white-shoes')).toBeTruthy();
});
it('uses the shared catalog card when the same project appears twice', () => {
const localCard = project('shared-project', 'Stale local title', 1_000);
const sharedCard = project('shared-project', 'Current shared title', 2_000);
expect(buildProjectSearchCatalog([localCard], [sharedCard])).toEqual([sharedCard]);
});
it('highlights the top match first and walks the list with the arrow keys', () => {
renderPalette();
const input = screen.getByTestId('project-search-input');
expect(activeName()).toBe('Newest deck');
fireEvent.keyDown(input, { key: 'ArrowDown' });
expect(activeName()).toBe('Middle deck');
fireEvent.keyDown(input, { key: 'ArrowDown' });
expect(activeName()).toBe('Older deck');
fireEvent.keyDown(input, { key: 'ArrowUp' });
expect(activeName()).toBe('Middle deck');
});
it('wraps around at both ends', () => {
renderPalette();
const input = screen.getByTestId('project-search-input');
fireEvent.keyDown(input, { key: 'ArrowUp' });
expect(activeName()).toBe('Older deck');
fireEvent.keyDown(input, { key: 'ArrowDown' });
expect(activeName()).toBe('Newest deck');
});
it('opens the highlighted project on Enter and closes the palette', () => {
const onOpenProject = vi.fn();
const onClose = vi.fn();
renderPalette(onOpenProject, onClose);
const input = screen.getByTestId('project-search-input');
fireEvent.keyDown(input, { key: 'ArrowDown' });
fireEvent.keyDown(input, { key: 'Enter' });
expect(onOpenProject).toHaveBeenCalledWith('middle');
expect(onClose).toHaveBeenCalled();
});
it('restarts the highlight at the top match when the query re-ranks the list', () => {
renderPalette();
const input = screen.getByTestId('project-search-input');
fireEvent.keyDown(input, { key: 'ArrowDown' });
fireEvent.keyDown(input, { key: 'ArrowDown' });
expect(activeName()).toBe('Older deck');
fireEvent.change(input, { target: { value: 'Middle' } });
// The stale index would have pointed past the end of the filtered list.
expect(activeName()).toBe('Middle deck');
fireEvent.keyDown(input, { key: 'Enter' });
expect(screen.getByTestId('project-search-item-middle')).toBeTruthy();
});
it('ignores the arrow keys when nothing matches', () => {
renderPalette();
const input = screen.getByTestId('project-search-input');
fireEvent.change(input, { target: { value: 'no-such-project' } });
fireEvent.keyDown(input, { key: 'ArrowDown' });
fireEvent.keyDown(input, { key: 'Enter' });
expect(activeName()).toBeUndefined();
});
it('uses server-derived project authority for Team project cover URLs', () => {
const teamProject = {
...project('team-project', 'Team project', 4_000),
metadata: {
entryFile: 'cover.png',
kind: 'image',
},
} as Project;
renderPalette(vi.fn(), vi.fn(), [teamProject], WORKSPACE_CONTEXT);
const image = screen.getByTestId('project-search-item-team-project').querySelector('img');
expect(image?.getAttribute('src')).toBe(
'/api/projects/team-project/raw/cover.png?v=4000',
);
});
});