229 lines
9.2 KiB
TypeScript
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 } }));
|
|
});
|
|
});
|