// @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 }) => (
), })); import { SpaceRail } from './SpaceRail'; function space(p: Partial & Pick): 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( {}} />); expect(screen.getByText('読み込み中…')).toBeInTheDocument(); }); it('shows the error state', () => { useSpacesMock.mockReturnValue({ data: undefined, isLoading: false, isError: true }); render( {}} />); expect(screen.getByText('ワークスペースを取得できませんでした')).toBeInTheDocument(); }); it('shows the empty state when there are no spaces', () => { useSpacesMock.mockReturnValue({ data: [], isLoading: false, isError: false }); render( {}} />); 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( {}} />); 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( {}} />); 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( {}} />); 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(); 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( {}} />); 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( {}} />); 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( {}} />); 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(); 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( {}} />); 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 } })); }); });