mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* 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
180 lines
5.9 KiB
TypeScript
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',
|
|
);
|
|
});
|
|
});
|