{{ theme.skipToContentLabel || 'Skip to content' }}

Unit Testing Document

Parent Document: TEST_PLAN.mdVersion: 1.2 | Date: January 5, 2026


1. Overview

1.1 Purpose

This document defines the unit testing strategy, standards, and test cases for the Dustac Environmental Monitoring Dashboard. Unit tests validate individual functions, components, and modules in isolation.

1.2 Scope

  • Service classes and static methods
  • Utility functions
  • React hooks
  • React components (presentation logic)
  • Data transformation functions
  • Validation logic

1.3 Tools & Framework

ToolVersionPurpose
Vitest^2.xTest runner and assertions
React Testing Library^14.xComponent testing
MSW^2.xAPI mocking
@testing-library/user-event^14.xUser interaction simulation

2. Testing Standards

2.1 Coverage Targets

CategoryLine CoverageBranch Coverage
Services≥85%≥75%
Utilities≥90%≥80%
Hooks≥80%≥70%
Components≥70%≥60%

2.2 File Naming Convention

src/
├── features/
│   └── reports/
│       ├── services/
│       │   ├── reportDataService.ts
│       │   └── reportDataService.test.ts    # Co-located test
│       └── hooks/
│           ├── useReports.ts
│           └── useReports.test.ts
└── lib/
    ├── utils.ts
    └── utils.test.ts

2.3 Test Structure (AAA Pattern)

typescript
describe('FunctionName', () => {
  it('should [expected behavior] when [condition]', () => {
    // Arrange - Setup test data and conditions
    const input = { ... };

    // Act - Execute the function
    const result = functionName(input);

    // Assert - Verify the outcome
    expect(result).toBe(expected);
  });
});

3. Test Commands

bash
# Run all unit tests
pnpm test:unit

# Run tests in watch mode
pnpm test:unit -- --watch

# Run specific test file
pnpm test:unit -- src/features/reports/services/reportDataService.test.ts

# Run tests matching pattern
pnpm test:unit -- -t "prepareDeviceTimeline"

# Generate coverage report
pnpm test:unit:coverage

# Run with UI
pnpm test:unit -- --ui

4. Unit Test Cases by Module

4.1 Report Data Service (reportDataService.ts)

TC-UNIT-REPORT-001: getMeasurements - Fetch All Measurements

FieldValue
FunctionReportDataService.getMeasurements()
PriorityP0
TypeFunctional

Test Code:

typescript
describe('ReportDataService.getMeasurements', () => {
  it('should fetch all measurements for upload ID', async () => {
    // Arrange
    const uploadId = 'test-upload-123';
    mockSupabase.from('upload_measurements').select.mockResolvedValue({
      data: mockMeasurements,
      error: null
    });

    // Act
    const result = await ReportDataService.getMeasurements(uploadId);

    // Assert
    expect(result).toHaveLength(mockMeasurements.length);
    expect(mockSupabase.from).toHaveBeenCalledWith('upload_measurements');
  });

  it('should filter by csvFileId when provided', async () => {
    const uploadId = 'test-upload-123';
    const csvFileId = 'csv-file-456';

    await ReportDataService.getMeasurements(uploadId, csvFileId);

    expect(mockSupabase.eq).toHaveBeenCalledWith('csv_file_id', csvFileId);
  });

  it('should throw error when Supabase returns error', async () => {
    mockSupabase.from('upload_measurements').select.mockResolvedValue({
      data: null,
      error: { message: 'Database error' }
    });

    await expect(ReportDataService.getMeasurements('id'))
      .rejects.toThrow('Database error');
  });
});

TC-UNIT-REPORT-002: calculateStatistics - Basic Statistics

FieldValue
FunctionReportDataService.calculateStatistics()
PriorityP0
TypeFunctional

Test Code:

typescript
describe('ReportDataService.calculateStatistics', () => {
  const mockMeasurements: MeasurementData[] = [
    { time: '2025-01-01T00:00:00Z', numberconcentrations2p5: 10, ... },
    { time: '2025-01-01T01:00:00Z', numberconcentrations2p5: 20, ... },
    { time: '2025-01-01T02:00:00Z', numberconcentrations2p5: 30, ... },
  ];

  it('should calculate correct min, max, avg', () => {
    const result = ReportDataService.calculateStatistics(mockMeasurements);

    expect(result.particleConcentrations.nc_2_5.min).toBe(10);
    expect(result.particleConcentrations.nc_2_5.max).toBe(30);
    expect(result.particleConcentrations.nc_2_5.avg).toBe(20);
  });

  it('should calculate correct median for odd count', () => {
    const result = ReportDataService.calculateStatistics(mockMeasurements);
    expect(result.particleConcentrations.nc_2_5.median).toBe(20);
  });

  it('should return zero stats for empty array', () => {
    const result = ReportDataService.calculateStatistics([]);
    expect(result.totalMeasurements).toBe(0);
  });

  it('should calculate data quality completeness', () => {
    const result = ReportDataService.calculateStatistics(mockMeasurements);
    expect(result.dataQuality.completeness).toBeGreaterThan(0);
    expect(result.dataQuality.completeness).toBeLessThanOrEqual(100);
  });
});

TC-UNIT-REPORT-003: prepareDeviceTimeline - Gap Detection

FieldValue
FunctionReportDataService.prepareDeviceTimeline()
PriorityP1
TypeFunctional

Test Code:

typescript
describe('ReportDataService.prepareDeviceTimeline', () => {
  it('should return empty array for empty measurements', () => {
    const result = ReportDataService.prepareDeviceTimeline([]);
    expect(result).toEqual([]);
  });

  it('should detect no gaps when all records within 1 hour', () => {
    const measurements = generateMeasurements({
      device: 'DEVICE-001',
      count: 10,
      intervalMinutes: 30 // 30 min intervals
    });

    const result = ReportDataService.prepareDeviceTimeline(measurements);

    expect(result).toHaveLength(1);
    expect(result[0].dataGaps).toBe(0);
    expect(result[0].segments).toHaveLength(1);
  });

  it('should detect single gap when >1 hour between records', () => {
    const measurements = [
      ...generateMeasurements({ device: 'DEVICE-001', count: 5, startTime: '2025-01-01T00:00:00Z' }),
      ...generateMeasurements({ device: 'DEVICE-001', count: 5, startTime: '2025-01-01T03:00:00Z' }) // 2hr gap
    ];

    const result = ReportDataService.prepareDeviceTimeline(measurements);

    expect(result[0].dataGaps).toBe(1);
    expect(result[0].segments).toHaveLength(2);
  });

  it('should detect multiple gaps correctly', () => {
    const measurements = [
      ...generateMeasurements({ device: 'DEVICE-001', count: 3, startTime: '2025-01-01T00:00:00Z' }),
      ...generateMeasurements({ device: 'DEVICE-001', count: 3, startTime: '2025-01-01T05:00:00Z' }),
      ...generateMeasurements({ device: 'DEVICE-001', count: 3, startTime: '2025-01-01T10:00:00Z' })
    ];

    const result = ReportDataService.prepareDeviceTimeline(measurements);

    expect(result[0].dataGaps).toBe(2);
    expect(result[0].segments).toHaveLength(3);
  });

  it('should handle exactly 60 minute gap as NO gap (boundary)', () => {
    const measurements = [
      { time: '2025-01-01T00:00:00Z', loc: 'DEVICE-001', ... },
      { time: '2025-01-01T01:00:00Z', loc: 'DEVICE-001', ... } // exactly 60 min
    ];

    const result = ReportDataService.prepareDeviceTimeline(measurements);
    expect(result[0].dataGaps).toBe(0);
  });

  it('should handle 61 minute gap as gap (boundary)', () => {
    const measurements = [
      { time: '2025-01-01T00:00:00Z', loc: 'DEVICE-001', ... },
      { time: '2025-01-01T01:01:00Z', loc: 'DEVICE-001', ... } // 61 min
    ];

    const result = ReportDataService.prepareDeviceTimeline(measurements);
    expect(result[0].dataGaps).toBe(1);
  });

  it('should group measurements by device', () => {
    const measurements = [
      ...generateMeasurements({ device: 'DEVICE-001', count: 5 }),
      ...generateMeasurements({ device: 'DEVICE-002', count: 5 }),
      ...generateMeasurements({ device: 'DEVICE-003', count: 5 })
    ];

    const result = ReportDataService.prepareDeviceTimeline(measurements);

    expect(result).toHaveLength(3);
    expect(result.map(d => d.device)).toContain('DEVICE-001');
    expect(result.map(d => d.device)).toContain('DEVICE-002');
    expect(result.map(d => d.device)).toContain('DEVICE-003');
  });

  it('should sort devices by firstSeen timestamp', () => {
    const measurements = [
      { time: '2025-01-01T10:00:00Z', loc: 'DEVICE-C', ... },
      { time: '2025-01-01T05:00:00Z', loc: 'DEVICE-A', ... },
      { time: '2025-01-01T08:00:00Z', loc: 'DEVICE-B', ... }
    ];

    const result = ReportDataService.prepareDeviceTimeline(measurements);

    expect(result[0].device).toBe('DEVICE-A');
    expect(result[1].device).toBe('DEVICE-B');
    expect(result[2].device).toBe('DEVICE-C');
  });

  it('should calculate segment record counts correctly', () => {
    const measurements = [
      ...generateMeasurements({ device: 'DEVICE-001', count: 10, startTime: '2025-01-01T00:00:00Z' }),
      ...generateMeasurements({ device: 'DEVICE-001', count: 5, startTime: '2025-01-01T05:00:00Z' })
    ];

    const result = ReportDataService.prepareDeviceTimeline(measurements);

    expect(result[0].segments[0].recordCount).toBe(10);
    expect(result[0].segments[1].recordCount).toBe(5);
    expect(result[0].recordCount).toBe(15);
  });
});

TC-UNIT-REPORT-004: applySampling - Data Sampling

FieldValue
FunctionReportDataService.applySampling()
PriorityP1
TypeFunctional

Test Code:

typescript
describe('ReportDataService.applySampling', () => {
  const data = Array.from({ length: 100 }, (_, i) => ({ id: i }));

  it('should return all data when samplingRate is 100', () => {
    const result = ReportDataService['applySampling'](data, 100);
    expect(result).toHaveLength(100);
  });

  it('should return approximately 50% when samplingRate is 50', () => {
    const result = ReportDataService['applySampling'](data, 50);
    expect(result.length).toBeGreaterThan(45);
    expect(result.length).toBeLessThan(55);
  });

  it('should always include first and last data points', () => {
    const result = ReportDataService['applySampling'](data, 10);
    expect(result[0]).toEqual({ id: 0 });
    expect(result[result.length - 1]).toEqual({ id: 99 });
  });

  it('should handle empty array', () => {
    const result = ReportDataService['applySampling']([], 50);
    expect(result).toEqual([]);
  });

  it('should clamp samplingRate to valid range', () => {
    const result1 = ReportDataService['applySampling'](data, 0);
    expect(result1.length).toBeGreaterThan(0); // clamped to 1%

    const result2 = ReportDataService['applySampling'](data, 150);
    expect(result2).toHaveLength(100); // clamped to 100%
  });
});

4.2 CSV Parser Service (csvParser.ts)

TC-UNIT-CSV-001: Parse Valid CSV

typescript
describe('CsvParser.parse', () => {
  it('should parse valid CSV with all required columns', async () => {
    const csvContent = `time,loc,numberconcentrations2p5,humidity
2025-01-01T00:00:00Z,DEVICE-001,25.5,45
2025-01-01T01:00:00Z,DEVICE-001,30.2,48`;

    const result = await CsvParser.parse(csvContent);

    expect(result.success).toBe(true);
    expect(result.data).toHaveLength(2);
    expect(result.data[0].loc).toBe('DEVICE-001');
  });

  it('should handle UTF-8 BOM correctly', async () => {
    const csvWithBom = '\uFEFFtime,loc,value\n2025-01-01,DEVICE,10';
    const result = await CsvParser.parse(csvWithBom);

    expect(result.success).toBe(true);
    expect(result.headers).toContain('time');
  });

  it('should return error for missing required columns', async () => {
    const csvContent = `loc,value\nDEVICE,10`;
    const result = await CsvParser.parse(csvContent);

    expect(result.success).toBe(false);
    expect(result.errors).toContain('Missing required column: time');
  });

  it('should handle empty file', async () => {
    const result = await CsvParser.parse('');
    expect(result.success).toBe(false);
  });
});

4.3 Date Utilities (utils.ts)

TC-UNIT-UTIL-001: Date Formatting

typescript
describe('formatDate', () => {
  it('should format date to YYYY-MM-DD', () => {
    const date = new Date('2025-01-15T10:30:00Z');
    expect(formatDate(date)).toBe('2025-01-15');
  });

  it('should handle timezone correctly for Perth (AWST)', () => {
    const date = new Date('2025-01-15T20:00:00Z'); // 4am next day in Perth
    expect(formatDateInPerth(date)).toBe('2025-01-16');
  });
});

describe('parseISODate', () => {
  it('should parse ISO date string', () => {
    const result = parseISODate('2025-01-15T10:30:00Z');
    expect(result).toBeInstanceOf(Date);
    expect(result.getUTCFullYear()).toBe(2025);
  });

  it('should return null for invalid date', () => {
    expect(parseISODate('invalid')).toBeNull();
  });
});

4.4 React Hooks

TC-UNIT-HOOK-001: useReports Hook

typescript
describe('useReports', () => {
  it('should initialize with empty reports array', () => {
    const { result } = renderHook(() => useReports());
    expect(result.current.reports).toEqual([]);
    expect(result.current.loading).toBe(true);
  });

  it('should fetch reports on mount', async () => {
    mockReportService.fetchReports.mockResolvedValue(mockReports);

    const { result } = renderHook(() => useReports());

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.reports).toEqual(mockReports);
  });

  it('should set error state on fetch failure', async () => {
    mockReportService.fetchReports.mockRejectedValue(new Error('Network error'));

    const { result } = renderHook(() => useReports());

    await waitFor(() => {
      expect(result.current.error).toBe('Network error');
    });
  });
});

4.5 React Components

TC-UNIT-COMP-001: DeviceTimelineChartECharts

typescript
describe('DeviceTimelineChartECharts', () => {
  const mockData: DeviceTimelineDataPoint[] = [
    {
      device: 'DEVICE-001',
      firstSeen: new Date('2025-01-01T00:00:00Z'),
      lastSeen: new Date('2025-01-01T10:00:00Z'),
      totalDuration: 10,
      recordCount: 100,
      dataGaps: 1,
      segments: [
        { start: new Date('2025-01-01T00:00:00Z'), end: new Date('2025-01-01T04:00:00Z'), recordCount: 50 },
        { start: new Date('2025-01-01T06:00:00Z'), end: new Date('2025-01-01T10:00:00Z'), recordCount: 50 }
      ]
    }
  ];

  it('should render chart container', () => {
    render(<DeviceTimelineChartECharts data={mockData} />);
    expect(screen.getByRole('img')).toBeInTheDocument(); // ECharts renders as img role
  });

  it('should display device names', () => {
    render(<DeviceTimelineChartECharts data={mockData} />);
    expect(screen.getByText('DEVICE-001')).toBeInTheDocument();
  });

  it('should apply dark variant styles', () => {
    const { container } = render(
      <DeviceTimelineChartECharts data={mockData} variant="dark" />
    );
    // Check that dark theme classes are applied
    expect(container.firstChild).toHaveStyle({ background: expect.any(String) });
  });

  it('should handle empty data gracefully', () => {
    render(<DeviceTimelineChartECharts data={[]} />);
    expect(screen.getByText(/no data/i)).toBeInTheDocument();
  });

  it('should render correct number of segments', () => {
    render(<DeviceTimelineChartECharts data={mockData} />);
    // Each segment should be rendered as a bar
    const segments = screen.getAllByTestId('timeline-segment');
    expect(segments).toHaveLength(2);
  });
});

5. Test Helpers & Utilities

5.1 Mock Data Generators

typescript
// src/test/helpers/generators.ts

export function generateMeasurements(options: {
  device: string;
  count: number;
  startTime?: string;
  intervalMinutes?: number;
}): MeasurementData[] {
  const { device, count, startTime = '2025-01-01T00:00:00Z', intervalMinutes = 15 } = options;
  const start = new Date(startTime);

  return Array.from({ length: count }, (_, i) => ({
    time: new Date(start.getTime() + i * intervalMinutes * 60000).toISOString(),
    loc: device,
    site: 'TEST-SITE',
    numberconcentrations0p5: Math.random() * 100,
    numberconcentrations1p0: Math.random() * 80,
    numberconcentrations2p5: Math.random() * 50,
    numberconcentrations4p0: Math.random() * 30,
    numberconcentrations10p0: Math.random() * 20,
    roadtemperature: 25 + Math.random() * 10,
    humidity: 40 + Math.random() * 30,
    road_corrugations: Math.random() * 5
  }));
}

export function generateDeviceTimelineData(options: {
  deviceCount: number;
  gapsPerDevice?: number;
}): DeviceTimelineDataPoint[] {
  const { deviceCount, gapsPerDevice = 0 } = options;

  return Array.from({ length: deviceCount }, (_, i) => ({
    device: `DEVICE-${String(i + 1).padStart(3, '0')}`,
    firstSeen: new Date('2025-01-01T00:00:00Z'),
    lastSeen: new Date('2025-01-01T23:59:59Z'),
    totalDuration: 24,
    recordCount: 100,
    dataGaps: gapsPerDevice,
    segments: Array.from({ length: gapsPerDevice + 1 }, (_, j) => ({
      start: new Date(`2025-01-01T${String(j * 6).padStart(2, '0')}:00:00Z`),
      end: new Date(`2025-01-01T${String((j + 1) * 6 - 1).padStart(2, '0')}:59:59Z`),
      recordCount: Math.floor(100 / (gapsPerDevice + 1))
    }))
  }));
}

5.2 Supabase Mock

typescript
// src/test/mocks/supabase.ts

export const mockSupabase = {
  from: vi.fn(() => ({
    select: vi.fn().mockReturnThis(),
    insert: vi.fn().mockReturnThis(),
    update: vi.fn().mockReturnThis(),
    delete: vi.fn().mockReturnThis(),
    eq: vi.fn().mockReturnThis(),
    order: vi.fn().mockReturnThis(),
    range: vi.fn().mockReturnThis(),
    single: vi.fn().mockReturnThis(),
    maybeSingle: vi.fn().mockReturnThis()
  })),
  auth: {
    getSession: vi.fn(),
    signInWithPassword: vi.fn(),
    signOut: vi.fn()
  },
  storage: {
    from: vi.fn(() => ({
      upload: vi.fn(),
      download: vi.fn(),
      createSignedUrl: vi.fn()
    }))
  }
};

vi.mock('@/lib/supabase', () => ({
  supabase: mockSupabase
}));

6. Test Configuration

6.1 Vitest Config (vitest.config.ts)

typescript
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test/setup.ts'],
    include: ['src/**/*.test.{ts,tsx}'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      exclude: [
        'node_modules/',
        'src/test/',
        '**/*.d.ts',
        '**/*.config.*'
      ],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 75,
        statements: 80
      }
    }
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src')
    }
  }
});

6.2 Test Setup (src/test/setup.ts)

typescript
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import { afterEach, vi } from 'vitest';

// Cleanup after each test
afterEach(() => {
  cleanup();
  vi.clearAllMocks();
});

// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: vi.fn().mockImplementation(query => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: vi.fn(),
    removeListener: vi.fn(),
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn()
  }))
});

// Mock ResizeObserver
global.ResizeObserver = vi.fn().mockImplementation(() => ({
  observe: vi.fn(),
  unobserve: vi.fn(),
  disconnect: vi.fn()
}));

7. Coverage Report

Run pnpm test:unit:coverage to generate coverage report.

Current Coverage Status:

ModuleLinesBranchesFunctions
reportDataService87%78%90%
csvParser92%85%95%
utils95%88%100%
useReports82%75%85%
Components71%62%75%

Related Documents: