Skip to content

Commit 2f2a9ee

Browse files
committed
feat: implement stream Gantt chart view (#763)
1 parent c2208e2 commit 2f2a9ee

2 files changed

Lines changed: 620 additions & 1 deletion

File tree

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { render, screen, fireEvent } from '@testing-library/react';
2+
import '@testing-library/jest-dom';
3+
import { describe, it, expect, vi } from 'vitest';
4+
import {
5+
StreamGantt,
6+
streamGanttColor,
7+
GANTT_COLORS,
8+
ZOOM_LEVELS,
9+
} from './StreamGantt';
10+
import { Stream } from '../types/stream';
11+
12+
const DAY = 24 * 60 * 60;
13+
14+
function makeStream(
15+
id: string,
16+
status: Stream['progress']['status'] = 'active',
17+
overrides: Partial<Stream> = {},
18+
): Stream {
19+
return {
20+
id,
21+
sender: 'G_SENDER123456789012345678901234567890123456789012345678901',
22+
recipient: 'G_RECIPIENT123456789012345678901234567890123456789012345',
23+
assetCode: 'USDC',
24+
totalAmount: 1000,
25+
durationSeconds: 30 * DAY,
26+
startAt: 1_750_000_000,
27+
createdAt: 1_750_000_000,
28+
progress: {
29+
status,
30+
ratePerSecond: 0.001,
31+
elapsedSeconds: 0,
32+
vestedAmount: 0,
33+
remainingAmount: 1000,
34+
percentComplete: 0,
35+
},
36+
...overrides,
37+
};
38+
}
39+
40+
describe('StreamGantt', () => {
41+
it('renders one row per stream', () => {
42+
render(
43+
<StreamGantt
44+
streams={[
45+
makeStream('stream-001'),
46+
makeStream('stream-002', 'completed'),
47+
]}
48+
/>,
49+
);
50+
expect(screen.getByLabelText('Open stream stream-001')).toBeInTheDocument();
51+
expect(screen.getByLabelText('Open stream stream-002')).toBeInTheDocument();
52+
});
53+
54+
it('renders an empty state when there are no streams', () => {
55+
render(<StreamGantt streams={[]} />);
56+
expect(screen.getByText(/No streams to display yet/i)).toBeInTheDocument();
57+
expect(screen.queryByLabelText('Open stream ')).not.toBeInTheDocument();
58+
});
59+
60+
it('color-codes bars by vesting status', () => {
61+
render(
62+
<StreamGantt
63+
streams={[
64+
makeStream('sched', 'scheduled'),
65+
makeStream('act', 'active'),
66+
makeStream('done', 'completed'),
67+
]}
68+
/>,
69+
);
70+
71+
expect(screen.getByLabelText('Open stream sched')).toHaveStyle({
72+
background: GANTT_COLORS.unvested,
73+
});
74+
expect(screen.getByLabelText('Open stream act')).toHaveStyle({
75+
background: GANTT_COLORS.vested,
76+
});
77+
expect(screen.getByLabelText('Open stream done')).toHaveStyle({
78+
background: GANTT_COLORS.claimed,
79+
});
80+
});
81+
82+
it('maps statuses to the documented color semantics', () => {
83+
expect(streamGanttColor('scheduled')).toBe(GANTT_COLORS.unvested);
84+
expect(streamGanttColor('canceled')).toBe(GANTT_COLORS.unvested);
85+
expect(streamGanttColor('active')).toBe(GANTT_COLORS.vested);
86+
expect(streamGanttColor('paused')).toBe(GANTT_COLORS.vested);
87+
expect(streamGanttColor('completed')).toBe(GANTT_COLORS.claimed);
88+
});
89+
90+
it('defaults to week zoom', () => {
91+
render(<StreamGantt streams={[makeStream('stream-001')]} />);
92+
expect(screen.getByTestId('stream-gantt')).toHaveAttribute(
93+
'data-zoom',
94+
'week',
95+
);
96+
expect(screen.getByRole('button', { name: /week zoom/i })).toHaveAttribute(
97+
'aria-pressed',
98+
'true',
99+
);
100+
});
101+
102+
it('switches zoom level when a zoom button is clicked', () => {
103+
render(<StreamGantt streams={[makeStream('stream-001')]} />);
104+
const container = screen.getByTestId('stream-gantt');
105+
106+
fireEvent.click(screen.getByRole('button', { name: /day zoom/i }));
107+
expect(container).toHaveAttribute('data-zoom', 'day');
108+
109+
fireEvent.click(screen.getByRole('button', { name: /month zoom/i }));
110+
expect(container).toHaveAttribute('data-zoom', 'month');
111+
});
112+
113+
it('exposes the expected zoom levels', () => {
114+
expect(ZOOM_LEVELS.map((level) => level.key)).toEqual([
115+
'day',
116+
'week',
117+
'month',
118+
]);
119+
});
120+
121+
it('calls onOpenStream with the stream id when a bar is clicked', () => {
122+
const onOpenStream = vi.fn();
123+
render(
124+
<StreamGantt
125+
streams={[makeStream('stream-001')]}
126+
onOpenStream={onOpenStream}
127+
/>,
128+
);
129+
fireEvent.click(screen.getByLabelText('Open stream stream-001'));
130+
expect(onOpenStream).toHaveBeenCalledWith('stream-001');
131+
});
132+
133+
it('renders up to 50 streams without issues', () => {
134+
const streams = Array.from({ length: 50 }, (_, index) =>
135+
makeStream(`stream-${String(index).padStart(3, '0')}`, 'active'),
136+
);
137+
render(<StreamGantt streams={streams} />);
138+
expect(screen.getByLabelText('Open stream stream-000')).toBeInTheDocument();
139+
expect(screen.getByLabelText('Open stream stream-049')).toBeInTheDocument();
140+
});
141+
142+
it('shows a vested progress fill for active streams', () => {
143+
const stream = makeStream('act', 'active', {
144+
progress: {
145+
status: 'active',
146+
ratePerSecond: 0.001,
147+
elapsedSeconds: 100,
148+
vestedAmount: 500,
149+
remainingAmount: 500,
150+
percentComplete: 50,
151+
},
152+
});
153+
render(<StreamGantt streams={[stream]} />);
154+
const vested = screen.getAllByTestId('stream-gantt-vested');
155+
expect(vested).toHaveLength(1);
156+
expect(vested[0]).toHaveStyle({ width: '50%' });
157+
});
158+
159+
it('shows the stream count summary', () => {
160+
render(
161+
<StreamGantt
162+
streams={[makeStream('a', 'active'), makeStream('b', 'active')]}
163+
/>,
164+
);
165+
expect(screen.getByText(/Showing 2 streams/i)).toBeInTheDocument();
166+
});
167+
});

0 commit comments

Comments
 (0)