Replaces the two-row card layout with a single horizontal flex row that fits within 48-56px height. Adds project badge overflow (max 2 + chip), hover-reveal menu (opacity-0/group-hover), phase counter (X / Y), active phase name with separator, and StatusDot pulse behavior. Removes ProgressBar, status label text, and second row entirely. Adds Vitest unit tests covering all 6 test groups from the spec. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
185 lines
6.7 KiB
TypeScript
185 lines
6.7 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import '@testing-library/jest-dom/vitest'
|
|
import { render, screen } from '@testing-library/react'
|
|
import { vi, describe, it, expect } from 'vitest'
|
|
|
|
vi.mock('@/lib/trpc', () => ({
|
|
trpc: {
|
|
useUtils: () => ({ listInitiatives: { invalidate: vi.fn() } }),
|
|
updateInitiative: { useMutation: vi.fn(() => ({ mutate: vi.fn() })) },
|
|
deleteInitiative: { useMutation: vi.fn(() => ({ mutate: vi.fn() })) },
|
|
},
|
|
}))
|
|
|
|
vi.mock('@/components/ui/card', () => ({
|
|
Card: ({ children, className, onClick }: any) => (
|
|
<div data-testid="card" className={className} onClick={onClick}>{children}</div>
|
|
),
|
|
}))
|
|
|
|
import { InitiativeCard, type SerializedInitiative } from './InitiativeCard'
|
|
|
|
function makeInitiative(overrides: Partial<SerializedInitiative> = {}): SerializedInitiative {
|
|
return {
|
|
id: 'init-1',
|
|
name: 'Test Initiative',
|
|
status: 'active',
|
|
branch: null,
|
|
createdAt: '2024-01-01T00:00:00Z',
|
|
updatedAt: '2024-01-01T00:00:00Z',
|
|
projects: [],
|
|
...overrides,
|
|
activity: {
|
|
state: 'planning',
|
|
activePhase: null,
|
|
phasesTotal: 0,
|
|
phasesCompleted: 0,
|
|
...(overrides.activity ?? {}),
|
|
},
|
|
}
|
|
}
|
|
|
|
describe('InitiativeCard — single-row structure', () => {
|
|
it('renders no second-row div (no mt-1.5 class in DOM)', () => {
|
|
render(<InitiativeCard initiative={makeInitiative()} onClick={() => {}} />)
|
|
expect(document.querySelector('.mt-1\\.5')).toBeNull()
|
|
})
|
|
|
|
it('flex container has items-center class', () => {
|
|
render(<InitiativeCard initiative={makeInitiative()} onClick={() => {}} />)
|
|
const flexContainer = document.querySelector('.flex.items-center')
|
|
expect(flexContainer).not.toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('InitiativeCard — project badge overflow', () => {
|
|
it('1 project → 1 badge with project name, no +N chip', () => {
|
|
const initiative = makeInitiative({
|
|
projects: [{ id: 'p1', name: 'Project Alpha' }],
|
|
})
|
|
render(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
|
expect(screen.getByText('Project Alpha')).toBeInTheDocument()
|
|
expect(screen.queryByText(/^\+\d+$/)).toBeNull()
|
|
})
|
|
|
|
it('3 projects → first 2 names present, third absent, +1 chip', () => {
|
|
const initiative = makeInitiative({
|
|
projects: [
|
|
{ id: 'p1', name: 'Alpha' },
|
|
{ id: 'p2', name: 'Beta' },
|
|
{ id: 'p3', name: 'Gamma' },
|
|
],
|
|
})
|
|
render(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
|
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
|
expect(screen.getByText('Beta')).toBeInTheDocument()
|
|
expect(screen.queryByText('Gamma')).toBeNull()
|
|
expect(screen.getByText('+1')).toBeInTheDocument()
|
|
})
|
|
|
|
it('5 projects → first 2 names present, +3 chip', () => {
|
|
const initiative = makeInitiative({
|
|
projects: [
|
|
{ id: 'p1', name: 'Alpha' },
|
|
{ id: 'p2', name: 'Beta' },
|
|
{ id: 'p3', name: 'Gamma' },
|
|
{ id: 'p4', name: 'Delta' },
|
|
{ id: 'p5', name: 'Epsilon' },
|
|
],
|
|
})
|
|
render(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
|
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
|
expect(screen.getByText('Beta')).toBeInTheDocument()
|
|
expect(screen.getByText('+3')).toBeInTheDocument()
|
|
})
|
|
|
|
it('0 projects → no badge elements rendered', () => {
|
|
const initiative = makeInitiative({ projects: [] })
|
|
render(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
|
// No outline or secondary badges for projects
|
|
expect(document.querySelectorAll('[class*="rounded-full"][class*="border"]').length).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('InitiativeCard — hover-reveal menu', () => {
|
|
it('dropdown wrapper has opacity-0 class', () => {
|
|
render(<InitiativeCard initiative={makeInitiative()} onClick={() => {}} />)
|
|
const wrapper = document.querySelector('.opacity-0')
|
|
expect(wrapper).not.toBeNull()
|
|
})
|
|
|
|
it('dropdown wrapper has group-hover:opacity-100 class', () => {
|
|
render(<InitiativeCard initiative={makeInitiative()} onClick={() => {}} />)
|
|
const wrapper = document.querySelector('.opacity-0')
|
|
expect(wrapper).toHaveClass('group-hover:opacity-100')
|
|
})
|
|
|
|
it('card has group class', () => {
|
|
render(<InitiativeCard initiative={makeInitiative()} onClick={() => {}} />)
|
|
const card = screen.getByTestId('card')
|
|
expect(card).toHaveClass('group')
|
|
})
|
|
})
|
|
|
|
describe('InitiativeCard — phase counter', () => {
|
|
it('shows "2 / 5" when phasesCompleted=2 and phasesTotal=5', () => {
|
|
const initiative = makeInitiative({
|
|
activity: { state: 'planning', activePhase: null, phasesTotal: 5, phasesCompleted: 2 },
|
|
})
|
|
render(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
|
expect(screen.getByText('2 / 5')).toBeInTheDocument()
|
|
})
|
|
|
|
it('counter absent when phasesTotal=0', () => {
|
|
const initiative = makeInitiative({
|
|
activity: { state: 'planning', activePhase: null, phasesTotal: 0, phasesCompleted: 0 },
|
|
})
|
|
render(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
|
expect(screen.queryByText(/\d+ \/ \d+/)).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('InitiativeCard — activity dot pulse', () => {
|
|
it('state="executing" → StatusDot has animate-status-pulse class', () => {
|
|
const initiative = makeInitiative({
|
|
activity: { state: 'executing', activePhase: null, phasesTotal: 0, phasesCompleted: 0 },
|
|
})
|
|
render(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
|
const dot = document.querySelector('[role="status"]')
|
|
expect(dot).toHaveClass('animate-status-pulse')
|
|
})
|
|
|
|
it('state="planning" → StatusDot does not have animate-status-pulse class', () => {
|
|
const initiative = makeInitiative({
|
|
activity: { state: 'planning', activePhase: null, phasesTotal: 0, phasesCompleted: 0 },
|
|
})
|
|
render(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
|
const dot = document.querySelector('[role="status"]')
|
|
expect(dot).not.toHaveClass('animate-status-pulse')
|
|
})
|
|
})
|
|
|
|
describe('InitiativeCard — active phase name', () => {
|
|
it('activePhase present → phase name and separator visible', () => {
|
|
const initiative = makeInitiative({
|
|
activity: {
|
|
state: 'planning',
|
|
activePhase: { id: 'p1', name: 'Phase Alpha' },
|
|
phasesTotal: 0,
|
|
phasesCompleted: 0,
|
|
},
|
|
})
|
|
render(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
|
expect(screen.getByText('Phase Alpha')).toBeInTheDocument()
|
|
expect(screen.getByText('·')).toBeInTheDocument()
|
|
})
|
|
|
|
it('activePhase=null → no separator or phase name rendered', () => {
|
|
const initiative = makeInitiative({
|
|
activity: { state: 'planning', activePhase: null, phasesTotal: 0, phasesCompleted: 0 },
|
|
})
|
|
render(<InitiativeCard initiative={initiative} onClick={() => {}} />)
|
|
expect(screen.queryByText('·')).toBeNull()
|
|
})
|
|
})
|