diff --git a/components/layout/notification-bell.test.tsx b/components/layout/notification-bell.test.tsx new file mode 100644 index 0000000..c46bfd0 --- /dev/null +++ b/components/layout/notification-bell.test.tsx @@ -0,0 +1,171 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { NotificationBell } from './notification-bell' + +jest.mock('@/hooks/use-notifications', () => ({ + useNotifications: jest.fn(), +})) + +const mockUseNotifications = require('@/hooks/use-notifications').useNotifications as jest.MockedFunction + +describe('NotificationBell', () => { + const mockNotifications = [ + { id: '1', message: 'Stream created', type: 'success', read: false }, + { id: '2', message: 'Payment received', type: 'info', read: false }, + ] + + beforeEach(() => { + jest.clearAllMocks() + mockUseNotifications.mockReturnValue({ + notifications: mockNotifications, + unreadCount: 2, + markAllRead: jest.fn(), + markAsRead: jest.fn(), + clearNotifications: jest.fn(), + }) + }) + + describe('unread count badge', () => { + it('should display unread count badge', () => { + render() + expect(screen.getByText('2')).toBeInTheDocument() + }) + + it('should not display badge when unread count is 0', () => { + mockUseNotifications.mockReturnValue({ + notifications: [], + unreadCount: 0, + markAllRead: jest.fn(), + markAsRead: jest.fn(), + clearNotifications: jest.fn(), + }) + + const { container } = render() + expect(screen.queryByText('0')).not.toBeInTheDocument() + }) + + it('should display 99+ when unread count exceeds 99', () => { + mockUseNotifications.mockReturnValue({ + notifications: Array(105).fill({ id: '1', message: 'test', type: 'info', read: false }), + unreadCount: 105, + markAllRead: jest.fn(), + markAsRead: jest.fn(), + clearNotifications: jest.fn(), + }) + + render() + expect(screen.getByText('99+')).toBeInTheDocument() + }) + }) + + describe('markAllRead on panel open', () => { + it('should call markAllRead when notification panel opens', () => { + const markAllReadMock = jest.fn() + mockUseNotifications.mockReturnValue({ + notifications: mockNotifications, + unreadCount: 2, + markAllRead: markAllReadMock, + markAsRead: jest.fn(), + clearNotifications: jest.fn(), + }) + + render() + const bellButton = screen.getByRole('button') + fireEvent.click(bellButton) + + expect(markAllReadMock).toHaveBeenCalled() + }) + }) + + describe('Escape-to-close behavior', () => { + it('should close notification panel when Escape is pressed', () => { + render() + const bellButton = screen.getByRole('button') + + // Open the panel + fireEvent.click(bellButton) + expect(screen.getByRole('dialog') || screen.getByText(/notification/i)).toBeInTheDocument() + + // Press Escape + fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' }) + + // Panel should be closed (depending on implementation) + }) + + it('should not close when other keys are pressed', () => { + render() + const bellButton = screen.getByRole('button') + fireEvent.click(bellButton) + + fireEvent.keyDown(document, { key: 'Enter', code: 'Enter' }) + + // Should still be open or handle gracefully + expect(bellButton).toBeInTheDocument() + }) + }) + + describe('empty state', () => { + it('should render "No notifications yet" when no notifications exist', () => { + mockUseNotifications.mockReturnValue({ + notifications: [], + unreadCount: 0, + markAllRead: jest.fn(), + markAsRead: jest.fn(), + clearNotifications: jest.fn(), + }) + + render() + const bellButton = screen.getByRole('button') + fireEvent.click(bellButton) + + expect(screen.getByText(/no notifications yet/i)).toBeInTheDocument() + }) + + it('should show empty state message when all notifications are cleared', () => { + const clearMock = jest.fn() + mockUseNotifications.mockReturnValue({ + notifications: [], + unreadCount: 0, + markAllRead: jest.fn(), + markAsRead: jest.fn(), + clearNotifications: clearMock, + }) + + render() + const bellButton = screen.getByRole('button') + fireEvent.click(bellButton) + + expect(screen.getByText(/no notifications yet/i)).toBeInTheDocument() + }) + }) + + describe('notification interactions', () => { + it('should call markAsRead when a notification is clicked', () => { + const markAsReadMock = jest.fn() + mockUseNotifications.mockReturnValue({ + notifications: mockNotifications, + unreadCount: 2, + markAllRead: jest.fn(), + markAsRead: markAsReadMock, + clearNotifications: jest.fn(), + }) + + render() + const bellButton = screen.getByRole('button') + fireEvent.click(bellButton) + + const notification = screen.getByText('Stream created') + fireEvent.click(notification) + + expect(markAsReadMock).toHaveBeenCalledWith('1') + }) + + it('should display multiple notifications in the panel', () => { + render() + const bellButton = screen.getByRole('button') + fireEvent.click(bellButton) + + expect(screen.getByText('Stream created')).toBeInTheDocument() + expect(screen.getByText('Payment received')).toBeInTheDocument() + }) + }) +}) diff --git a/components/streams/activity-feed.test.tsx b/components/streams/activity-feed.test.tsx new file mode 100644 index 0000000..60b40a4 --- /dev/null +++ b/components/streams/activity-feed.test.tsx @@ -0,0 +1,216 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { ActivityFeed } from './activity-feed' + +jest.mock('@/hooks/use-stream-activity', () => ({ + useStreamActivity: jest.fn(), +})) + +const mockUseStreamActivity = require('@/hooks/use-stream-activity').useStreamActivity as jest.MockedFunction + +describe('ActivityFeed', () => { + const mockEvents = [ + { id: '1', type: 'created', timestamp: Date.now(), description: 'Stream created' }, + { id: '2', type: 'deposit', timestamp: Date.now(), description: 'Deposit received' }, + { id: '3', type: 'withdrawal', timestamp: Date.now(), description: 'Funds withdrawn' }, + ] + + beforeEach(() => { + jest.clearAllMocks() + mockUseStreamActivity.mockReturnValue({ + events: mockEvents, + hasMore: false, + loading: false, + loadMore: jest.fn(), + filterByType: jest.fn(), + selectedFilter: 'all', + }) + }) + + describe('empty state', () => { + it('should render "No activity yet" when no events exist', () => { + mockUseStreamActivity.mockReturnValue({ + events: [], + hasMore: false, + loading: false, + loadMore: jest.fn(), + filterByType: jest.fn(), + selectedFilter: 'all', + }) + + render() + expect(screen.getByText(/no activity yet/i)).toBeInTheDocument() + }) + + it('should render only empty state message and not load-more button', () => { + mockUseStreamActivity.mockReturnValue({ + events: [], + hasMore: true, + loading: false, + loadMore: jest.fn(), + filterByType: jest.fn(), + selectedFilter: 'all', + }) + + render() + expect(screen.getByText(/no activity yet/i)).toBeInTheDocument() + expect(screen.queryByText(/load more/i)).not.toBeInTheDocument() + }) + }) + + describe('pagination with hasMore', () => { + it('should not display load-more button when hasMore is false', () => { + mockUseStreamActivity.mockReturnValue({ + events: mockEvents, + hasMore: false, + loading: false, + loadMore: jest.fn(), + filterByType: jest.fn(), + selectedFilter: 'all', + }) + + render() + expect(screen.queryByText(/load more/i)).not.toBeInTheDocument() + }) + + it('should display load-more button when hasMore is true', () => { + mockUseStreamActivity.mockReturnValue({ + events: mockEvents, + hasMore: true, + loading: false, + loadMore: jest.fn(), + filterByType: jest.fn(), + selectedFilter: 'all', + }) + + render() + expect(screen.getByText(/load more/i)).toBeInTheDocument() + }) + + it('should call loadMore when load-more button is clicked', () => { + const loadMoreMock = jest.fn() + mockUseStreamActivity.mockReturnValue({ + events: mockEvents, + hasMore: true, + loading: false, + loadMore: loadMoreMock, + filterByType: jest.fn(), + selectedFilter: 'all', + }) + + render() + const loadMoreButton = screen.getByText(/load more/i) + fireEvent.click(loadMoreButton) + + expect(loadMoreMock).toHaveBeenCalled() + }) + + it('should disable load-more button while loading', () => { + mockUseStreamActivity.mockReturnValue({ + events: mockEvents, + hasMore: true, + loading: true, + loadMore: jest.fn(), + filterByType: jest.fn(), + selectedFilter: 'all', + }) + + render() + const loadMoreButton = screen.getByText(/load more/i) as HTMLButtonElement + expect(loadMoreButton.disabled).toBe(true) + }) + }) + + describe('event type filtering', () => { + it('should render filter buttons for event types', () => { + render() + expect(screen.getByText(/all/i)).toBeInTheDocument() + expect(screen.getByText(/created/i) || screen.getByText(/deposit/i)).toBeInTheDocument() + }) + + it('should show only events matching selected filter', () => { + const filterByTypeMock = jest.fn() + mockUseStreamActivity.mockReturnValue({ + events: [mockEvents[1], mockEvents[2]], // Only deposit and withdrawal + hasMore: false, + loading: false, + loadMore: jest.fn(), + filterByType: filterByTypeMock, + selectedFilter: 'deposit', + }) + + render() + + // Events should be filtered + expect(screen.getByText('Deposit received')).toBeInTheDocument() + expect(screen.getByText('Funds withdrawn')).toBeInTheDocument() + expect(screen.queryByText('Stream created')).not.toBeInTheDocument() + }) + + it('should call filterByType when filter button is clicked', () => { + const filterByTypeMock = jest.fn() + mockUseStreamActivity.mockReturnValue({ + events: mockEvents, + hasMore: false, + loading: false, + loadMore: jest.fn(), + filterByType: filterByTypeMock, + selectedFilter: 'all', + }) + + render() + const depositFilter = screen.getByText(/deposit/i) + fireEvent.click(depositFilter) + + expect(filterByTypeMock).toHaveBeenCalledWith('deposit') + }) + + it('should highlight the selected filter button', () => { + mockUseStreamActivity.mockReturnValue({ + events: mockEvents, + hasMore: false, + loading: false, + loadMore: jest.fn(), + filterByType: jest.fn(), + selectedFilter: 'deposit', + }) + + render() + const depositFilter = screen.getByText(/deposit/i).closest('button') + + expect(depositFilter).toHaveClass('active') || expect(depositFilter).toHaveClass('bg-secondary') + }) + }) + + describe('event display', () => { + it('should display all events in feed', () => { + render() + + mockEvents.forEach(event => { + expect(screen.getByText(event.description)).toBeInTheDocument() + }) + }) + + it('should display events in reverse chronological order', () => { + const orderedEvents = [ + { id: '1', type: 'created', timestamp: 1000, description: 'Event 1' }, + { id: '2', type: 'deposit', timestamp: 2000, description: 'Event 2' }, + { id: '3', type: 'withdrawal', timestamp: 3000, description: 'Event 3' }, + ] + + mockUseStreamActivity.mockReturnValue({ + events: orderedEvents, + hasMore: false, + loading: false, + loadMore: jest.fn(), + filterByType: jest.fn(), + selectedFilter: 'all', + }) + + render() + + const eventElements = screen.getAllByText(/Event/) + expect(eventElements[0]).toHaveTextContent('Event 3') + expect(eventElements[eventElements.length - 1]).toHaveTextContent('Event 1') + }) + }) +}) diff --git a/components/ui/accessible-countdown-timer.stories.tsx b/components/ui/accessible-countdown-timer.stories.tsx new file mode 100644 index 0000000..9eda340 --- /dev/null +++ b/components/ui/accessible-countdown-timer.stories.tsx @@ -0,0 +1,163 @@ +import type { Meta, StoryObj } from "@storybook/react" +import { AccessibleCountdownTimer } from "./accessible-countdown-timer" + +const meta = { + title: "UI/AccessibleCountdownTimer", + component: AccessibleCountdownTimer, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +} satisfies Meta + +export default meta +type Story = StoryObj + +// Far future target (30 days from now) +export const FarFuture: Story = { + render: () => { + const now = Math.floor(Date.now() / 1000) + const thirtyDaysFromNow = BigInt(now + 30 * 24 * 60 * 60) + return ( +
+

Target: 30 days from now

+ +

+ Screen reader will announce: "30 days, 0 hours, 0 minutes remaining" +

+
+ ) + }, +} + +// Imminent (less than 1 minute remaining) +export const Imminent: Story = { + render: () => { + const now = Math.floor(Date.now() / 1000) + const thirtySecondsFromNow = BigInt(now + 30) + return ( +
+

Target: 30 seconds from now

+ +

+ Screen reader will announce: "0 minutes, 30 seconds remaining" +

+
+ ) + }, +} + +// Already expired +export const Expired: Story = { + render: () => { + const now = Math.floor(Date.now() / 1000) + const oneHourAgo = BigInt(now - 60 * 60) + return ( +
+

Target: 1 hour ago

+ +

+ Screen reader will announce: "Timer expired" +

+
+ ) + }, +} + +// One day remaining +export const OneDayRemaining: Story = { + render: () => { + const now = Math.floor(Date.now() / 1000) + const oneDayFromNow = BigInt(now + 24 * 60 * 60) + return ( +
+

Target: 1 day from now

+ +

+ Screen reader will announce: "1 day, 0 hours, 0 minutes remaining" +

+
+ ) + }, +} + +// One hour remaining +export const OneHourRemaining: Story = { + render: () => { + const now = Math.floor(Date.now() / 1000) + const oneHourFromNow = BigInt(now + 60 * 60) + return ( +
+

Target: 1 hour from now

+ +

+ Screen reader will announce: "1 hour, 0 minutes remaining" +

+
+ ) + }, +} + +// Five minutes remaining +export const FiveMinutesRemaining: Story = { + render: () => { + const now = Math.floor(Date.now() / 1000) + const fiveMinutesFromNow = BigInt(now + 5 * 60) + return ( +
+

Target: 5 minutes from now

+ +

+ Screen reader will announce: "5 minutes remaining" +

+
+ ) + }, +} + +// Custom ended label with accessibility +export const CustomEndedLabel: Story = { + render: () => { + const now = Math.floor(Date.now() / 1000) + const pastTime = BigInt(now - 60 * 60) + return ( +
+

Custom ended label: "Completed"

+ +

+ Screen reader will announce: "Completed" +

+
+ ) + }, +} + +// Multiple timers demonstrating live region updates +export const MultipleTimers: Story = { + render: () => { + const now = Math.floor(Date.now() / 1000) + const farFuture = BigInt(now + 30 * 24 * 60 * 60) + const imminent = BigInt(now + 30) + const expired = BigInt(now - 60 * 60) + + return ( +
+
+

Far future:

+ +
+
+

Imminent:

+ +
+
+

Expired:

+ +
+

+ Screen readers will announce each timer's state including live updates as they change +

+
+ ) + }, +} diff --git a/components/ui/accessible-unlock-amount.stories.tsx b/components/ui/accessible-unlock-amount.stories.tsx new file mode 100644 index 0000000..f137df4 --- /dev/null +++ b/components/ui/accessible-unlock-amount.stories.tsx @@ -0,0 +1,181 @@ +import type { Meta, StoryObj } from "@storybook/react" +import { AccessibleUnlockAmount } from "./accessible-unlock-amount" + +const meta = { + title: "UI/AccessibleUnlockAmount", + component: AccessibleUnlockAmount, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +} satisfies Meta + +export default meta +type Story = StoryObj + +// Fully unlocked state (100%) +export const FullyUnlocked: Story = { + render: () => ( +
+

Fully unlocked (100%)

+ +

+ Screen reader will announce: "10000 USDC unlocked out of 10000 USDC total" +

+
+ ), +} + +// Partially unlocked state (50%) +export const PartiallyUnlocked: Story = { + render: () => ( +
+

Partially unlocked (50%)

+ +

+ Screen reader will announce: "5000 USDC unlocked out of 10000 USDC total" +

+
+ ), +} + +// No unlock yet (0%) +export const NoUnlock: Story = { + render: () => ( +
+

No unlock (0%)

+ +

+ Screen reader will announce: "0 USDC unlocked out of 10000 USDC total" +

+
+ ), +} + +// Partial unlock with different token +export const PartialUnlockWithEUR: Story = { + render: () => ( +
+

Partially unlocked EUR (25%)

+ +

+ Screen reader will announce: "2500 EUR unlocked out of 10000 EUR total" +

+
+ ), +} + +// Quarter unlocked (25%) +export const QuarterUnlocked: Story = { + render: () => ( +
+

Quarter unlocked (25%)

+ +

+ Screen reader will announce: "2500 USDT unlocked out of 10000 USDT total" +

+
+ ), +} + +// Three-quarter unlocked (75%) +export const ThreeQuarterUnlocked: Story = { + render: () => ( +
+

Three-quarter unlocked (75%)

+ +

+ Screen reader will announce: "7500 USDC unlocked out of 10000 USDC total" +

+
+ ), +} + +// Large amounts +export const LargeAmounts: Story = { + render: () => ( +
+

Large amount - half unlocked

+ +

+ Screen reader will announce: "500000000 USDC unlocked out of 1000000000 USDC total" +

+
+ ), +} + +// Comparison of multiple unlock states +export const UnlockComparison: Story = { + render: () => ( +
+
+

0% Unlocked:

+ +
+
+

25% Unlocked:

+ +
+
+

50% Unlocked:

+ +
+
+

75% Unlocked:

+ +
+
+

100% Unlocked:

+ +
+
+ ), +}