maestro/ui/src/components/spaces/SpaceRail.test.tsx
oss-sync 2044f0a2c4
Some checks failed
CI / build-and-test (push) Failing after 7m0s
sync: update from private repo (91d8d79c)
2026-07-09 23:57:26 +00:00

229 lines
9.2 KiB
TypeScript

// @vitest-environment jsdom
/**
* Component test for SpaceRail — the workspace switcher rail. Mocks the data
* hooks (useSpaces, useLocalTaskList) and useAuthState so the real grouping,
* "自分" badge, running-count badge, selection, empty/loading/error states and
* the create dialog toggle are exercised. SpaceFormDialog is stubbed so opening
* it doesn't pull in the full create-dialog tree.
*/
import '../../test/dom-setup';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import type { Space } from '../../api';
import i18n from '../../i18n';
// --- Mocks (declared before importing the component under test) -------------
const useSpacesMock = vi.fn();
const useTaskListMock = vi.fn();
const useAuthStateMock = vi.fn();
const updateDisplayMock = { mutateAsync: vi.fn() };
vi.mock('../../hooks/useSpaces', () => ({
useSpaces: () => useSpacesMock(),
useUpdateSpaceDisplayPrefs: () => updateDisplayMock,
}));
vi.mock('../../hooks/useTaskList', () => ({ useLocalTaskList: () => useTaskListMock() }));
vi.mock('../../App', () => ({ useAuthState: () => useAuthStateMock() }));
vi.mock('./SpaceFormDialog', () => ({
SpaceFormDialog: ({ onSaved }: { onSaved: (id: string) => void }) => (
<div data-testid="space-form-dialog">
<button onClick={() => onSaved('new-space')}>save</button>
</div>
),
}));
import { SpaceRail } from './SpaceRail';
function space(p: Partial<Space> & Pick<Space, 'id' | 'kind' | 'title'>): Space {
return {
description: '',
ownerId: null,
visibility: 'private',
visibilityScopeOrgId: null,
status: 'open',
brandColor: null,
workspaceDir: null,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
...p,
} as Space;
}
beforeEach(() => {
// Rail labels (loading/error/empty/running-count/"自分" badge) now route through
// i18next. Pin the language so text assertions are deterministic.
void i18n.changeLanguage('ja');
useSpacesMock.mockReset();
useTaskListMock.mockReset();
useAuthStateMock.mockReset();
updateDisplayMock.mutateAsync.mockReset();
updateDisplayMock.mutateAsync.mockResolvedValue({ favorite: false, hidden: false });
useTaskListMock.mockReturnValue({ data: [] });
useAuthStateMock.mockReturnValue({ mode: 'disabled' });
});
describe('SpaceRail', () => {
it('shows the loading state', () => {
useSpacesMock.mockReturnValue({ data: undefined, isLoading: true, isError: false });
render(<SpaceRail onSelect={() => {}} />);
expect(screen.getByText('読み込み中…')).toBeInTheDocument();
});
it('shows the error state', () => {
useSpacesMock.mockReturnValue({ data: undefined, isLoading: false, isError: true });
render(<SpaceRail onSelect={() => {}} />);
expect(screen.getByText('ワークスペースを取得できませんでした')).toBeInTheDocument();
});
it('shows the empty state when there are no spaces', () => {
useSpacesMock.mockReturnValue({ data: [], isLoading: false, isError: false });
render(<SpaceRail onSelect={() => {}} />);
expect(
screen.getByText('ワークスペースがありません。「+ 新規」から作成してください。'),
).toBeInTheDocument();
});
it('groups personal and case spaces under their headers', () => {
useSpacesMock.mockReturnValue({
data: [
space({ id: 'p1', kind: 'personal', title: 'My Workspace' }),
space({ id: 'c1', kind: 'case', title: 'Project A' }),
],
isLoading: false,
isError: false,
});
render(<SpaceRail onSelect={() => {}} />);
const groups = screen.getAllByTestId('space-group');
expect(groups.map((g) => g.getAttribute('data-group'))).toEqual([
'統合スペース',
'個別スペース',
]);
expect(screen.getByText('My Workspace')).toBeInTheDocument();
expect(screen.getByText('Project A')).toBeInTheDocument();
});
it('shows favorites in a dedicated section and hides hidden workspaces from the normal list', () => {
useSpacesMock.mockReturnValue({
data: [
space({ id: 'fav', kind: 'case', title: 'Favorite Project', favorite: true }),
space({ id: 'normal', kind: 'case', title: 'Normal Project' }),
space({ id: 'hidden', kind: 'case', title: 'Hidden Project', hidden: true }),
],
isLoading: false,
isError: false,
});
render(<SpaceRail onSelect={() => {}} />);
const groups = screen.getAllByTestId('space-group');
expect(groups.map((g) => g.getAttribute('data-group'))).toContain('お気に入り');
expect(screen.getByText('Favorite Project')).toBeInTheDocument();
expect(screen.getByText('Normal Project')).toBeInTheDocument();
expect(screen.queryByText('Hidden Project')).toBeNull();
fireEvent.click(screen.getByTestId('space-hidden-toggle'));
expect(screen.getByText('Hidden Project')).toBeInTheDocument();
expect(screen.getByTestId('space-hidden-badge')).toHaveTextContent('非表示');
});
it('search includes hidden workspaces with a hidden badge', () => {
useSpacesMock.mockReturnValue({
data: [
space({ id: 'visible', kind: 'case', title: 'Visible Project' }),
space({ id: 'hidden', kind: 'case', title: 'Hidden Project', hidden: true }),
],
isLoading: false,
isError: false,
});
render(<SpaceRail onSelect={() => {}} />);
fireEvent.change(screen.getByTestId('space-search-input'), { target: { value: 'hidden' } });
expect(screen.getByText('Hidden Project')).toBeInTheDocument();
expect(screen.getByTestId('space-hidden-badge')).toHaveTextContent('非表示');
});
it('calls onSelect with the space id when a row is clicked', () => {
const onSelect = vi.fn();
useSpacesMock.mockReturnValue({
data: [space({ id: 'c1', kind: 'case', title: 'Project A' })],
isLoading: false,
isError: false,
});
render(<SpaceRail onSelect={onSelect} />);
fireEvent.click(screen.getByText('Project A'));
expect(onSelect).toHaveBeenCalledWith('c1');
});
it('renders a running-count badge when a space has running tasks', () => {
useSpacesMock.mockReturnValue({
data: [space({ id: 'c1', kind: 'case', title: 'Project A' })],
isLoading: false,
isError: false,
});
useTaskListMock.mockReturnValue({
data: [
{ spaceId: 'c1', latestJob: { status: 'running' } },
{ spaceId: 'c1', latestJob: { status: 'succeeded' } },
],
});
render(<SpaceRail onSelect={() => {}} />);
const badge = screen.getByTestId('space-running-count');
expect(badge).toHaveTextContent('1 実行中');
});
it('shows the "自分" badge only when other users\' spaces are present (admin view)', () => {
useAuthStateMock.mockReturnValue({ mode: 'authenticated', user: { id: 'me' } });
useSpacesMock.mockReturnValue({
data: [
space({ id: 'c1', kind: 'case', title: 'Mine', ownerId: 'me' }),
space({ id: 'c2', kind: 'case', title: 'Theirs', ownerId: 'other' }),
],
isLoading: false,
isError: false,
});
render(<SpaceRail onSelect={() => {}} />);
const badges = screen.getAllByTestId('space-mine-badge');
expect(badges).toHaveLength(1);
// Badge lives inside the owner's row.
const mineRow = screen.getByText('Mine').closest('[data-testid="space-row"]');
expect(mineRow).toHaveAttribute('data-space-mine', '1');
});
it('hides the "自分" badge when all spaces belong to the viewer', () => {
useAuthStateMock.mockReturnValue({ mode: 'authenticated', user: { id: 'me' } });
useSpacesMock.mockReturnValue({
data: [space({ id: 'c1', kind: 'case', title: 'Mine', ownerId: 'me' })],
isLoading: false,
isError: false,
});
render(<SpaceRail onSelect={() => {}} />);
expect(screen.queryByTestId('space-mine-badge')).toBeNull();
});
it('opens the create dialog and selects the new space on save', () => {
const onSelect = vi.fn();
useSpacesMock.mockReturnValue({ data: [], isLoading: false, isError: false });
render(<SpaceRail onSelect={onSelect} />);
expect(screen.queryByTestId('space-form-dialog')).toBeNull();
fireEvent.click(screen.getByTestId('create-space-btn'));
expect(screen.getByTestId('space-form-dialog')).toBeInTheDocument();
fireEvent.click(screen.getByText('save'));
expect(onSelect).toHaveBeenCalledWith('new-space');
expect(screen.queryByTestId('space-form-dialog')).toBeNull();
});
it('updates display settings from the row menu and can undo the action', async () => {
useSpacesMock.mockReturnValue({
data: [space({ id: 'c1', kind: 'case', title: 'Project A' })],
isLoading: false,
isError: false,
});
render(<SpaceRail onSelect={() => {}} />);
fireEvent.click(screen.getByTestId('space-row-menu'));
fireEvent.click(screen.getByText('非表示にする'));
await waitFor(() => expect(updateDisplayMock.mutateAsync).toHaveBeenCalledWith({ id: 'c1', patch: { hidden: true } }));
expect(screen.getByTestId('space-display-toast')).toHaveTextContent('「Project A」を非表示にしました');
fireEvent.click(screen.getByTestId('space-display-undo'));
await waitFor(() => expect(updateDisplayMock.mutateAsync).toHaveBeenCalledWith({ id: 'c1', patch: { favorite: false, hidden: false } }));
});
});