import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import Dashboard from './Dashboard' import { withCodes } from '../codes/fixtures' import type { DashboardData, DashboardOrgRow } from '../api/client' const mocks = vi.hoisted(() => ({ getDashboard: vi.fn(), updateStage: vi.fn(), })) vi.mock('../api/client', async (importOriginal) => ({ ...(await importOriginal()), getDashboard: mocks.getDashboard, updateStage: mocks.updateStage, })) const DAY = 24 * 60 * 60 * 1000 function row(overrides: Partial = {}): DashboardOrgRow { return { id: 1, orgNo: '001', orgName: '한국출판문화산업진흥원', stage: 5, hasChannel: true, lawyerName: '전세준', mjName: '이연경', lawyerAssignedDate: '2026-06-19', reviewTotal: 392, reviewDone: 78, processTotal: 0, processDone: 0, stageChangedAt: Date.now() - 3 * DAY, lastChangedAt: Date.now() - 3 * DAY, reCount: 0, ...overrides, } } function data(rows: DashboardOrgRow[], summary: Partial = {}): DashboardData { return { summary: { applied: rows.length, docSubmitted: 0, reviewTotal: 0, reviewDone: 0, processTotal: 0, processDone: 0, unresolvedRe: 0, reportWriting: 0, completed: 0, ...summary, }, orgs: rows, } } function renderDashboard(onOpenOrg = vi.fn(), onOpenOrgList = vi.fn()) { render(withCodes()) return { onOpenOrg, onOpenOrgList } } /** 기관명은 칸반 카드와 표에 동시에 나오므로, 표를 볼 때는 항상 표 안에서만 찾는다. */ function table() { return within(screen.getByTestId('recent-table')) } describe('Dashboard', () => { beforeEach(() => { vi.clearAllMocks() mocks.getDashboard.mockResolvedValue(data([row()])) mocks.updateStage.mockResolvedValue({}) }) it('KPI 타일에 서버가 준 집계를 그대로 보여준다', async () => { mocks.getDashboard.mockResolvedValue( data([row()], { applied: 50, docSubmitted: 9, reviewTotal: 2012, reviewDone: 503, processTotal: 197, processDone: 0, completed: 1, }), ) renderDashboard() expect(await screen.findByText('503 / 2,012')).toBeTruthy() expect(screen.getByText('0 / 197')).toBeTruthy() // 권리확인 진척률 expect(screen.getByText('25%')).toBeTruthy() }) it('채널이 없는 기관은 단계 미지정 컬럼에 담긴다', async () => { mocks.getDashboard.mockResolvedValue( data([row({ id: 1, orgName: '채널없는기관', hasChannel: false, stage: null })]), ) renderDashboard() const column = await screen.findByTestId('column-unassigned') expect(column.textContent).toContain('채널없는기관') expect(column.textContent).toContain('단계 미지정') }) it('단계 미지정 카드를 끌면 이동하지 않고 채널 생성 경고창이 뜬다', async () => { mocks.getDashboard.mockResolvedValue( data([row({ orgName: '채널없는기관', hasChannel: false, stage: null })]), ) renderDashboard() const column = await screen.findByTestId('column-unassigned') const card = within(column).getByText('채널없는기관') fireEvent.dragStart(card.parentElement!, { dataTransfer: { setData: vi.fn(), getData: vi.fn() }, }) expect(await screen.findByText('채널을 먼저 생성해 주세요')).toBeTruthy() expect(mocks.updateStage).not.toHaveBeenCalled() }) it('채널이 있는 카드를 다른 컬럼에 떨어뜨리면 단계가 바뀐다', async () => { renderDashboard() await screen.findByTestId('recent-table') const target = screen.getByTestId('column-6') fireEvent.drop(target, { dataTransfer: { getData: () => '1', setData: vi.fn() }, }) await waitFor(() => expect(mocks.updateStage).toHaveBeenCalledWith(1, 6)) }) it('같은 컬럼에 떨어뜨리면 아무 것도 하지 않는다', async () => { renderDashboard() await screen.findByTestId('recent-table') fireEvent.drop(screen.getByTestId('column-5'), { dataTransfer: { getData: () => '1', setData: vi.fn() }, }) await waitFor(() => expect(mocks.getDashboard).toHaveBeenCalledTimes(1)) expect(mocks.updateStage).not.toHaveBeenCalled() }) it('단계 미지정 컬럼에는 떨어뜨려도 단계가 바뀌지 않는다', async () => { renderDashboard() await screen.findByTestId('recent-table') fireEvent.drop(screen.getByTestId('column-unassigned'), { dataTransfer: { getData: () => '1', setData: vi.fn() }, }) await waitFor(() => expect(mocks.updateStage).not.toHaveBeenCalled()) }) it('카드에 현재 단계 진입 후 경과일을 D+n으로 보여준다', async () => { mocks.getDashboard.mockResolvedValue( data([row({ stageChangedAt: Date.now() - 30 * DAY })]), ) renderDashboard() expect(await screen.findByText('D+30')).toBeTruthy() }) it('최근 진행 기관 표는 최근 변경순으로 정렬한다', async () => { mocks.getDashboard.mockResolvedValue( data([ row({ id: 1, orgNo: '001', orgName: '오래된기관', lastChangedAt: Date.now() - 10 * DAY }), row({ id: 2, orgNo: '002', orgName: '최근기관', lastChangedAt: Date.now() }), ]), ) renderDashboard() await screen.findByTestId('recent-table') const names = table().getAllByRole('row').slice(1).map((tr) => tr.children[1].textContent) expect(names).toEqual(['최근기관', '오래된기관']) }) it('표는 8개까지만 보여주고 더보기로 늘린다', async () => { const rows = Array.from({ length: 10 }, (_, i) => row({ id: i + 1, orgNo: String(i + 1).padStart(3, '0'), orgName: `기관${i + 1}` }), ) mocks.getDashboard.mockResolvedValue(data(rows)) renderDashboard() await screen.findByTestId('recent-table') expect(table().getAllByRole('row')).toHaveLength(9) // 헤더 1 + 8 fireEvent.click(screen.getByText('더보기 (2개 남음)')) expect(table().getAllByRole('row')).toHaveLength(11) }) it('기관명 검색으로 표를 거른다', async () => { mocks.getDashboard.mockResolvedValue( data([ row({ id: 1, orgNo: '001', orgName: '한국출판문화산업진흥원' }), row({ id: 2, orgNo: '002', orgName: '세종학당재단' }), ]), ) renderDashboard() await screen.findByTestId('recent-table') fireEvent.change(screen.getByLabelText('기관명/기관코드 검색'), { target: { value: '세종' } }) expect(table().queryByText('한국출판문화산업진흥원')).toBeNull() expect(table().getByText('세종학당재단')).toBeTruthy() }) it('표에서 기관을 클릭하면 기관관리로 넘긴다', async () => { const onOpenOrg = vi.fn() renderDashboard(onOpenOrg) await screen.findByTestId('recent-table') fireEvent.click(table().getByText('한국출판문화산업진흥원')) expect(onOpenOrg).toHaveBeenCalledWith(1) }) it('하단 전체 보기는 사업관리로 넘긴다', async () => { const onOpenOrgList = vi.fn() renderDashboard(vi.fn(), onOpenOrgList) fireEvent.click(await screen.findByText('사업관리에서 전체 보기 →')) expect(onOpenOrgList).toHaveBeenCalled() }) })