Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules
dist
43 changes: 43 additions & 0 deletions src/components/AboutPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<MemoryRouter><About /></MemoryRouter>)
}

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()
})
})
30 changes: 30 additions & 0 deletions src/components/FileUpload.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<FileUpload files={[]} onFilesChange={() => {}} 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(<FileUpload files={files} onFilesChange={() => {}} 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(<FileUpload files={[]} onFilesChange={() => {}} disabled={false} />)
const inputs = screen.getAllByLabelText('Upload query genome files')
expect(inputs.length).toBeGreaterThan(0)
})
})
22 changes: 22 additions & 0 deletions src/components/LogConsole.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<LogConsole logs={[]} />)
expect(screen.getByText(/no logs yet/i)).toBeInTheDocument()
})

it('renders log lines', () => {
const logs = ['[MashX] Starting...', '[MashX] Done.']
render(<LogConsole logs={logs} />)
expect(screen.getByText(/Starting\.\.\./)).toBeInTheDocument()
expect(screen.getByText(/Done\./)).toBeInTheDocument()
})

it('renders a copy log button', () => {
render(<LogConsole logs={['test']} />)
expect(screen.getByText(/copy/i)).toBeInTheDocument()
})
})
40 changes: 40 additions & 0 deletions src/components/OptionsPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<OptionsPanel options={DEFAULT_OPTIONS} onChange={() => {}} 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(
<OptionsPanel options={DEFAULT_OPTIONS} onChange={() => {}} 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(
<OptionsPanel options={DEFAULT_OPTIONS} onChange={onChange} disabled={false} />,
)

const topNInput = screen.getAllByRole('spinbutton')[0]
fireEvent.change(topNInput, { target: { value: '50' } })

expect(onChange).toHaveBeenCalledWith({
...DEFAULT_OPTIONS,
topN: 50,
})
})
})
108 changes: 108 additions & 0 deletions src/components/ResultsTable.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ResultsTable result={mockResult} />)
expect(screen.getByRole('heading', { name: 'Results' })).toBeInTheDocument()
})

it('renders all hit rows', () => {
render(<ResultsTable result={mockResult} />)
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(<ResultsTable result={mockResult} />)
expect(screen.getByText('Escherichia coli')).toBeInTheDocument()
expect(screen.getByText('Staphylococcus aureus')).toBeInTheDocument()
})

it('renders TaxID links to NCBI', () => {
render(<ResultsTable result={mockResult} />)
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(<ResultsTable result={mockResult} />)
expect(screen.getByRole('button', { name: 'Export CSV' })).toBeInTheDocument()
})

it('filters results by search text', () => {
render(<ResultsTable result={mockResult} />)
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(<ResultsTable result={mockResult} />)
expect(screen.getByText(/3 of 3 hits/)).toBeInTheDocument()
})

it('applies distance color classes', () => {
render(<ResultsTable result={mockResult} />)
// 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')
})
})
36 changes: 36 additions & 0 deletions src/mashx/databases.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading