diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2f5c986 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - run: npm ci + + - name: Type check + run: npx tsc --noEmit + + - name: Lint + run: npm run lint + + - name: Test + run: npm test + + - name: Build + run: npm run build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f06235c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/src/components/AboutPage.test.tsx b/src/components/AboutPage.test.tsx new file mode 100644 index 0000000..1f36a8c --- /dev/null +++ b/src/components/AboutPage.test.tsx @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { About } from '../pages/About' + +function renderAbout() { + return render() +} + +describe('AboutPage', () => { + it('renders the about heading', () => { + renderAbout() + expect(screen.getByRole('heading', { name: 'About mashx' })).toBeInTheDocument() + }) + + it('renders the privacy note', () => { + renderAbout() + expect( + screen.getByText(/no data leaves your machine/i), + ).toBeInTheDocument() + }) + + it('renders the available databases section', () => { + renderAbout() + expect( + screen.getByRole('heading', { name: 'Available Databases' }), + ).toBeInTheDocument() + }) + + it('renders the how it works section', () => { + renderAbout() + expect( + screen.getByRole('heading', { name: 'How it works' }), + ).toBeInTheDocument() + }) + + it('renders author information', () => { + renderAbout() + expect( + screen.getByRole('heading', { name: 'Nabil-Fareed Alikhan' }), + ).toBeInTheDocument() + }) +}) diff --git a/src/components/FileUpload.test.tsx b/src/components/FileUpload.test.tsx new file mode 100644 index 0000000..cc91881 --- /dev/null +++ b/src/components/FileUpload.test.tsx @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { FileUpload } from './FileUpload' + +describe('FileUpload', () => { + it('renders the upload prompt when no files selected', () => { + render( {}} disabled={false} />) + expect( + screen.getByText('Drop query genomes or click to browse'), + ).toBeInTheDocument() + expect(screen.getByText('.fasta, .fa, .fna, .fsa, .gz')).toBeInTheDocument() + }) + + it('renders file count and names when files are selected', () => { + const files = [ + new File([''], 'sample1.fasta'), + new File([''], 'sample2.fa'), + ] + render( {}} disabled={false} />) + expect(screen.getByText('2 file(s) selected')).toBeInTheDocument() + expect(screen.getByText('sample1.fasta')).toBeInTheDocument() + expect(screen.getByText('sample2.fa')).toBeInTheDocument() + }) + + it('has accessible file input', () => { + render( {}} disabled={false} />) + const inputs = screen.getAllByLabelText('Upload query genome files') + expect(inputs.length).toBeGreaterThan(0) + }) +}) diff --git a/src/components/LogConsole.test.tsx b/src/components/LogConsole.test.tsx new file mode 100644 index 0000000..343afd4 --- /dev/null +++ b/src/components/LogConsole.test.tsx @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { LogConsole } from '@genomicx/ui' + +describe('LogConsole', () => { + it('renders empty state when logs array is empty', () => { + render() + expect(screen.getByText(/no logs yet/i)).toBeInTheDocument() + }) + + it('renders log lines', () => { + const logs = ['[MashX] Starting...', '[MashX] Done.'] + render() + expect(screen.getByText(/Starting\.\.\./)).toBeInTheDocument() + expect(screen.getByText(/Done\./)).toBeInTheDocument() + }) + + it('renders a copy log button', () => { + render() + expect(screen.getByText(/copy/i)).toBeInTheDocument() + }) +}) diff --git a/src/components/OptionsPanel.test.tsx b/src/components/OptionsPanel.test.tsx new file mode 100644 index 0000000..1ac4e12 --- /dev/null +++ b/src/components/OptionsPanel.test.tsx @@ -0,0 +1,40 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { OptionsPanel } from './OptionsPanel' +import { DEFAULT_OPTIONS } from '../mashx/types' + +describe('OptionsPanel', () => { + it('renders all option fields', () => { + render( + {}} disabled={false} />, + ) + expect(screen.getByText('Top N hits')).toBeInTheDocument() + expect(screen.getByText('Sketch size (s)')).toBeInTheDocument() + expect(screen.getByText('k-mer size (k)')).toBeInTheDocument() + }) + + it('displays current option values', () => { + render( + {}} disabled={false} />, + ) + const inputs = screen.getAllByRole('spinbutton') + expect(inputs[0]).toHaveValue(20) + expect(inputs[1]).toHaveValue(1000) + expect(inputs[2]).toHaveValue(21) + }) + + it('calls onChange when topN is updated', () => { + const onChange = vi.fn() + render( + , + ) + + const topNInput = screen.getAllByRole('spinbutton')[0] + fireEvent.change(topNInput, { target: { value: '50' } }) + + expect(onChange).toHaveBeenCalledWith({ + ...DEFAULT_OPTIONS, + topN: 50, + }) + }) +}) diff --git a/src/components/ResultsTable.test.tsx b/src/components/ResultsTable.test.tsx new file mode 100644 index 0000000..f5f1aa3 --- /dev/null +++ b/src/components/ResultsTable.test.tsx @@ -0,0 +1,108 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent, within } from '@testing-library/react' +import { ResultsTable } from './ResultsTable' +import type { MashxResult } from '../mashx/types' + +vi.mock('../mashx/export', () => ({ + exportCsv: vi.fn(), +})) + +const mockResult: MashxResult = { + hits: [ + { + reference: 'GCF_000123.1', + query: 'sample.fasta', + distance: 0.001, + pValue: 1e-20, + sharedHashes: '990/1000', + organism: 'Escherichia coli', + taxId: '562', + }, + { + reference: 'GCF_000456.2', + query: 'sample.fasta', + distance: 0.1, + pValue: 3e-10, + sharedHashes: '800/1000', + organism: 'Staphylococcus aureus', + taxId: '1280', + }, + { + reference: 'GCF_000789.3', + query: 'sample.fasta', + distance: 0.2, + pValue: 5e-5, + sharedHashes: '500/1000', + organism: 'Salmonella enterica', + taxId: '28901', + }, + ], + queryFiles: ['sample.fasta'], + databaseName: 'Test DB', + ranAt: '2025-01-01T00:00:00.000Z', + topN: 20, +} + +describe('ResultsTable', () => { + it('renders the results heading', () => { + render() + expect(screen.getByRole('heading', { name: 'Results' })).toBeInTheDocument() + }) + + it('renders all hit rows', () => { + render() + const table = screen.getByRole('table') + const rows = within(table).getAllByRole('row') + // header + 3 data rows + expect(rows.length).toBe(4) + }) + + it('shows organism column when hits have organism data', () => { + render() + expect(screen.getByText('Escherichia coli')).toBeInTheDocument() + expect(screen.getByText('Staphylococcus aureus')).toBeInTheDocument() + }) + + it('renders TaxID links to NCBI', () => { + render() + const link = screen.getByText('562').closest('a') + expect(link).toHaveAttribute( + 'href', + 'https://www.ncbi.nlm.nih.gov/Taxonomy/Browser/wwwtax.cgi?id=562', + ) + }) + + it('renders Export CSV button', () => { + render() + expect(screen.getByRole('button', { name: 'Export CSV' })).toBeInTheDocument() + }) + + it('filters results by search text', () => { + render() + const filterInput = screen.getByPlaceholderText(/filter by name/i) + fireEvent.change(filterInput, { target: { value: 'Escherichia' } }) + + const table = screen.getByRole('table') + const rows = within(table).getAllByRole('row') + // header + 1 filtered row + expect(rows.length).toBe(2) + }) + + it('shows hit count', () => { + render() + expect(screen.getByText(/3 of 3 hits/)).toBeInTheDocument() + }) + + it('applies distance color classes', () => { + render() + // 0.001 should be dist-close + const closeBadge = screen.getByText('0.0010') + expect(closeBadge.className).toContain('dist-close') + // 0.1 should be dist-medium + const medBadge = screen.getByText('0.1000') + expect(medBadge.className).toContain('dist-medium') + // 0.2 should be dist-far + const farBadge = screen.getByText('0.2000') + expect(farBadge.className).toContain('dist-far') + }) +}) diff --git a/src/mashx/databases.test.ts b/src/mashx/databases.test.ts new file mode 100644 index 0000000..43e33a9 --- /dev/null +++ b/src/mashx/databases.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest' +import { + DATABASES, + CACHE_VERSION, + DEFAULT_TOP_N, + MASH_SKETCH_SIZE, + MASH_KMER_SIZE, +} from './databases' + +describe('databases', () => { + it('exports expected constants', () => { + expect(CACHE_VERSION).toBe('mashx-v1') + expect(DEFAULT_TOP_N).toBe(20) + expect(MASH_SKETCH_SIZE).toBe(1000) + expect(MASH_KMER_SIZE).toBe(21) + }) + + it('DATABASES is a non-empty array', () => { + expect(Array.isArray(DATABASES)).toBe(true) + expect(DATABASES.length).toBeGreaterThan(0) + }) + + it.each(DATABASES)('database "$name" has required fields', (db) => { + expect(db.id).toBeTruthy() + expect(db.name).toBeTruthy() + expect(db.description).toBeTruthy() + expect(db.url).toMatch(/^https?:\/\//) + expect(db.sizeBytes).toBeGreaterThan(0) + expect(db.version).toBeTruthy() + }) + + it('database IDs are unique', () => { + const ids = DATABASES.map((d) => d.id) + expect(new Set(ids).size).toBe(ids.length) + }) +}) diff --git a/src/mashx/export.test.ts b/src/mashx/export.test.ts new file mode 100644 index 0000000..aeea4fa --- /dev/null +++ b/src/mashx/export.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { MashxResult } from './types' + +vi.mock('file-saver', () => ({ + saveAs: vi.fn(), +})) + +import { exportCsv } from './export' +import { saveAs } from 'file-saver' + +const mockedSaveAs = saveAs as unknown as ReturnType + +beforeEach(() => { + mockedSaveAs.mockClear() +}) + +describe('exportCsv', () => { + it('generates CSV with correct headers and rows', () => { + const result: MashxResult = { + hits: [ + { + reference: 'GCF_000123.1', + query: 'sample.fasta', + distance: 0.001234, + pValue: 1.5e-20, + sharedHashes: '990/1000', + organism: 'E. coli', + taxId: '562', + }, + { + reference: 'GCF_000456.2', + query: 'sample.fasta', + distance: 0.05, + pValue: 3.2e-10, + sharedHashes: '800/1000', + }, + ], + queryFiles: ['sample.fasta'], + databaseName: 'Test DB', + ranAt: '2025-01-01T00:00:00.000Z', + topN: 20, + } + + exportCsv(result) + + expect(mockedSaveAs).toHaveBeenCalledOnce() + const [blob, filename] = mockedSaveAs.mock.calls[0] + + expect(blob).toBeInstanceOf(Blob) + expect(blob.type).toBe('text/csv;charset=utf-8;') + expect(filename).toMatch(/^mashx-results-\d{4}-\d{2}-\d{2}\.csv$/) + }) + + it('escapes double quotes in CSV cells', () => { + // Spy on Blob constructor to capture the CSV string + let capturedCsv = '' + const OrigBlob = globalThis.Blob + globalThis.Blob = class extends OrigBlob { + constructor(parts: BlobPart[], options?: BlobPropertyBag) { + super(parts, options) + capturedCsv = String(parts[0]) + } + } as typeof Blob + + const result: MashxResult = { + hits: [ + { + reference: 'ref with "quotes"', + query: 'q.fasta', + distance: 0, + pValue: 0, + sharedHashes: '1/1', + }, + ], + queryFiles: ['q.fasta'], + databaseName: 'Test', + ranAt: '2025-01-01T00:00:00.000Z', + topN: 1, + } + + exportCsv(result) + globalThis.Blob = OrigBlob + + expect(capturedCsv).toContain('""quotes""') + }) +}) diff --git a/src/mashx/meta.test.ts b/src/mashx/meta.test.ts new file mode 100644 index 0000000..aff5f12 --- /dev/null +++ b/src/mashx/meta.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { fetchMeta } from './meta' + +beforeEach(() => { + vi.restoreAllMocks() +}) + +describe('fetchMeta', () => { + it('parses a TSV with assembly, taxid, and name columns', async () => { + const tsv = [ + 'Assembly\tTaxID\tScientificName', + 'GCF_000123.1\t562\tEscherichia coli', + 'GCF_000456.2\t1280\tStaphylococcus aureus', + ].join('\n') + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(tsv, { status: 200 }), + ) + + const map = await fetchMeta('https://example.com/meta.tsv') + + // Full ID lookup + expect(map.get('GCF_000123.1')).toEqual({ + taxName: 'Escherichia coli', + taxId: '562', + organism: 'Escherichia coli', + }) + + // Prefix lookup + expect(map.get('GCF_000123')).toEqual({ + taxName: 'Escherichia coli', + taxId: '562', + organism: 'Escherichia coli', + }) + + // Second entry + expect(map.get('GCF_000456.2')?.organism).toBe('Staphylococcus aureus') + }) + + it('handles alternative column names (accession, organism)', async () => { + const tsv = [ + 'accession\ttax_id\torganism', + 'NC_001.1\t99\tSome organism', + ].join('\n') + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(tsv, { status: 200 }), + ) + + const map = await fetchMeta('https://example.com/alt.tsv') + expect(map.get('NC_001.1')?.taxId).toBe('99') + expect(map.get('NC_001.1')?.organism).toBe('Some organism') + }) + + it('returns empty map on fetch failure', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('', { status: 404 }), + ) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const map = await fetchMeta('https://example.com/missing.tsv') + expect(map.size).toBe(0) + }) + + it('skips comment and empty lines', async () => { + const tsv = [ + 'Assembly\tTaxID\tName', + '# This is a comment', + '', + 'GCF_001.1\t100\tFoo', + ].join('\n') + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(tsv, { status: 200 }), + ) + + const map = await fetchMeta('https://example.com/meta.tsv') + expect(map.size).toBe(2) // GCF_001.1 + prefix GCF_001 + expect(map.get('GCF_001.1')?.organism).toBe('Foo') + }) + + it('returns empty map for single-line TSV (header only)', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('Assembly\tTaxID\tName', { status: 200 }), + ) + + const map = await fetchMeta('https://example.com/empty.tsv') + expect(map.size).toBe(0) + }) +}) diff --git a/src/mashx/types.test.ts b/src/mashx/types.test.ts new file mode 100644 index 0000000..6544454 --- /dev/null +++ b/src/mashx/types.test.ts @@ -0,0 +1,12 @@ +import { describe, it, expect } from 'vitest' +import { DEFAULT_OPTIONS } from './types' + +describe('DEFAULT_OPTIONS', () => { + it('has correct default values', () => { + expect(DEFAULT_OPTIONS).toEqual({ + topN: 20, + sketchSize: 1000, + kmerSize: 21, + }) + }) +}) diff --git a/src/test-setup.ts b/src/test-setup.ts new file mode 100644 index 0000000..a36174e --- /dev/null +++ b/src/test-setup.ts @@ -0,0 +1,10 @@ +import '@testing-library/jest-dom/vitest' +import { cleanup } from '@testing-library/react' +import { afterEach } from 'vitest' + +afterEach(() => { + cleanup() +}) + +// jsdom does not implement scrollIntoView +Element.prototype.scrollIntoView = () => {} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..0654b16 --- /dev/null +++ b/vercel.json @@ -0,0 +1,5 @@ +{ + "buildCommand": "npm run build", + "outputDirectory": "dist", + "framework": "vite" +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..726f05b --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + setupFiles: ['./src/test-setup.ts'], + }, +})