@opencloning/opencloningdb 1.9.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,258 @@
1
+ import React from 'react';
2
+ import { ConfigProvider } from '@opencloning/ui/providers/ConfigProvider';
3
+ import { convertToTeselaJson } from '@opencloning/utils/readNwrite';
4
+ import SequencingCheckModal from './SequencingCheckModal';
5
+ import { formatSequenceName } from '../common';
6
+
7
+ const BACKEND_URL = 'http://localhost:8000';
8
+ const testConfig = { backendUrl: BACKEND_URL, showAppBar: false, noExternalRequests: true, enableAssembler: false, enablePlannotate: false };
9
+ const ALIGN_DELAY = 500;
10
+
11
+ // Short reads, aligned with a stubbed response, the value is the expected identity
12
+ const READS = {
13
+ AAAAAAAAAA: { alignment: ['AAAAAAAAAA', 'AAAAAAAAAA'], identity: '100.00' },
14
+ CCCCCCCCCC: { alignment: ['CCCCCCCCCC', 'CCCCCCCCC-'], identity: '90.00' },
15
+ GGGGGGGGGG: { alignment: ['GGGGGGGGGG', 'GGGGGGGGGA'], identity: '90.00' },
16
+ TTTTTTTTTT: { alignment: ['TTTTTTTTTTTTTTTTTTTT', 'TTTTTTTTTTTTTTTTT---'], identity: '85.00' },
17
+ ACGTACGTAC: { alignment: ['ACGTACGTAC', '--GTACGTAC'], identity: '80.00' },
18
+ };
19
+
20
+ /** Pick the sequences with the gibson_assembly tag in the bulk component. */
21
+ function loadSequencesByTag() {
22
+ cy.wait('@getTags');
23
+ // Like clickMultiSelectOption, but the menu is closed with Escape (the page body has no size behind the modal)
24
+ cy.get('[data-testid="sequencing-check-modal"]').contains('label', 'Tags').siblings('div').first().click();
25
+ cy.get('div[role="presentation"]').contains('gibson_assembly').click();
26
+ cy.focused().type('{esc}');
27
+ cy.contains('button', 'Load sequences').click();
28
+ cy.wait('@getSequences');
29
+ }
30
+
31
+ describe('<SequencingCheckModal />', () => {
32
+ beforeEach(() => {
33
+ cy.setupOpenCloningDBTestAuth();
34
+ cy.interceptOpenCloningDBStub('get_tags', { alias: 'getTags' });
35
+ cy.interceptOpenCloningDBStub('get_text_file_sequences_by_tag', { alias: 'getSequences' });
36
+ });
37
+
38
+ it('checks the sequencing files of the sequences and sorts them by identity', () => {
39
+ cy.viewport(1400, 900);
40
+ cy.getStub('get_text_file_sequences_by_tag').then((stub) => {
41
+ // pREP42-MCS+ (28, circular), NC_003424 (29), ase1_CDS (30), digested_vector (31), pREP42-ase1 (32)
42
+ const [plasmid] = stub.response.body;
43
+ const plasmidSeguid = plasmid.sequence_ref.seguid;
44
+ const plasmidBases = convertToTeselaJson(plasmid.sequence).sequence.toUpperCase();
45
+ // Same length as the plasmid, one base changed: the seguid does not match, so it is aligned
46
+ const mutantBases = (plasmidBases[0] === 'A' ? 'C' : 'A') + plasmidBases.slice(1);
47
+
48
+ const files = {
49
+ 1: { name: 'exact.fa', sequence: plasmidBases },
50
+ 2: { name: 'mutant.fa', sequence: mutantBases },
51
+ 3: { name: 'ase1_read.fa', sequence: 'ACGTACGTAC' },
52
+ 4: { name: 'read_a.fa', sequence: 'AAAAAAAAAA' },
53
+ 5: { name: 'read_c.fa', sequence: 'CCCCCCCCCC' },
54
+ 6: { name: 'read_g.fa', sequence: 'GGGGGGGGGG' },
55
+ 7: { name: 'read_t.fa', sequence: 'TTTTTTTTTT' },
56
+ 8: { name: 'broken.fa', sequence: 'ACGTTGCAAC' },
57
+ };
58
+ const filesBySequenceId = { 28: [1, 2], 29: [], 30: [3], 31: [4, 5, 6, 7], 32: [8] };
59
+
60
+ cy.intercept('GET', Cypress.getDbURL('/sequences/*/sequencing_files'), (req) => {
61
+ const sequenceId = Number(req.url.split('/').at(-2));
62
+ req.reply(filesBySequenceId[sequenceId].map((id) => ({ id, original_name: files[id].name }))); // eslint-disable-line camelcase
63
+ }).as('getSequencingFiles');
64
+ cy.intercept('GET', Cypress.getDbURL('/sequencing_files/*/download'), (req) => {
65
+ const file = files[Number(req.url.split('/').at(-2))];
66
+ req.reply({ body: `>${file.name}\n${file.sequence}\n`, headers: { 'content-type': 'application/octet-stream' } });
67
+ }).as('downloadFile');
68
+
69
+ const seguidRequests = [];
70
+ cy.intercept('POST', `${BACKEND_URL}/seguid`, (req) => {
71
+ seguidRequests.push(req.body);
72
+ req.reply(JSON.stringify(req.body.sequence.toUpperCase() === plasmidBases ? plasmidSeguid : 'cdseguid=something_else'));
73
+ }).as('seguid');
74
+
75
+ const alignStarts = [];
76
+ const alignedTraces = [];
77
+ cy.intercept('POST', `${BACKEND_URL}/align_sanger`, (req) => {
78
+ alignStarts.push(Date.now());
79
+ const trace = req.body.traces[0].toUpperCase();
80
+ alignedTraces.push(trace);
81
+ if (trace === 'ACGTTGCAAC') {
82
+ req.reply({ statusCode: 400, body: { detail: 'Alignment failed' }, delay: ALIGN_DELAY });
83
+ } else if (trace === mutantBases) {
84
+ req.reply({ body: ['ACGT', 'ACGA'], delay: ALIGN_DELAY });
85
+ } else if (trace === plasmidBases) {
86
+ // Only aligned when the exact match is viewed
87
+ req.reply({ body: [plasmidBases, plasmidBases], delay: ALIGN_DELAY });
88
+ } else {
89
+ req.reply({ body: READS[trace].alignment, delay: ALIGN_DELAY });
90
+ }
91
+ }).as('alignSanger');
92
+
93
+ cy.mount(
94
+ <ConfigProvider config={testConfig}>
95
+ <SequencingCheckModal open onClose={() => {}} />
96
+ </ConfigProvider>,
97
+ );
98
+
99
+ // Pick the sequences with the bulk component
100
+ loadSequencesByTag();
101
+
102
+ // Before the check: sequences in the original order, the one without files last
103
+ cy.get('[data-testid="sequencing-check-sequence-cell"]').should('have.length', 5);
104
+ cy.get('[data-testid="sequencing-check-file-row"]').should('have.length', 8);
105
+ cy.get('[data-testid="sequencing-check-empty-row"]').should('have.length', 1).and('contain', 'NC_003424').and('contain', 'No sequencing data');
106
+ cy.get('[data-testid="sequencing-check-sequence-cell"]').last().should('contain', 'NC_003424');
107
+ // The sequence cell spans the rows of its files
108
+ cy.get('[data-testid="sequencing-check-sequence-cell"]').first().should('contain', 'pREP42-MCS+').and('have.attr', 'rowspan', '2');
109
+ cy.get('@seguid.all').should('have.length', 0);
110
+ cy.get('@alignSanger.all').should('have.length', 0);
111
+
112
+ cy.get('[data-testid="sequencing-check-run-button"]').click();
113
+ cy.get('[data-testid="status-icon-pending"]').should('exist');
114
+ cy.get('[data-testid="sequencing-check-progress"]', { timeout: 10000 }).should('contain', '8 / 8');
115
+ cy.get('[data-testid="sequencing-check-run-button"]').should('not.be.disabled');
116
+
117
+ // Sorted: error first, then lowest identity first, no sequencing data last
118
+ const namesById = Object.fromEntries(stub.response.body.map(({ sequence_ref: ref }) => [ref.id, formatSequenceName(ref)]));
119
+ const expectedOrder = [32, 28, 30, 31, 29].map((id) => namesById[id]);
120
+ cy.get('[data-testid="sequencing-check-sequence-cell"]').should(($cells) => {
121
+ const cells = $cells.toArray();
122
+ expect(cells.map((cell) => cell.querySelector('[data-testid="sequencing-check-sequence-name"]').innerText)).to.deep.equal(expectedOrder);
123
+ expect(cells.map((cell) => cell.rowSpan)).to.deep.equal([1, 2, 1, 4, 1]);
124
+ });
125
+
126
+ // Files sorted within each sequence, the exact match was found by seguid
127
+ const expectedFiles = [
128
+ ['broken.fa', ''],
129
+ ['mutant.fa', '75.00'],
130
+ ['exact.fa', '100.00'],
131
+ ['ase1_read.fa', '80.00'],
132
+ ['read_t.fa', '85.00'],
133
+ ['read_c.fa', '90.00'],
134
+ ['read_g.fa', '90.00'],
135
+ ['read_a.fa', '100.00'],
136
+ ];
137
+ cy.get('[data-testid="sequencing-check-file-row"]').should(($rows) => {
138
+ const actual = $rows.toArray().map((row) => [
139
+ row.querySelector('[data-testid="sequencing-check-file-name"]').innerText.trim(),
140
+ row.querySelector('[data-testid="sequencing-check-identity"]').innerText.trim(),
141
+ ]);
142
+ expect(actual).to.deep.equal(expectedFiles);
143
+ });
144
+ cy.get('[data-testid="sequencing-check-file-row"]').first().should('contain', 'Alignment failed');
145
+ cy.get('[data-testid="sequencing-check-file-row"]').eq(1).should('contain', 'Identity below 100%');
146
+
147
+ cy.then(() => {
148
+ // Only files with the same length as the reference get their seguid computed, with its topology
149
+ expect(seguidRequests).to.have.length(2);
150
+ seguidRequests.forEach((body) => expect(body.circular).to.equal(true));
151
+ // The exact match is not aligned
152
+ expect(alignedTraces).to.have.length(7);
153
+ expect(alignedTraces).not.to.include(plasmidBases);
154
+ // At most 4 checks at the same time: any 5 alignment requests span at least one alignment delay
155
+ const starts = [...alignStarts].sort((a, b) => a - b);
156
+ for (let i = 0; i + 4 < starts.length; i++) {
157
+ expect(starts[i + 4] - starts[i]).to.be.at.least(ALIGN_DELAY * 0.8);
158
+ }
159
+ });
160
+
161
+ // The alignments and the files of the check are reused by the alignment viewer
162
+ cy.get('@downloadFile.all').should('have.length', 8);
163
+ cy.get('[data-testid="sequencing-check-file-row"]').contains('tr', 'read_c.fa').find('[aria-label="View alignment"]').click();
164
+ cy.get('[data-testid="sequencing-alignment-viewer"] .veTabActive.veTabAlignments').should('be.visible');
165
+ cy.get('@alignSanger.all').should('have.length', 7);
166
+ // The exact match was not aligned during the check, so it is aligned now, but not downloaded again
167
+ cy.get('[data-testid="sequencing-check-file-row"]').contains('tr', 'exact.fa').find('[aria-label="View alignment"]').click();
168
+ cy.get('[data-testid="sequencing-alignment-viewer"] .veTabActive.veTabAlignments').should('be.visible');
169
+ cy.get('[data-testid="sequencing-alignment-viewer"]').should('contain', 'exact.fa');
170
+ cy.get('@alignSanger.all').should((calls) => {
171
+ expect(calls).to.have.length(8);
172
+ expect(calls.at(-1).request.body.traces).to.deep.equal([plasmidBases]);
173
+ });
174
+ cy.get('@downloadFile.all').should('have.length', 8);
175
+ cy.get('[aria-label="Close alignment"]').click();
176
+
177
+ // Checking again only recomputes the seguids, and retries the alignment that failed (errors are not cached)
178
+ cy.get('[data-testid="sequencing-check-run-button"]').click();
179
+ cy.get('[data-testid="sequencing-check-run-button"]').should('not.be.disabled');
180
+ cy.get('[data-testid="sequencing-check-progress"]').should('contain', '8 / 8');
181
+ cy.get('[data-testid="sequencing-check-file-row"]').contains('tr', 'mutant.fa').should('contain', '75.00');
182
+ cy.get('[data-testid="sequencing-check-file-row"]').contains('tr', 'broken.fa').should('contain', 'Alignment failed');
183
+ cy.get('@alignSanger.all').should((calls) => {
184
+ expect(calls).to.have.length(9);
185
+ expect(calls.at(-1).request.body.traces).to.deep.equal(['ACGTTGCAAC']);
186
+ });
187
+ cy.get('@downloadFile.all').should('have.length', 8);
188
+ cy.get('@seguid.all').should('have.length', 4);
189
+
190
+ // Pick other sequences goes back to the bulk component
191
+ cy.contains('button', 'Pick other sequences').click();
192
+ cy.contains('button', 'Load sequences').should('exist');
193
+ });
194
+ });
195
+
196
+ it('shows the alignment of a sequencing file below the table', () => {
197
+ cy.viewport(1400, 900);
198
+ cy.getStub('get_text_file_sequences_by_tag').then((stub) => {
199
+ const [plasmid] = stub.response.body;
200
+ const plasmidBases = convertToTeselaJson(plasmid.sequence).sequence.toUpperCase();
201
+ const mutantBases = (plasmidBases[0] === 'A' ? 'C' : 'A') + plasmidBases.slice(1);
202
+ const files = {
203
+ 1: { name: 'mutant.fa', sequence: mutantBases },
204
+ 2: { name: 'other.fa', sequence: plasmidBases },
205
+ };
206
+
207
+ cy.intercept('GET', Cypress.getDbURL('/sequences/*/sequencing_files'), (req) => {
208
+ const sequenceId = Number(req.url.split('/').at(-2));
209
+ req.reply(sequenceId === plasmid.sequence_ref.id ? [1, 2].map((id) => ({ id, original_name: files[id].name })) : []); // eslint-disable-line camelcase
210
+ });
211
+ cy.intercept('GET', Cypress.getDbURL('/sequencing_files/*/download'), (req) => {
212
+ const file = files[Number(req.url.split('/').at(-2))];
213
+ req.reply({ body: `>${file.name}\n${file.sequence}\n`, headers: { 'content-type': 'application/octet-stream' } });
214
+ });
215
+ cy.intercept('POST', `${BACKEND_URL}/align_sanger`, (req) => {
216
+ req.reply({ body: [plasmidBases, req.body.traces[0].toUpperCase()], delay: 200 });
217
+ }).as('alignSanger');
218
+
219
+ cy.mount(
220
+ <ConfigProvider config={testConfig}>
221
+ <SequencingCheckModal open onClose={() => {}} />
222
+ </ConfigProvider>,
223
+ );
224
+ loadSequencesByTag();
225
+
226
+ // No alignment shown by default
227
+ cy.get('[data-testid="sequencing-check-file-row"]').should('have.length', 2);
228
+ cy.get('[data-testid="sequencing-alignment-viewer"]').should('not.exist');
229
+
230
+ // The eye button aligns the file to its sequence (without running the check) and shows the alignment tab
231
+ cy.get('[data-testid="sequencing-check-file-row"]').contains('tr', 'mutant.fa').find('[aria-label="View alignment"]').click();
232
+ cy.get('[data-testid="sequencing-alignment-viewer"]').find('.MuiCircularProgress-root').should('exist');
233
+ cy.wait('@alignSanger').its('request.body').should((body) => {
234
+ expect(body.sequence.id).to.equal(plasmid.sequence.id);
235
+ expect(body.traces).to.deep.equal([mutantBases]);
236
+ });
237
+ cy.get('[data-testid="sequencing-alignment-viewer"]').should('contain', 'mutant.fa');
238
+ cy.get('[data-testid="sequencing-alignment-viewer"] .veTabActive.veTabAlignments').should('be.visible');
239
+ cy.get('[data-testid="sequencing-alignment-viewer"] .veTabActive').should('have.length', 1);
240
+ cy.get('[data-testid="sequencing-check-file-row"]').contains('tr', 'mutant.fa').should('have.class', 'Mui-selected');
241
+
242
+ // Another file replaces the alignment
243
+ cy.get('[data-testid="sequencing-check-file-row"]').contains('tr', 'other.fa').find('[aria-label="View alignment"]').click();
244
+ cy.wait('@alignSanger').its('request.body.traces').should('deep.equal', [plasmidBases]);
245
+ cy.get('[data-testid="sequencing-alignment-viewer"]').should('contain', 'other.fa').and('not.contain', 'mutant.fa');
246
+ cy.get('[data-testid="sequencing-check-file-row"]').contains('tr', 'mutant.fa').should('not.have.class', 'Mui-selected');
247
+
248
+ // Hide it with the eye button, show it again from the cache, and close it
249
+ cy.get('[data-testid="sequencing-check-file-row"]').contains('tr', 'other.fa').find('[aria-label="Hide alignment"]').click();
250
+ cy.get('[data-testid="sequencing-alignment-viewer"]').should('not.exist');
251
+ cy.get('[data-testid="sequencing-check-file-row"]').contains('tr', 'mutant.fa').find('[aria-label="View alignment"]').click();
252
+ cy.get('[data-testid="sequencing-alignment-viewer"] .veTabActive.veTabAlignments').should('be.visible');
253
+ cy.get('@alignSanger.all').should('have.length', 2);
254
+ cy.get('[aria-label="Close alignment"]').click();
255
+ cy.get('[data-testid="sequencing-alignment-viewer"]').should('not.exist');
256
+ });
257
+ });
258
+ });
@@ -0,0 +1,90 @@
1
+ import React from 'react';
2
+ import { Alert, Box, Button, CircularProgress, Typography } from '@mui/material';
3
+ import error2String from '@opencloning/utils/error2String';
4
+ import BulkUploadModal from '../bulk_upload/BulkUploadModal';
5
+ import { BulkTableContainer } from '../bulk_upload/common';
6
+ import GetSequencesInBulkComponent from '../GetSequencesInBulkComponent';
7
+ import SequencingCheckTable from './SequencingCheckTable';
8
+ import SequencingAlignmentViewer from './SequencingAlignmentViewer';
9
+ import useSequencingCheck from './useSequencingCheck';
10
+
11
+ function SequencingCheckResults({ sequences, onPickOtherSequences, renderSequenceName }) {
12
+ const { rows, filesLoading, filesError, run, isRunning, progress } = useSequencingCheck(sequences);
13
+ // { sequence, file } of the alignment shown below the table, null if none
14
+ const [selectedAlignment, setSelectedAlignment] = React.useState(null);
15
+ const onViewAlignment = (sequence, file) => setSelectedAlignment(sequence ? { sequence, file } : null);
16
+
17
+ return (
18
+ <>
19
+ <Typography variant="body2" sx={{ mb: 2 }}>
20
+ Each sequencing file is aligned to its sequence (exact matches are detected without aligning).
21
+ Sequences are sorted by percentage identity, lowest first.
22
+ </Typography>
23
+ {filesError && <Alert severity="error" sx={{ mb: 2 }}>Could not load the sequencing files: {error2String(filesError)}</Alert>}
24
+ {/* Scrolls when the alignment is shown, so that the buttons stay visible */}
25
+ <Box sx={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
26
+ <BulkTableContainer>
27
+ <SequencingCheckTable
28
+ rows={rows}
29
+ renderSequenceName={renderSequenceName}
30
+ selectedFileId={selectedAlignment?.file.id ?? null}
31
+ onViewAlignment={onViewAlignment}
32
+ />
33
+ </BulkTableContainer>
34
+ {selectedAlignment && (
35
+ <SequencingAlignmentViewer
36
+ key={selectedAlignment.file.id}
37
+ sequence={selectedAlignment.sequence}
38
+ file={selectedAlignment.file}
39
+ onClose={() => setSelectedAlignment(null)}
40
+ />
41
+ )}
42
+ </Box>
43
+ <Box sx={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 1 }}>
44
+ {progress.total > 0 && (
45
+ <Typography variant="body2" sx={{ mr: 'auto' }} data-testid="sequencing-check-progress">
46
+ {progress.done} / {progress.total} sequencing files checked
47
+ </Typography>
48
+ )}
49
+ <Button variant="outlined" onClick={onPickOtherSequences} disabled={isRunning}>
50
+ Pick other sequences
51
+ </Button>
52
+ <Button
53
+ variant="contained"
54
+ onClick={() => run()}
55
+ disabled={isRunning || filesLoading || progress.total === 0}
56
+ data-testid="sequencing-check-run-button"
57
+ >
58
+ {isRunning ? <CircularProgress size={24} /> : 'Check sequencing files'}
59
+ </Button>
60
+ </Box>
61
+ </>
62
+ );
63
+ }
64
+
65
+ function SequencingCheckContent({ renderSequenceName }) {
66
+ const [sequences, setSequences] = React.useState(null);
67
+ if (sequences === null) {
68
+ return <GetSequencesInBulkComponent onSequencesLoaded={setSequences} />;
69
+ }
70
+ return (
71
+ <SequencingCheckResults
72
+ sequences={sequences}
73
+ onPickOtherSequences={() => setSequences(null)}
74
+ renderSequenceName={renderSequenceName}
75
+ />
76
+ );
77
+ }
78
+
79
+ /**
80
+ * Pick sequences in bulk and check them against their sequencing files (percentage identity).
81
+ * renderSequenceName(sequence) can be passed to render the sequence names (e.g. as links), sequence is
82
+ * an item of the output of GetSequencesInBulkComponent ({ databaseId, name, ... }).
83
+ */
84
+ export default function SequencingCheckModal({ open, onClose, renderSequenceName }) {
85
+ return (
86
+ <BulkUploadModal open={open} onClose={onClose} title="Check sequencing files" dataTestId="sequencing-check-modal">
87
+ <SequencingCheckContent renderSequenceName={renderSequenceName} />
88
+ </BulkUploadModal>
89
+ );
90
+ }
@@ -0,0 +1,112 @@
1
+ import React from 'react';
2
+ import { IconButton, Table, TableBody, TableCell, TableHead, TableRow, Tooltip } from '@mui/material';
3
+ import { Visibility as VisibilityIcon, VisibilityOff as VisibilityOffIcon } from '@mui/icons-material';
4
+ import { StatusIcon } from '../bulk_upload/common';
5
+ import { formatIdentity } from './sequencingCheckUtils';
6
+
7
+ function identityText(result) {
8
+ return result?.identity === undefined ? '' : formatIdentity(result.identity);
9
+ }
10
+
11
+ function fileIssues(result, status) {
12
+ if (status === 'error') {
13
+ return [result.message || 'Check failed'];
14
+ }
15
+ if (status === 'warning') {
16
+ return ['Identity below 100%'];
17
+ }
18
+ return [];
19
+ }
20
+
21
+ const defaultRenderSequenceName = (sequence) => sequence.name;
22
+
23
+ function ViewAlignmentButton({ selected, onClick }) {
24
+ const title = selected ? 'Hide alignment' : 'View alignment';
25
+ return (
26
+ <Tooltip title={title} placement="left">
27
+ <IconButton size="small" onClick={onClick} aria-label={title} color={selected ? 'primary' : 'default'} data-testid="sequencing-check-view-alignment">
28
+ {selected ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
29
+ </IconButton>
30
+ </Tooltip>
31
+ );
32
+ }
33
+
34
+ function IssuesCell({ status, issues }) {
35
+ return (
36
+ <TableCell>
37
+ {status !== 'clear' && issues.map((issue) => <div key={issue}>{issue}</div>)}
38
+ </TableCell>
39
+ );
40
+ }
41
+
42
+ /**
43
+ * Rows from useSequencingCheck: one row per sequencing file, with the sequence cell spanning the rows of its files
44
+ * (a single row with no file if the sequence has none). Each file has an eye button that calls
45
+ * onViewAlignment(sequence, file), or onViewAlignment(null) if that file is the selected one (selectedFileId).
46
+ */
47
+ export default function SequencingCheckTable({ rows, renderSequenceName = defaultRenderSequenceName, selectedFileId = null, onViewAlignment }) {
48
+ return (
49
+ <Table stickyHeader size="small" sx={{ tableLayout: 'fixed', width: '100%' }} data-testid="sequencing-check-table">
50
+ <colgroup>
51
+ <col style={{ width: '30%' }} />
52
+ <col style={{ width: '48px' }} />
53
+ <col style={{ width: '35%' }} />
54
+ <col style={{ width: '10%' }} />
55
+ <col style={{ width: '56px' }} />
56
+ <col style={{ width: '25%' }} />
57
+ </colgroup>
58
+ <TableHead>
59
+ <TableRow>
60
+ <TableCell>Sequence</TableCell>
61
+ <TableCell padding="checkbox"></TableCell>
62
+ <TableCell>Sequencing file</TableCell>
63
+ <TableCell>% identity</TableCell>
64
+ <TableCell></TableCell>
65
+ <TableCell>Notes</TableCell>
66
+ </TableRow>
67
+ </TableHead>
68
+ <TableBody>
69
+ {rows.map((row) => {
70
+ const sequenceCell = (
71
+ <TableCell
72
+ rowSpan={Math.max(row.files.length, 1)}
73
+ // Not highlighted with the selected file row
74
+ sx={{ fontWeight: 'bold', verticalAlign: 'top', bgcolor: 'background.paper', overflowWrap: 'anywhere' }}
75
+ data-testid="sequencing-check-sequence-cell"
76
+ >
77
+ <span data-testid="sequencing-check-sequence-name">{renderSequenceName(row.sequence)}</span>
78
+ </TableCell>
79
+ );
80
+ if (row.files.length === 0) {
81
+ // Files still loading, or no sequencing files
82
+ return (
83
+ <TableRow key={row.sequence.databaseId} data-testid="sequencing-check-empty-row">
84
+ {sequenceCell}
85
+ <TableCell align="center" padding="checkbox"><StatusIcon status={row.status} /></TableCell>
86
+ <TableCell colSpan={3} />
87
+ <IssuesCell status={row.status} issues={row.filesLoaded ? ['No sequencing data'] : []} />
88
+ </TableRow>
89
+ );
90
+ }
91
+ return row.files.map(({ file, result, status }, i) => {
92
+ const selected = file.id === selectedFileId;
93
+ return (
94
+ <TableRow key={file.id} selected={selected} data-testid="sequencing-check-file-row">
95
+ {i === 0 && sequenceCell}
96
+ <TableCell align="center" padding="checkbox"><StatusIcon status={status} /></TableCell>
97
+ <TableCell sx={{ fontFamily: 'monospace', overflowWrap: 'anywhere' }} data-testid="sequencing-check-file-name">{file.name}</TableCell>
98
+ <TableCell data-testid="sequencing-check-identity">{identityText(result)}</TableCell>
99
+ <TableCell padding="none" align="center">
100
+ {onViewAlignment && (
101
+ <ViewAlignmentButton selected={selected} onClick={() => (selected ? onViewAlignment(null) : onViewAlignment(row.sequence, file))} />
102
+ )}
103
+ </TableCell>
104
+ <IssuesCell status={status} issues={fileIssues(result, status)} />
105
+ </TableRow>
106
+ );
107
+ });
108
+ })}
109
+ </TableBody>
110
+ </Table>
111
+ );
112
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Helpers for the bulk sequencing check (sequences vs. their sequencing files).
3
+ *
4
+ * A file result is one of:
5
+ * { status: 'pending' } - check in progress
6
+ * { status: 'match', identity: 100 } - query seguid equals the reference seguid (no alignment needed)
7
+ * { status: 'aligned', identity: <number> } - identity computed from the alignment
8
+ * { status: 'error', message: <string> } - the check failed
9
+ * Files that have not been checked yet have no result (undefined).
10
+ */
11
+
12
+ /** Number of files checked at the same time. */
13
+ export const MAX_CONCURRENT_CHECKS = 4;
14
+
15
+ /**
16
+ * Percentage identity of a pairwise alignment (gapped strings of the same length).
17
+ * Columns where both strings have a gap are ignored, any other gap counts as a mismatch.
18
+ */
19
+ export function percentIdentity(refAligned, queryAligned) {
20
+ if (refAligned.length !== queryAligned.length) {
21
+ throw new Error('Aligned sequences must have the same length');
22
+ }
23
+ const ref = refAligned.toUpperCase();
24
+ const query = queryAligned.toUpperCase();
25
+ let columns = 0;
26
+ let matches = 0;
27
+ for (let i = 0; i < ref.length; i++) {
28
+ if (ref[i] === '-' && query[i] === '-') {
29
+ continue;
30
+ }
31
+ columns++;
32
+ if (ref[i] === query[i]) {
33
+ matches++;
34
+ }
35
+ }
36
+ return columns === 0 ? 0 : (100 * matches) / columns;
37
+ }
38
+
39
+ /** Format an identity with 2 decimals, rounding down so that anything below 100 never shows as 100. */
40
+ export function formatIdentity(identity) {
41
+ return (Math.floor(identity * 100) / 100).toFixed(2);
42
+ }
43
+
44
+ /**
45
+ * Call worker(item) for every item, with at most `limit` calls running at the same time.
46
+ * Resolves with the results in the order of items. A rejected worker rejects the whole call,
47
+ * so workers should handle their own errors.
48
+ */
49
+ export async function runWithConcurrency(items, limit, worker) {
50
+ const results = new Array(items.length);
51
+ let next = 0;
52
+ const runNext = async () => {
53
+ while (next < items.length) {
54
+ const index = next++;
55
+ results[index] = await worker(items[index], index);
56
+ }
57
+ };
58
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, runNext));
59
+ return results;
60
+ }
61
+
62
+ /** Status of a file for the StatusIcon in bulk_upload/common.jsx (null if not checked yet). */
63
+ export function fileRowStatus(result) {
64
+ if (!result) {
65
+ return null;
66
+ }
67
+ if (result.status === 'pending' || result.status === 'error') {
68
+ return result.status;
69
+ }
70
+ return result.identity === 100 ? 'clear' : 'warning';
71
+ }
72
+
73
+ const STATUS_SEVERITY = ['error', 'warning', 'pending', null, 'clear'];
74
+
75
+ /** Status of a sequence row: 'info' if it has no sequencing files, otherwise the worst status of its files. */
76
+ export function sequenceRowStatus(files, results) {
77
+ if (files.length === 0) {
78
+ return 'info';
79
+ }
80
+ const statuses = files.map((file) => fileRowStatus(results[file.id]));
81
+ return STATUS_SEVERITY.find((status) => statuses.includes(status));
82
+ }
83
+
84
+ /**
85
+ * Sort key of a file, compared lexicographically: errors first, then identities (lowest first),
86
+ * then files that are pending or not checked yet.
87
+ */
88
+ function fileSortKey(result) {
89
+ if (result?.status === 'error') {
90
+ return [0, 0];
91
+ }
92
+ if (result?.identity !== undefined) {
93
+ return [1, result.identity];
94
+ }
95
+ return [2, 0];
96
+ }
97
+
98
+ const NO_FILES_SORT_KEY = [3, 0];
99
+
100
+ function compareKeys(a, b) {
101
+ return a[0] - b[0] || a[1] - b[1];
102
+ }
103
+
104
+ /**
105
+ * Build the table rows from the sequences, their sequencing files and the results, sorted by
106
+ * percentage identity (lowest first), both the sequences and the files within each sequence.
107
+ * Sequences without sequencing files go last. The sort is stable, so ties keep the original order.
108
+ *
109
+ * @param {Array} sequences - [{ databaseId, name, ... }]
110
+ * @param {Object} filesBySequenceId - { [databaseId]: [{ id, name, ... }] | undefined (loading) }
111
+ * @param {Object} results - { [fileId]: result }
112
+ * @returns {Array} [{ sequence, files: [{ file, result, status }], status, identity }]
113
+ */
114
+ export function buildSortedRows(sequences, filesBySequenceId, results) {
115
+ const rows = sequences.map((sequence) => {
116
+ const files = (filesBySequenceId[sequence.databaseId] ?? [])
117
+ .map((file) => ({ file, result: results[file.id], status: fileRowStatus(results[file.id]), sortKey: fileSortKey(results[file.id]) }))
118
+ .sort((a, b) => compareKeys(a.sortKey, b.sortKey));
119
+ const identities = files.map(({ result }) => result?.identity).filter((identity) => identity !== undefined);
120
+ const filesLoaded = filesBySequenceId[sequence.databaseId] !== undefined;
121
+ if (!filesLoaded) {
122
+ return { sequence, files, filesLoaded, status: 'pending', identity: undefined, sortKey: fileSortKey(undefined) };
123
+ }
124
+ return {
125
+ sequence,
126
+ files,
127
+ filesLoaded,
128
+ status: sequenceRowStatus(files.map(({ file }) => file), results),
129
+ identity: identities.length > 0 ? Math.min(...identities) : undefined,
130
+ sortKey: files.length > 0 ? files[0].sortKey : NO_FILES_SORT_KEY,
131
+ };
132
+ });
133
+ return rows.sort((a, b) => compareKeys(a.sortKey, b.sortKey));
134
+ }