feat(agent-office): AgentCard component with state dot + badge

- state→color mapping via STATE_COLORS
- notification badge with 9+ overflow
- active prop for selected card border

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 20:52:34 +09:00
parent 50d427e367
commit 1630109856
2 changed files with 90 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
// src/pages/agent-office/components/AgentCard.jsx
import { AGENT_META, STATE_COLORS, DEFAULT_STATE_COLOR } from '../constants.js';
export default function AgentCard({ agentId, agentState, notificationCount = 0, active = false, onClick }) {
const meta = AGENT_META[agentId];
if (!meta) return null;
const state = agentState?.state || 'idle';
const stateInfo = STATE_COLORS[state] || DEFAULT_STATE_COLOR;
const dotClass = `ao-card-dot ${state}${stateInfo.pulse ? ' pulse' : ''}`;
const badgeText = notificationCount > 9 ? '9+' : String(notificationCount);
return (
<button
type="button"
className={`ao-card${active ? ' active' : ''}`}
onClick={onClick}
style={{ '--card-accent': meta.color }}
>
<span className={dotClass} title={state} />
{notificationCount > 0 && (
<span className="ao-card-badge">{badgeText}</span>
)}
<div className="ao-card-image">
<img src={meta.image} alt={meta.displayName} />
</div>
<div className="ao-card-name">{meta.displayName}</div>
</button>
);
}

View File

@@ -0,0 +1,60 @@
// src/pages/agent-office/components/AgentCard.test.jsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import AgentCard from './AgentCard.jsx';
describe('AgentCard', () => {
it('에이전트의 displayName을 표시', () => {
render(<AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={0} onClick={() => {}} />);
expect(screen.getByText('주식 트레이더')).toBeInTheDocument();
});
it('working 상태일 때 dot에 working 클래스 부여', () => {
const { container } = render(
<AgentCard agentId="stock" agentState={{ state: 'working' }} notificationCount={0} onClick={() => {}} />
);
const dot = container.querySelector('.ao-card-dot');
expect(dot).toHaveClass('working');
});
it('agentState 없으면 idle로 fallback', () => {
const { container } = render(
<AgentCard agentId="stock" agentState={undefined} notificationCount={0} onClick={() => {}} />
);
const dot = container.querySelector('.ao-card-dot');
expect(dot).toHaveClass('idle');
});
it('notificationCount > 0이면 뱃지 표시', () => {
render(<AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={3} onClick={() => {}} />);
expect(screen.getByText('3')).toBeInTheDocument();
});
it('notificationCount === 0이면 뱃지 없음', () => {
const { container } = render(
<AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={0} onClick={() => {}} />
);
expect(container.querySelector('.ao-card-badge')).toBeNull();
});
it('notificationCount > 9이면 9+ 표시', () => {
render(<AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={15} onClick={() => {}} />);
expect(screen.getByText('9+')).toBeInTheDocument();
});
it('클릭 시 onClick 호출', () => {
const onClick = vi.fn();
const { container } = render(
<AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={0} onClick={onClick} />
);
fireEvent.click(container.querySelector('.ao-card'));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('active prop 시 카드에 active 클래스 부여', () => {
const { container } = render(
<AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={0} active onClick={() => {}} />
);
expect(container.querySelector('.ao-card')).toHaveClass('active');
});
});