This document documents all missing edge cases and validation test scenarios for the CreateStreamModal component to support issue #1113.
This document builds on existing test coverage in src/components/tests/CreateStreamModal*.ts* to identify and plan implementation of edge cases for stream creation modal validation.
-
Dates & Cross-field Validation (dates.test.tsx:135-262)
- Cliff-before-end validation
- Custom start date validation
- Cliff and start date ordering
- Date format and timezone handling
-
Recipient Validation (recipient.test.tsx:1-149)
- Self-send validation (exact match)
- Self-send validation (case insensitive)
- Self-send validation (whitespace insensitive)
- Disconnected wallet bypass
-
Bounds Validation (bounds.test.tsx:31-86)
- Upper bounds on accrual rate and duration
- Precise boundary handling at MAX_ACCRUAL_RATE and MAX_DURATION_DAYS
- Error messaging for out-of-bounds values
-
Property-Based Testing (property.test.tsx:27-103)
- Field-level validation updates on touch
- Untouched fields show no validation state
- Validation state persistence during mode switching
-
Stepper Navigation (stepper.test.tsx:46-144)
- Step navigation with progress tracking
- Keyboard accessibility for stepper
- Disabled states during submission
File: src/components/__tests__/CreateStreamModal.loading.test.tsx
Test Scenarios:
test('cancel button disabled when submission is in flight', async () => {
// Mock createStream to return a promise that resolves later
const { container } = renderModal();
await advanceToStep3(container);
clickSubmitButton(container);
// Cancel button should be disabled
const cancelBtn = screen.getByRole('button', { name: /cancel/i });
expect(cancelBtn).toBeDisabled();
// Close modal should also be disabled
const closeBtn = screen.getByTestId('close-button');
expect(closeBtn).toBeDisabled();
});test('modal lock state when submission is queued', async () => {
vi.useFakeTimers();
const { container } = renderModal();
await advanceToStep3(container);
// Simulate offline submission
setOnlineStatus(false);
clickSubmitButton(container);
// Offline queue banner should appear
expect(screen.getByText(/offline queue/i)).toBeInTheDocument();
// Navigation buttons should be blocked
const backBtn = screen.getByRole('button', { name: /back/i });
expect(backBtn).toBeDisabled();
});test('loading state during queue auto-flush on reconnection', async () => {
vi.useFakeTimers();
const { container } = renderModal();
await advanceToStep3(container);
// Create queued submission
setOnlineStatus(false);
clickSubmitButton(container);
// Simulate connection restoration
setOnlineStatus(true);
// Queue flush process should show loading
expect(screen.getByText(/flushing queue/i)).toBeInTheDocument();
// Modal UI should remain locked during flush
const cancelBtn = screen.getByRole('button', { name: /cancel/i });
expect(cancelBtn).toBeDisabled();
});File: src/components/__tests__/CreateStreamModal.emptyStates.test.tsx
Test Scenarios:
test('reset to step 1 with all fields cleared', () => {
const { container } = renderModal();
// Fill form completely
fillStep1(container);
await goToStep2(container);
fillStep2(container);
await goToStep3(container);
// Navigate back to step 1
clickBackButton(container);
// All fields should be empty in step 1
const recipientInput = container.querySelector('#create-stream-recipient');
const depositInput = container.querySelector('#create-stream-deposit');
expect(recipientInput).toHaveValue('');
expect(depositInput).toHaveValue('');
// Validation state should be neutral (no error/success classes)
expect(recipientInput.closest('.input-container')).not.toHaveClass('input-container--error');
expect(depositInput.closest('.input-container')).not.toHaveClass('input-container--success');
});test('reject submission when all required fields empty', () => {
const { container } = renderModal();
// Stay in step 1 (all fields empty)
// Try to advance - should be blocked
const nextBtn = within(container).getByRole('button', { name: /^next$/i });
fireEvent.click(nextBtn);
// Should still be on step 1
expect(container.querySelector('[aria-current="step"]')?.textContent).toContain('1');
// Error should be shown on recipient field
expect(screen.getByText(/recipient is required/i)).toBeInTheDocument();
});test('empty validation triggers on field blur, not change', () => {
const { container } = renderModal();
advanceToStep2(container);
const rateInput = container.querySelector('#create-stream-accrual-rate');
// Changing field to empty should NOT trigger error immediately
fireEvent.change(rateInput, { target: { value: '50' } });
fireEvent.change(rateInput, { target: { value: '' } });
// Still in step 2 (no error yet)
expect(screen.getByRole('heading', { name: /rate & schedule/i })).toBeInTheDocument();
// Blur should trigger validation
fireEvent.blur(rateInput);
// Should show error now
expect(screen.getByText(/rate must be positive/i)).toBeInTheDocument();
});File: src/components/__tests__/CreateStreamModal.keyboard.test.tsx
Test Scenarios:
test('navigate between form fields using Tab key in wizard mode', () => {
const { container } = renderModal();
// Enter single stream mode
clickStepMode(container, 'single');
// In step 1, Tab should move: recipient → deposit → next button
const recipientInput = container.querySelector('#create-stream-recipient');
const depositInput = container.querySelector('#create-stream-deposit');
// Focus recipient field initially
expect(recipientInput).toHaveFocus();
// Tab to deposit field
fireEvent.keyDown(recipientInput, { key: 'Tab', keyCode: 9 });
expect(depositInput).toHaveFocus();
// Tab to next button
fireEvent.keyDown(depositInput, { key: 'Tab', keyCode: 9 });
const nextBtn = within(container).getByRole('button', { name: /^next$/i });
expect(nextBtn).toHaveFocus();
});test('submit form using Enter key in advanced mode (no wizard)', () => {
const { container } = renderModal();
// Enter advanced mode (skip wizard)
clickAdvancedMode(container);
// Fill form
fillAdvancedForm(container);
// Focus on review section
const reviewSection = container.querySelector('.review-cards');
reviewSection.focus();
// Enter key should trigger submission
fireEvent.keyDown(reviewSection, { key: 'Enter', keyCode: 13 });
// Submit process should start
expect(screen.getByText(/submitting stream/i)).toBeInTheDocument();
});test('stepper keyboard navigation - arrow keys, Home, End', () => {
const { container } = renderModal();
// Navigate to step 2
advanceToStep2(container);
// In advanced mode, stepper items should be keyboard accessible
if (window.modalState?.wizardMode === false) {
// Arrow left/right to focus stepper navigation
// Home/End to jump to first/last completed step
// Enter/Space to jump to completed step
}
});File: src/components/__tests__/CreateStreamModal.responsive.test.tsx
Test Scenarios:
test('modal layout and validation on mobile viewport', async () => {
// Set viewport to mobile size
await setViewport({ width: 375, height: 667 });
const { container } = renderModal();
// Form should still validate correctly
fillStep1(container);
fireEvent.click(within(container).getByRole('button', { name: /^next$/i }));
// All validation should work
expect(container.querySelector('[aria-current="step"]')?.textContent).toContain('2');
});test('touch interaction patterns for form submission', async () => {
await setViewport({ width: 375, height: 667 });
const { container } = renderModal();
// Long-press on segments should be prevented from triggering accidental changes
const nowBtn = container.querySelector('.segment-btn');
fireEvent.mouseDown(nowBtn, { detail: 1 });
fireEvent.mouseUp(nowBtn);
// Should not trigger rapid changes
expect(getStartTimeOption()).toBe('now');
});test('modal swipe gestures on mobile (if supported)', async () => {
await setViewport({ width: 375, height: 667 });
const { container } = renderModal();
// Document container swipe behavior
const modalContainer = container.querySelector('.modal-overlay');
// Should not close modal via swipe (intentionally)
fireEvent.touchStart(modalContainer, { touches: [{ clientX: 300, clientY: 200 }] });
fireEvent.touchMove(modalContainer, { touches: [{ clientX: 50, clientY: 200 }] });
// Modal should remain open
expect(modalContainer).toBeInTheDocument();
});File: src/components/__tests__/CreateStreamModal.errorStates.test.tsx
Test Scenarios:
test('display multiple inline errors simultaneously', () => {
const { container } = renderModal();
advanceToStep2(container);
const rateInput = container.querySelector('#create-stream-accrual-rate');
const durationInput = container.querySelector('#create-stream-duration');
// Create validation failures
fireEvent.change(rateInput, { target: { value: 'abc' } }); // Not a number
fireEvent.change(durationInput, { target: { value: '0' } }); // Zero value
fireEvent.blur(rateInput);
fireEvent.blur(durationInput);
// Both error messages should be visible
expect(screen.getByText(/rate must be positive number/i)).toBeInTheDocument();
expect(screen.getByText(/duration must be positive/i)).toBeInTheDocument();
// Next button should remain disabled
const nextBtn = within(container).getByRole('button', { name: /^next$/i });
expect(nextBtn).toBeDisabled();
});test('error messages are announced to screen readers', () => {
const { container } = renderModal();
advanceToStep2(container);
const rateInput = container.querySelector('#create-stream-accrual-rate');
fireEvent.change(rateInput, { target: { value: '-100' } });
fireEvent.blur(rateInput);
// Error message should have proper ARIA attributes
const errorMessage = screen.getByRole('alert');
expect(errorMessage).toHaveAttribute('aria-live', 'assertive');
});- Loading state edge cases (4 tests)
- Empty states edge cases (3 tests)
- Keyboard navigation (3 tests)
- Error state edge cases (2 tests)
- Responsive behavior (3 tests)
For each test case, ensure:
- Mock Wallet Context: Properly mock useWallet() hook
- Toast Provider: Wrap components with ToastProvider
- Async Handling: Handle promises and async effects
- Event Simulation: Use fireEvent for realistic interactions
- Cleanup: Clean up afterEach test
- Viewport Testing: Use Vi's mock/hack for viewport simulations
// vitest.config.js
{
testEnvironment: 'jsdom',
setupFiles: ['./src/components/__tests__/setup.ts'],
globals: true,
include: [
'src/components/**/CreateStreamModal*.test.tsx'
]
}Run the test suite to achieve minimum coverage:
npm test -- --coverage --testNamePattern="CreateStreamModal"Target coverage areas (based on missing test plan):
- Loading state coverage: 80%
- Empty state coverage: 90%
- Keyboard navigation coverage: 70%
- Error state coverage: 85%
- Responsive behavior coverage: 60%
Total new tests required: 17 tests across 5 test files
- Loading.test.tsx: 4 tests
- EmptyStates.test.tsx: 3 tests
- Keyboard.test.tsx: 3 tests
- Responsive.test.tsx: 3 tests
- ErrorStates.test.tsx: 2 tests
- Plus supporting utility files (setup.ts, testUtils.ts)
This test plan documents all missing edge cases for CreateStreamModal validation, providing a comprehensive testing strategy that addresses issue #1113 concerns.
All tests will be backward compatible and focus on ensuring explicit behavior documentation and regression safety.