dsh-molbio-tools 0.22.2 → 0.24.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.
- package/CHANGELOG.md +173 -0
- package/README.md +51 -19
- package/benchmark/README.md +7 -7
- package/benchmark/fixtures.mjs +32 -4
- package/benchmark/run.mjs +1 -0
- package/benchmark/sequences.mjs +1 -0
- package/benchmark/tasks/02-primers.json +3 -3
- package/benchmark/tasks/06-analysis.json +111 -1
- package/benchmark/tasks/09-formats.json +44 -1
- package/benchmark/verifications.mjs +13 -0
- package/build/client-entry.mjs +80 -1
- package/build/panel-core.mjs +85 -0
- package/build/release-preflight.mjs +1 -1
- package/contamination.mjs +229 -0
- package/cordis.patch.yml +9 -5
- package/design.mjs +111 -78
- package/docs/capability-gap-survey.md +5 -0
- package/docs/client-panel.md +9 -7
- package/docs/history.md +62 -0
- package/docs/maintainer.md +8 -5
- package/docs/roadmap.md +33 -15
- package/docs/rules.md +5 -2
- package/docs/store-evidence.md +30 -23
- package/docs/store-preset-exemption-issue.md +1 -1
- package/docs/store-preset-exemption.md +1 -1
- package/docs/v24-plan.md +178 -0
- package/docs/workflow.md +11 -10
- package/dotplot.mjs +257 -0
- package/fastq-qc.mjs +6 -9
- package/fastq-trim.mjs +238 -0
- package/index.mjs +470 -24
- package/lib/client.js +327 -93
- package/package.json +8 -3
- package/preset/molbio-lab/agent.cordis.yml +19 -13
- package/preset/molbio-lab/preset.yml +1 -1
- package/protein-charge.mjs +158 -0
- package/protein.mjs +36 -14
- package/seqio.mjs +10 -0
- package/test/client.mjs +12 -5
- package/test/contract.mjs +44 -12
- package/test/drift-probe.mjs +15 -1
- package/test/map-card.mjs +139 -1
- package/test/preset-health.mjs +39 -0
- package/test/smoke.mjs +288 -11
package/test/map-card.mjs
CHANGED
|
@@ -344,6 +344,144 @@ assert.ok(unparseableText.includes('could not be parsed as SVG'), 'unparseable m
|
|
|
344
344
|
assert.ok(unparseableText.includes('pCARD · 206 bp'), 'and the header is still there, above the explanation');
|
|
345
345
|
unparseable.unmount();
|
|
346
346
|
|
|
347
|
+
// ── figure cards: sequence logo and gRNA design ─────────────────────────────
|
|
348
|
+
|
|
349
|
+
/** Deterministic pseudo-random DNA for the gRNA fixture. */
|
|
350
|
+
function dnaFixture(n, seed) {
|
|
351
|
+
let s = seed;
|
|
352
|
+
const bases = 'ACGT';
|
|
353
|
+
let out = '';
|
|
354
|
+
for (let i = 0; i < n; i++) {
|
|
355
|
+
s = (s * 1103515245 + 12345) % 2147483648;
|
|
356
|
+
out += bases[(s >> 16) % 4];
|
|
357
|
+
}
|
|
358
|
+
return out;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const logoTool = registered.find((entry) => entry.name === 'molbio_sequence_logo');
|
|
362
|
+
const grnaTool = registered.find((entry) => entry.name === 'molbio_grna_design');
|
|
363
|
+
assert.ok(logoTool !== undefined && grnaTool !== undefined, 'both figure tools register');
|
|
364
|
+
|
|
365
|
+
const logoArgs = { sequences: ['ACGTACGTACGTACGT', 'ACGTACGTACGTACGA', 'ACGTACGTTCGTACGT'] };
|
|
366
|
+
const logoValue = await logoTool.execute(logoArgs, exec);
|
|
367
|
+
const logoMeta = logoTool.output.presentationMeta(logoArgs, logoValue);
|
|
368
|
+
assert.equal(logoMeta.kind, 'molbio-figure');
|
|
369
|
+
assert.equal(logoMeta.figure, 'logo');
|
|
370
|
+
assert.equal(logoMeta.svg_path, logoValue.svg_path);
|
|
371
|
+
assert.ok(typeof logoMeta.svg === 'string' && logoMeta.svg.startsWith('<svg'), 'the logo card meta carries the markup');
|
|
372
|
+
assert.equal(logoMeta.svg_bytes, logoMeta.svg.length);
|
|
373
|
+
assert.match(logoMeta.summary, /3 sequences × 16 columns/, 'the summary states the alignment size');
|
|
374
|
+
|
|
375
|
+
const logoView = panelCore.figureCardView(logoMeta);
|
|
376
|
+
assert.equal(logoView.kind, 'figure', 'the card resolves the logo projection');
|
|
377
|
+
assert.equal(logoView.svg, logoMeta.svg);
|
|
378
|
+
assert.equal(logoView.svgPath, logoValue.svg_path);
|
|
379
|
+
assert.equal(panelCore.figureCardSummary(logoView), logoMeta.summary);
|
|
380
|
+
|
|
381
|
+
const grnaArgs = {
|
|
382
|
+
sequence: dnaFixture(320, 7),
|
|
383
|
+
map_path: at('grna-map.svg'),
|
|
384
|
+
save_path: at('grna-order.csv'),
|
|
385
|
+
max_guides: 5,
|
|
386
|
+
};
|
|
387
|
+
const grnaValue = await grnaTool.execute(grnaArgs, exec);
|
|
388
|
+
assert.ok(grnaValue.guides.length > 0, 'the gRNA fixture yields at least one guide');
|
|
389
|
+
const grnaMeta = grnaTool.output.presentationMeta(grnaArgs, grnaValue);
|
|
390
|
+
assert.equal(grnaMeta.kind, 'molbio-figure');
|
|
391
|
+
assert.equal(grnaMeta.figure, 'grna');
|
|
392
|
+
assert.equal(grnaMeta.svg_path, grnaValue.map_path);
|
|
393
|
+
assert.ok(typeof grnaMeta.svg === 'string' && grnaMeta.svg.startsWith('<svg'), 'the gRNA card carries the map markup');
|
|
394
|
+
assert.ok(grnaMeta.guides.length >= 1 && grnaMeta.guides.length <= 5, 'the card carries at most five guide rows');
|
|
395
|
+
assert.equal(grnaMeta.guides[0].sequence, grnaValue.guides[0].sequence);
|
|
396
|
+
assert.equal(grnaMeta.csv_path, grnaValue.saved_to);
|
|
397
|
+
assert.match(grnaMeta.summary, /SpCas9/, 'the summary names the Cas family');
|
|
398
|
+
|
|
399
|
+
const grnaView = panelCore.figureCardView(grnaMeta);
|
|
400
|
+
assert.equal(grnaView.kind, 'figure');
|
|
401
|
+
assert.equal(grnaView.guides.length, grnaMeta.guides.length);
|
|
402
|
+
assert.equal(grnaView.guides[0].offTargets, grnaValue.guides[0].off_target_count ?? 0);
|
|
403
|
+
|
|
404
|
+
// A gRNA call WITHOUT a map still gets the ranked-guide table, with an
|
|
405
|
+
// explanation for the absent picture rather than a blank card.
|
|
406
|
+
const plainGrnaValue = await grnaTool.execute({ sequence: dnaFixture(320, 7), max_guides: 5 }, exec);
|
|
407
|
+
const plainMeta = grnaTool.output.presentationMeta({ sequence: dnaFixture(320, 7) }, plainGrnaValue);
|
|
408
|
+
assert.equal(plainMeta.svg_missing, true);
|
|
409
|
+
const plainView = panelCore.figureCardView(plainMeta);
|
|
410
|
+
assert.equal(plainView.kind, 'notice');
|
|
411
|
+
assert.match(plainView.message, /No picture was requested/i);
|
|
412
|
+
assert.ok(plainView.guides.length > 0, 'the table survives the missing picture');
|
|
413
|
+
|
|
414
|
+
// ── figure-card fallbacks: never throw inside a transcript ──────────────────
|
|
415
|
+
|
|
416
|
+
assert.equal(panelCore.figureCardView({ kind: 'molbio-map' }).kind, 'notice', 'a map meta is not a figure');
|
|
417
|
+
assert.equal(panelCore.figureCardView(undefined).kind, 'notice');
|
|
418
|
+
assert.equal(panelCore.figureCardView(null).kind, 'notice');
|
|
419
|
+
assert.equal(panelCore.figureCardView('nonsense').kind, 'notice');
|
|
420
|
+
assert.equal(panelCore.figureCardView([]).kind, 'notice');
|
|
421
|
+
assert.equal(panelCore.figureCardView({ kind: 'molbio-figure', figure: 'unknown' }).kind, 'notice');
|
|
422
|
+
const oversizedFigure = panelCore.figureCardView({
|
|
423
|
+
kind: 'molbio-figure',
|
|
424
|
+
figure: 'logo',
|
|
425
|
+
summary: 'Sequence logo',
|
|
426
|
+
svg_omitted: true,
|
|
427
|
+
svg_bytes: 900_000,
|
|
428
|
+
svg_path: at('big-logo.svg'),
|
|
429
|
+
});
|
|
430
|
+
assert.equal(oversizedFigure.kind, 'notice');
|
|
431
|
+
assert.match(oversizedFigure.message, /too large/i);
|
|
432
|
+
assert.equal(oversizedFigure.svgPath, at('big-logo.svg'));
|
|
433
|
+
assert.equal(panelCore.figureCardView({ kind: 'molbio-figure', figure: 'logo', svg: '<div>hi</div>' }).kind, 'notice');
|
|
434
|
+
const sanitized = panelCore.figureCardView({
|
|
435
|
+
kind: 'molbio-figure',
|
|
436
|
+
figure: 'grna',
|
|
437
|
+
summary: 's',
|
|
438
|
+
svg_missing: true,
|
|
439
|
+
guides: [null, 7, { sequence: 42 }, { sequence: 'ACGT', rank: 'x', start: 1.5 }, { sequence: 'ACGT', rank: 1 }, { sequence: 'ACGT', rank: 2 }, { sequence: 'ACGT', rank: 3 }, { sequence: 'ACGT', rank: 4 }],
|
|
440
|
+
});
|
|
441
|
+
assert.equal(sanitized.guides.length, 5, 'malformed rows are dropped and the list is capped at five');
|
|
442
|
+
assert.equal(sanitized.guides[0].rank, 0, 'a non-integer rank degrades to 0 rather than being trusted');
|
|
443
|
+
|
|
444
|
+
// ── the figure component: logo and gRNA render through the same card ────────
|
|
445
|
+
|
|
446
|
+
const FigureCard = slots.registrations.find(
|
|
447
|
+
(entry) => entry.registration.name === 'tool.call.toolview' && entry.registration.key === 'molbio_sequence_logo',
|
|
448
|
+
).component;
|
|
449
|
+
assert.equal(typeof FigureCard, 'function', 'the figure card is registered for molbio_sequence_logo');
|
|
450
|
+
|
|
451
|
+
globalThis.__molbioTestHooks = mount(FigureCard);
|
|
452
|
+
const logoCard = globalThis.__molbioTestHooks;
|
|
453
|
+
logoCard.setProps({ block: { kind: 'tool-result', callId: 'l1', isError: false, meta: logoMeta, content: [{ type: 'text', text: 'logo saved' }] }, inspect: undefined });
|
|
454
|
+
await logoCard.render();
|
|
455
|
+
const logoText = texts(logoCard.tree).join('\n');
|
|
456
|
+
assert.ok(logoText.includes('Sequence logo · 3 sequences × 16 columns'), 'the logo card header summarises the alignment');
|
|
457
|
+
assert.ok(logoText.includes(logoValue.svg_path), 'the logo card names the written file');
|
|
458
|
+
const logoHosts = (function find(node, out = []) {
|
|
459
|
+
if (Array.isArray(node)) {
|
|
460
|
+
for (const child of node) find(child, out);
|
|
461
|
+
return out;
|
|
462
|
+
}
|
|
463
|
+
if (node !== null && typeof node === 'object' && node.props !== undefined) {
|
|
464
|
+
if (node.props.ref !== undefined && node.props.ref !== null) out.push(node);
|
|
465
|
+
find(node.props.children, out);
|
|
466
|
+
}
|
|
467
|
+
return out;
|
|
468
|
+
})(logoCard.tree);
|
|
469
|
+
assert.equal(logoHosts.length, 1, 'the logo card mounts one SVG host');
|
|
470
|
+
assert.equal(logoHosts[0].props.ref.current.children.length, 1, 'and appended the parsed logo into it');
|
|
471
|
+
logoCard.unmount();
|
|
472
|
+
|
|
473
|
+
globalThis.__molbioTestHooks = mount(FigureCard);
|
|
474
|
+
const grnaCard = globalThis.__molbioTestHooks;
|
|
475
|
+
grnaCard.setProps({ block: { kind: 'tool-result', callId: 'g1', isError: false, meta: grnaMeta, content: [{ type: 'text', text: 'guides designed' }] }, inspect: undefined });
|
|
476
|
+
await grnaCard.render();
|
|
477
|
+
const grnaText = texts(grnaCard.tree).join('\n');
|
|
478
|
+
assert.ok(grnaText.includes('SpCas9'), 'the gRNA card header names the family');
|
|
479
|
+
assert.ok(grnaText.includes(grnaValue.guides[0].sequence), 'the gRNA card lists the top guide');
|
|
480
|
+
assert.ok(grnaText.includes('Off-targets'), 'the gRNA card shows the guide table header');
|
|
481
|
+
assert.ok(grnaText.includes(grnaValue.map_path), 'and the written map path');
|
|
482
|
+
assert.ok(grnaText.includes(grnaValue.saved_to), 'and the ordering CSV path');
|
|
483
|
+
grnaCard.unmount();
|
|
484
|
+
|
|
347
485
|
await rm(shimPath, { force: true });
|
|
348
486
|
await rm(entryPath, { force: true });
|
|
349
|
-
console.log('
|
|
487
|
+
console.log('tool card checks passed (map + logo/gRNA: host projection -> card view -> rendered SVG)');
|
package/test/preset-health.mjs
CHANGED
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
*/
|
|
37
37
|
import { readFile, readdir } from 'node:fs/promises';
|
|
38
38
|
import { existsSync } from 'node:fs';
|
|
39
|
+
import { createRequire } from 'node:module';
|
|
39
40
|
import { dirname, join, resolve } from 'node:path';
|
|
40
41
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
41
42
|
// Re-exported for test/drift-probe.mjs, which drives the same reader against
|
|
@@ -101,6 +102,34 @@ function flattenRows(rows, at = '') {
|
|
|
101
102
|
return found;
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Resolve a row's specifier the way the Loader does, or say why it cannot.
|
|
107
|
+
*
|
|
108
|
+
* The Loader imports a package row BY SPECIFIER (`import.meta.resolve` anchored
|
|
109
|
+
* in the config project's scope, see `cordis-plugin-loader`), and Node enforces
|
|
110
|
+
* the target package's `exports` map on that specifier. `packageInstalled` only
|
|
111
|
+
* proves that a directory exists, so a row naming a SUBPATH the harness dropped
|
|
112
|
+
* used to pass this suite and then fail at preset mount — that is exactly how
|
|
113
|
+
* `@deepseek-ai/dsh-tool-subagent-control/list-agents` (removed in 0.2.2-rc.1,
|
|
114
|
+
* where `list_agents` moved into the control plugin itself) survived until a
|
|
115
|
+
* manual run against the new harness.
|
|
116
|
+
*
|
|
117
|
+
* Exported so `test/drift-probe.mjs` can drive it against a mutated specifier:
|
|
118
|
+
* a guard nobody has seen fail is not a guard (rules.md rule 9).
|
|
119
|
+
*
|
|
120
|
+
* @param {string} name the row's `name`.
|
|
121
|
+
* @param {string} harnessRoot the installed harness.
|
|
122
|
+
* @returns {string|undefined} a diagnostic, or undefined when it resolves.
|
|
123
|
+
*/
|
|
124
|
+
export function specifierProblem(name, harnessRoot) {
|
|
125
|
+
try {
|
|
126
|
+
createRequire(join(harnessRoot, 'package.json')).resolve(name);
|
|
127
|
+
return undefined;
|
|
128
|
+
} catch (error) {
|
|
129
|
+
return `specifier does not resolve: ${error?.code ?? error?.message ?? error}`;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
104
133
|
/** Validate one row against its package's own `Config` schema. */
|
|
105
134
|
async function checkRow(row, packages, harnessRoot, presetBase) {
|
|
106
135
|
const id = typeof row.id === 'string' ? row.id : row.name;
|
|
@@ -118,6 +147,16 @@ async function checkRow(row, packages, harnessRoot, presetBase) {
|
|
|
118
147
|
: { ...result, status: 'unresolvable', detail: `preset-relative module not found: ${target}` };
|
|
119
148
|
}
|
|
120
149
|
|
|
150
|
+
// A bare package row is imported by SPECIFIER, so its `exports` map decides
|
|
151
|
+
// whether it mounts. Check that before looking for the package directory:
|
|
152
|
+
// `packageInstalled` walks up from the harness root and would happily report a
|
|
153
|
+
// package whose named subpath no longer exists. `cordis:*` rows name Loader
|
|
154
|
+
// builtins, not modules, so they are skipped here too.
|
|
155
|
+
if (!row.name.includes(':') && row.name !== selfName) {
|
|
156
|
+
const problem = specifierProblem(row.name, harnessRoot);
|
|
157
|
+
if (problem !== undefined) return { ...result, status: 'unresolvable', detail: problem };
|
|
158
|
+
}
|
|
159
|
+
|
|
121
160
|
const pkgDir = row.name === selfName
|
|
122
161
|
// The row names THIS package. `packageInstalled` walks up from the harness
|
|
123
162
|
// root, where this package is not installed — but in the preset it is
|
package/test/smoke.mjs
CHANGED
|
@@ -1587,8 +1587,8 @@ function makeTemplate(n, seed = 42) {
|
|
|
1587
1587
|
|
|
1588
1588
|
{
|
|
1589
1589
|
// cross-intron primer design (fixture seeds chosen to pass the v12
|
|
1590
|
-
// Primer3-style structural defaults)
|
|
1591
|
-
const e1 = makeTemplate(120,
|
|
1590
|
+
// Primer3-style structural defaults under the corrected orientation)
|
|
1591
|
+
const e1 = makeTemplate(120, 49);
|
|
1592
1592
|
const e2 = makeTemplate(120, 14);
|
|
1593
1593
|
const e3 = makeTemplate(120, 34);
|
|
1594
1594
|
const genomic = e1 + makeTemplate(800, 29) + e2 + makeTemplate(800, 31) + e3;
|
|
@@ -1603,15 +1603,26 @@ function makeTemplate(n, seed = 42) {
|
|
|
1603
1603
|
});
|
|
1604
1604
|
assert.ok(out.pairs.length > 0, 'should find junction-spanning pairs');
|
|
1605
1605
|
const pair = out.pairs[0];
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1606
|
+
// 0.23.0: the junction is carried by the REVERSE primer (the assay sits 5' of
|
|
1607
|
+
// the junction); forward is upstream and inside one exon. The reverse sequence
|
|
1608
|
+
// is the molecule to order, i.e. the reverse complement of its spliced site.
|
|
1609
|
+
assert.ok(pair.reverse.junction_left >= 6 && pair.reverse.junction_right >= 6);
|
|
1610
|
+
assert.equal(pair.reverse.exons.length, 2);
|
|
1611
|
+
assert.equal(Math.abs(Number(pair.reverse.exons[1]) - Number(pair.reverse.exons[0])), 1);
|
|
1609
1612
|
assert.ok(pair.genomic_amplicon_length >= 900);
|
|
1610
1613
|
assert.ok(pair.spliced_amplicon.length >= 80 && pair.spliced_amplicon.length <= 200);
|
|
1611
|
-
assert.ok(pair.
|
|
1614
|
+
assert.ok(pair.forward.exon >= 1 && pair.forward.exon < Number(pair.reverse.exons[1]), 'forward sits in an earlier exon than the reverse primer\'s downstream exon');
|
|
1615
|
+
assert.ok(pair.forward.spliced_start < pair.reverse.spliced_start, 'F is upstream of R');
|
|
1616
|
+
assert.equal(pair.forward.spliced_start, pair.spliced_amplicon.start, 'amplicon opens at the forward 5\' end');
|
|
1617
|
+
assert.equal(pair.reverse.spliced_end, pair.spliced_amplicon.end, 'amplicon closes at the reverse 5\' end');
|
|
1612
1618
|
const spliced = e1 + e2 + e3;
|
|
1613
1619
|
assert.ok(spliced.includes(pair.forward.sequence), 'forward primer is a spliced-sequence substring');
|
|
1614
|
-
assert.
|
|
1620
|
+
assert.equal(
|
|
1621
|
+
pair.reverse.sequence,
|
|
1622
|
+
lib.reverseComplement(spliced.slice(pair.reverse.spliced_start - 1, pair.reverse.spliced_end)),
|
|
1623
|
+
'reverse primer is the reverse complement of its spliced site (the molecule to order)',
|
|
1624
|
+
);
|
|
1625
|
+
assert.ok(pair.reverse.spliced_start <= 120 && pair.reverse.spliced_end > 120, 'the reverse primer straddles the exon 1/2 junction');
|
|
1615
1626
|
}
|
|
1616
1627
|
|
|
1617
1628
|
// ── v12: mismatch tolerance in primer design ────────────────────────────────
|
|
@@ -1782,10 +1793,24 @@ function makeTemplate(n, seed = 42) {
|
|
|
1782
1793
|
assert.equal(primer.mismatch_count, primer.mismatches.length);
|
|
1783
1794
|
for (const m of primer.mismatches) {
|
|
1784
1795
|
assert.equal(m.primer_base, primer.sequence[m.position - 1]);
|
|
1785
|
-
|
|
1796
|
+
// The perfect-match base is the transcript base for the forward primer and
|
|
1797
|
+
// its COMPLEMENT for the reverse primer (which is reported as the molecule
|
|
1798
|
+
// to order, i.e. the reverse complement of its spliced site).
|
|
1799
|
+
const transcriptBase = spliced[m.spliced_position - 1];
|
|
1800
|
+
assert.equal(m.template_base, isForward ? transcriptBase : lib.complement(transcriptBase), 'template_base is the perfect-match base on this strand');
|
|
1786
1801
|
assert.equal(m.genomic_position, splicedToGenomic(m.spliced_position, exons), 'genomic position maps consistently');
|
|
1787
1802
|
assert.ok(m.distance_from_3prime >= 1, 'no terminal-base mismatch in intron mode');
|
|
1788
1803
|
}
|
|
1804
|
+
// The reported molecule must equal its spliced site (forward) or that
|
|
1805
|
+
// site's reverse complement (reverse) outside the reported mismatches.
|
|
1806
|
+
const expected = isForward
|
|
1807
|
+
? spliced.slice(primer.spliced_start - 1, primer.spliced_end)
|
|
1808
|
+
: lib.reverseComplement(spliced.slice(primer.spliced_start - 1, primer.spliced_end));
|
|
1809
|
+
const positions = new Set(primer.mismatches.map((m) => m.position));
|
|
1810
|
+
for (let i = 0; i < expected.length; i++) {
|
|
1811
|
+
if (positions.has(i + 1)) continue;
|
|
1812
|
+
assert.equal(primer.sequence[i], expected[i], 'reported primer equals its site outside the reported mismatches');
|
|
1813
|
+
}
|
|
1789
1814
|
}
|
|
1790
1815
|
}
|
|
1791
1816
|
|
|
@@ -2133,8 +2158,10 @@ if (savedNcbiEnv.MOLBIO_NCBI_API_KEY !== undefined) process.env.MOLBIO_NCBI_API_
|
|
|
2133
2158
|
assert.equal(plain.pairs[0].target_distance, undefined, 'no target_distance without target_position');
|
|
2134
2159
|
await assert.rejects(() => run('molbio_design_primers', { template, target_position: 601 }), /outside the template/);
|
|
2135
2160
|
await assert.rejects(() => run('molbio_design_primers', { template, target_penalty: -1 }), /target_penalty/);
|
|
2136
|
-
// cross-intron design reports spliced-coordinate target distances too
|
|
2137
|
-
|
|
2161
|
+
// cross-intron design reports spliced-coordinate target distances too, and
|
|
2162
|
+
// (0.23.0) measures each one from the primer's actual 3' end: the forward's
|
|
2163
|
+
// highest coordinate and the reverse's LOWEST coordinate.
|
|
2164
|
+
const e1 = makeTemplate(120, 49);
|
|
2138
2165
|
const e2 = makeTemplate(120, 14);
|
|
2139
2166
|
const e3 = makeTemplate(120, 34);
|
|
2140
2167
|
const genomic = e1 + makeTemplate(800, 29) + e2 + makeTemplate(800, 31) + e3;
|
|
@@ -2143,6 +2170,8 @@ if (savedNcbiEnv.MOLBIO_NCBI_API_KEY !== undefined) process.env.MOLBIO_NCBI_API_
|
|
|
2143
2170
|
assert.ok(intron.pairs.length > 0);
|
|
2144
2171
|
for (const pair of intron.pairs) {
|
|
2145
2172
|
assert.equal(pair.target_distance, Math.min(pair.forward.target_distance, pair.reverse.target_distance));
|
|
2173
|
+
assert.equal(pair.forward.target_distance, Math.abs(pair.forward.spliced_end - 150), 'forward distance is from its 3\' end');
|
|
2174
|
+
assert.equal(pair.reverse.target_distance, Math.abs(pair.reverse.spliced_start - 150), 'reverse distance is from its 3\' end');
|
|
2146
2175
|
assert.ok(pair.forward.target_distance <= 359);
|
|
2147
2176
|
}
|
|
2148
2177
|
}
|
|
@@ -3323,6 +3352,7 @@ if (savedNcbiEnv.MOLBIO_NCBI_API_KEY !== undefined) process.env.MOLBIO_NCBI_API_
|
|
|
3323
3352
|
'molbio_grna_design', 'molbio_qpcr_efficiency', 'molbio_plot', 'molbio_virtual_gel',
|
|
3324
3353
|
'molbio_sequence_logo', 'molbio_helical_wheel', 'molbio_hydropathy_plot',
|
|
3325
3354
|
'molbio_fastq_qc', 'molbio_phylogenetic_tree', 'molbio_pcr_simulate', 'molbio_gc_composition',
|
|
3355
|
+
'molbio_protein_charge', 'molbio_dotplot',
|
|
3326
3356
|
];
|
|
3327
3357
|
for (const toolName of pictureTools) {
|
|
3328
3358
|
const tool = toolByName(toolName);
|
|
@@ -3330,7 +3360,7 @@ if (savedNcbiEnv.MOLBIO_NCBI_API_KEY !== undefined) process.env.MOLBIO_NCBI_API_
|
|
|
3330
3360
|
assert.ok(Object.hasOwn(tool.output.schema.properties, 'image'), `${toolName} can report the attached image`);
|
|
3331
3361
|
assert.ok(Object.hasOwn(tool.output.schema.properties, 'image_note'), `${toolName} can explain a missing image`);
|
|
3332
3362
|
}
|
|
3333
|
-
assert.equal(registered.filter((tool) => Object.hasOwn(tool.parameters.properties, 'attach_image')).length,
|
|
3363
|
+
assert.equal(registered.filter((tool) => Object.hasOwn(tool.parameters.properties, 'attach_image')).length, 17, 'exactly the 17 picture tools — the report-only analysis tools are untouched');
|
|
3334
3364
|
|
|
3335
3365
|
// 9. Rendering text alone still works (the attachment must not break render).
|
|
3336
3366
|
const logo = await runWith('molbio_sequence_logo', { alignment: ['ACGTACGT', 'ACGTTCGT'], attach_image: true }, routedExec);
|
|
@@ -3447,6 +3477,22 @@ if (savedNcbiEnv.MOLBIO_NCBI_API_KEY !== undefined) process.env.MOLBIO_NCBI_API_
|
|
|
3447
3477
|
assert.equal(adapterUncapped.adapter.per_base_percent[23], 12.5, 'through its last base at position 24');
|
|
3448
3478
|
assert.equal(adapterUncapped.adapter.per_base_percent[24], 0, 'and stops there');
|
|
3449
3479
|
|
|
3480
|
+
// 6b. The Small RNA 5' entry is documented as RNA; the table is DNA-normalized
|
|
3481
|
+
// so BOTH the forward molecule and its reverse complement are detectable in
|
|
3482
|
+
// cDNA reads. Before the fix only one of these two reads matched.
|
|
3483
|
+
const smallRna = 'GTTCAGAGTTCTACAGTCCGACGATC';
|
|
3484
|
+
const smallRnaFastq = [
|
|
3485
|
+
'@rna_f', 'ACGT' + smallRna + 'ACGT', '+', 'I'.repeat(8 + smallRna.length),
|
|
3486
|
+
'@rna_r', 'ACGT' + reverseComplementOf(smallRna) + 'ACGT', '+', 'I'.repeat(8 + smallRna.length),
|
|
3487
|
+
'',
|
|
3488
|
+
].join('\n');
|
|
3489
|
+
const qcRna = await run('molbio_fastq_qc', { fastq: smallRnaFastq, adapter_scan_reads: 10, max_plot_bases: 100 });
|
|
3490
|
+
assert.deepEqual(
|
|
3491
|
+
qcRna.adapter.hits.map((hit) => [hit.name, hit.reads, hit.percent]),
|
|
3492
|
+
[["Illumina Small RNA 5'", 2, 100]],
|
|
3493
|
+
'the DNA-normalized Small RNA adapter matches the forward and reverse-complement reads',
|
|
3494
|
+
);
|
|
3495
|
+
|
|
3450
3496
|
// 7. Over-representation names the threshold that produced it — and on a
|
|
3451
3497
|
// sample this small that threshold legitimately yields nothing, because
|
|
3452
3498
|
// the bar is max(20 reads, 0.1% of the sample).
|
|
@@ -4187,6 +4233,237 @@ if (savedNcbiEnv.MOLBIO_NCBI_API_KEY !== undefined) process.env.MOLBIO_NCBI_API_
|
|
|
4187
4233
|
await assert.rejects(() => run('molbio_gc_composition', { sequence: 'ACGTACGTAC', window: 5 }), /window must be an integer of at least 10/);
|
|
4188
4234
|
}
|
|
4189
4235
|
|
|
4236
|
+
// ── 0.23.0: protein charge vs pH ───────────────────────────────────────────
|
|
4237
|
+
{
|
|
4238
|
+
// KKDD has two positive and two negative side chains plus the termini, so the
|
|
4239
|
+
// two ends of the curve are exactly +3 and -3 (within the 1e-4 sampling
|
|
4240
|
+
// rounding) and the pI is the zero crossing between them.
|
|
4241
|
+
const sequence = 'KKDD';
|
|
4242
|
+
const props = await run('molbio_protein_props', { sequence });
|
|
4243
|
+
const out = await run('molbio_protein_charge', { sequence, output_path: at('charge.svg') });
|
|
4244
|
+
assert.equal(out.length, 4);
|
|
4245
|
+
assert.equal(out.pka_set, 'bjellqvist');
|
|
4246
|
+
assert.equal(out.pka_label, 'Bjellqvist 1993');
|
|
4247
|
+
assert.equal(out.pI, props.pi, 'the default pK set matches molbio_protein_props');
|
|
4248
|
+
assert.equal(out.points.length, 29);
|
|
4249
|
+
assert.equal(out.points[0].ph, 0);
|
|
4250
|
+
assert.equal(out.points[28].ph, 14);
|
|
4251
|
+
assert.ok(Math.abs(out.points[0].charge - 3) < 0.01, 'pH 0: N-terminus + 2 Lys = +3');
|
|
4252
|
+
assert.ok(Math.abs(out.points[28].charge + 3) < 0.01, 'pH 14: C-terminus + 2 Asp = -3');
|
|
4253
|
+
assert.ok(Math.abs(out.charge_at_7_4 + 0.0591) < 0.01);
|
|
4254
|
+
// The pI must lie between the two sampled pH values whose charges bracket 0.
|
|
4255
|
+
let bracketed = false;
|
|
4256
|
+
for (let i = 1; i < out.points.length; i++) {
|
|
4257
|
+
const a = out.points[i - 1];
|
|
4258
|
+
const b = out.points[i];
|
|
4259
|
+
if ((a.charge >= 0 && b.charge <= 0) || (a.charge <= 0 && b.charge >= 0)) {
|
|
4260
|
+
assert.ok(out.pI >= a.ph && out.pI <= b.ph, 'pI is the zero crossing');
|
|
4261
|
+
bracketed = true;
|
|
4262
|
+
break;
|
|
4263
|
+
}
|
|
4264
|
+
}
|
|
4265
|
+
assert.ok(bracketed, 'the sampled curve changes sign');
|
|
4266
|
+
assert.ok(memFs.files.get(at('charge.svg')).includes('<svg'), 'the titration SVG is written');
|
|
4267
|
+
|
|
4268
|
+
// A different pK table is a different answer, and it says which one it used.
|
|
4269
|
+
const emboss = await run('molbio_protein_charge', { sequence, pka_set: 'emboss', output_path: at('charge-emboss.svg') });
|
|
4270
|
+
assert.equal(emboss.pka_set, 'emboss');
|
|
4271
|
+
assert.equal(emboss.pka_label, 'EMBOSS Epk.dat');
|
|
4272
|
+
assert.notEqual(emboss.pI, out.pI, 'the pK set changes the pI, so the set name must travel with it');
|
|
4273
|
+
|
|
4274
|
+
// Errors: range checks in the module, enum check by the argument schema.
|
|
4275
|
+
await assert.rejects(() => run('molbio_protein_charge', { sequence, ph_min: 15 }), /ph_min/);
|
|
4276
|
+
await assert.rejects(() => run('molbio_protein_charge', { sequence, ph_min: 8, ph_max: 7 }), /greater than ph_min/);
|
|
4277
|
+
await assert.rejects(() => run('molbio_protein_charge', { sequence, steps: 1 }), /steps/);
|
|
4278
|
+
await assert.rejects(() => run('molbio_protein_charge', { sequence, pka_set: 'nonsense' }), /must be one of/);
|
|
4279
|
+
await assert.rejects(() => run('molbio_protein_charge', { sequence: 'ACGTX' }), /invalid character/);
|
|
4280
|
+
}
|
|
4281
|
+
|
|
4282
|
+
// ── 0.23.0: sequence dotplot ────────────────────────────────────────────────
|
|
4283
|
+
{
|
|
4284
|
+
// Two 12 bp copies of the same word separated by an 8 bp T stretch: with a
|
|
4285
|
+
// 4-mer word every k-mer in one copy matches the same k-mer in the other.
|
|
4286
|
+
const sequence = 'ACGTACGTACGT' + 'TTTTTTTT' + 'ACGTACGTACGT';
|
|
4287
|
+
const out = await run('molbio_dotplot', { sequence, word: 4, output_path: at('dot.svg') });
|
|
4288
|
+
assert.equal(out.length, 32);
|
|
4289
|
+
assert.equal(out.word, 4);
|
|
4290
|
+
assert.equal(out.mode, 'both');
|
|
4291
|
+
assert.equal(out.direct_matches, 133);
|
|
4292
|
+
assert.equal(out.inverted_matches, 40);
|
|
4293
|
+
assert.equal(out.direct_cells, out.direct_matches, 'this small fixture is under one dot per grid cell');
|
|
4294
|
+
assert.equal(out.truncated, false);
|
|
4295
|
+
assert.equal(out.plot_truncated, false);
|
|
4296
|
+
const dotSvg = memFs.files.get(at('dot.svg'));
|
|
4297
|
+
assert.ok(dotSvg.includes('<svg'), 'the dotplot SVG is written');
|
|
4298
|
+
// Layout guard: the x-axis title and the legend both live BELOW the plot but
|
|
4299
|
+
// inside the panel. The first version put them on/outside the bottom border
|
|
4300
|
+
// (720 px canvas, 628 px square plot); a plain "contains <svg>" check would
|
|
4301
|
+
// pass while the footer was clipped, so assert the vertical budget instead.
|
|
4302
|
+
const dotView = /viewBox="0 0 (\d+) (\d+)"/.exec(dotSvg);
|
|
4303
|
+
assert.ok(dotView !== null, 'the dotplot declares a viewBox');
|
|
4304
|
+
const dotHeight = Number(dotView[2]);
|
|
4305
|
+
const dotTexts = [...dotSvg.matchAll(/<text ([^>]*)>([^<]*)<\/text>/g)]
|
|
4306
|
+
.map((match) => ({ attrs: match[1], text: match[2] }));
|
|
4307
|
+
const yOf = (attrs) => Number(/y="([0-9.]+)"/.exec(attrs)[1]);
|
|
4308
|
+
const xTitle = dotTexts.find((node) => node.text === 'position (bp)' && !node.attrs.includes('transform='));
|
|
4309
|
+
const directLegend = dotTexts.find((node) => node.text === 'direct repeat');
|
|
4310
|
+
assert.ok(xTitle !== undefined, 'the x axis is titled');
|
|
4311
|
+
assert.ok(directLegend !== undefined, 'the direct-repeat legend is drawn');
|
|
4312
|
+
assert.ok(yOf(xTitle.attrs) <= dotHeight - 30, 'the x-axis title is not sitting on the bottom border');
|
|
4313
|
+
assert.ok(yOf(directLegend.attrs) <= dotHeight - 20, 'the legend is inside the canvas');
|
|
4314
|
+
assert.ok(yOf(directLegend.attrs) > yOf(xTitle.attrs), 'the legend sits below the x-axis title, not on top of it');
|
|
4315
|
+
|
|
4316
|
+
// Inverted-only mode does not report direct matches, and an inverted repeat of
|
|
4317
|
+
// the first block shows up as 12 exact 4-mer hits.
|
|
4318
|
+
const inverted = await run('molbio_dotplot', { sequence: 'AAAACCCC' + 'GGGGTTTT', word: 4, mode: 'inverted' });
|
|
4319
|
+
assert.equal(inverted.direct_matches, 0);
|
|
4320
|
+
assert.equal(inverted.inverted_matches, 12);
|
|
4321
|
+
assert.equal(inverted.inverted_cells, 12);
|
|
4322
|
+
|
|
4323
|
+
// Ambiguous k-mers are skipped and counted, never silently matched.
|
|
4324
|
+
const ambiguous = await run('molbio_dotplot', { sequence: 'ACGTNNNNACGTACGT', word: 4 });
|
|
4325
|
+
assert.equal(ambiguous.ambiguous_bases, 7, 'every 4-mer window touching the N run is skipped');
|
|
4326
|
+
|
|
4327
|
+
// The work cap turns a homopolymer into a bounded, explicitly truncated result.
|
|
4328
|
+
const capped = await run('molbio_dotplot', { sequence: 'A'.repeat(600), word: 4 });
|
|
4329
|
+
assert.equal(capped.truncated, true, 'the scan is capped rather than hanging');
|
|
4330
|
+
assert.ok(capped.direct_matches <= 200_000);
|
|
4331
|
+
|
|
4332
|
+
// Determinism: the same input produces the same counts.
|
|
4333
|
+
const again = await run('molbio_dotplot', { sequence, word: 4 });
|
|
4334
|
+
assert.deepEqual(
|
|
4335
|
+
[again.direct_matches, again.inverted_matches, again.direct_cells, again.inverted_cells],
|
|
4336
|
+
[out.direct_matches, out.inverted_matches, out.direct_cells, out.inverted_cells],
|
|
4337
|
+
);
|
|
4338
|
+
|
|
4339
|
+
await assert.rejects(() => run('molbio_dotplot', { sequence, word: 3 }), /word/);
|
|
4340
|
+
await assert.rejects(() => run('molbio_dotplot', { sequence, word: 13 }), /word/);
|
|
4341
|
+
await assert.rejects(() => run('molbio_dotplot', { sequence: 'ACGTACGT', word: 8 }), /at least/);
|
|
4342
|
+
await assert.rejects(() => run('molbio_dotplot', { sequence, mode: 'diagonal' }), /must be one of/);
|
|
4343
|
+
}
|
|
4344
|
+
|
|
4345
|
+
|
|
4346
|
+
// ── 0.24.0: contamination screening + FASTQ trimming ─────────────────────────
|
|
4347
|
+
{
|
|
4348
|
+
// Two known embedded signatures: a pUC/pBR322 origin fragment at the start and
|
|
4349
|
+
// the Illumina Universal adapter at the end. The middle is a neutral repeat.
|
|
4350
|
+
const contaminated = 'GTTTGCCGGATCAAGAGCTACCAACTCTTT' + 'ACGT'.repeat(20) + 'AGATCGGAAGAG';
|
|
4351
|
+
const screen = await run('molbio_contamination_screen', { sequence: contaminated });
|
|
4352
|
+
assert.equal(screen.input_type, 'sequence');
|
|
4353
|
+
assert.equal(screen.total_entries, 1);
|
|
4354
|
+
assert.equal(screen.hit_count, 2);
|
|
4355
|
+
assert.deepEqual(screen.hits.map((hit) => hit.name), ['Illumina Universal', 'pUC/pBR322 origin']);
|
|
4356
|
+
const pucHit = screen.hits.find((hit) => hit.name === 'pUC/pBR322 origin');
|
|
4357
|
+
assert.deepEqual(
|
|
4358
|
+
[pucHit.kind, pucHit.category, pucHit.example.start, pucHit.example.end, pucHit.example.strand, pucHit.best_mismatches],
|
|
4359
|
+
['vector', 'origin', 1, 30, '+', 0],
|
|
4360
|
+
);
|
|
4361
|
+
const adapterHit = screen.hits.find((hit) => hit.name === 'Illumina Universal');
|
|
4362
|
+
assert.deepEqual([adapterHit.example.start, adapterHit.example.end, adapterHit.example.strand], [111, 122, '+']);
|
|
4363
|
+
|
|
4364
|
+
// A reverse-strand hit is reported on the forward coordinate system.
|
|
4365
|
+
const t7 = 'TAATACGACTCACTATAGGG';
|
|
4366
|
+
const reverse = await run('molbio_contamination_screen', { sequence: 'ACGT'.repeat(10) + reverseComplementOf(t7) });
|
|
4367
|
+
const t7Hit = reverse.hits.find((hit) => hit.name === 'pET T7 promoter');
|
|
4368
|
+
assert.equal(t7Hit.example.strand, '-');
|
|
4369
|
+
assert.equal(t7Hit.example.start, 41);
|
|
4370
|
+
|
|
4371
|
+
// One mismatch is invisible at max_mismatches 0 and found at 1. The mutated
|
|
4372
|
+
// pUC signature still leaves the adapter hit in place.
|
|
4373
|
+
const mutated = contaminated.replace('GTTTGCCGGATCAAGAGCTACCAACTCTTT', 'GTTTGCCGGATCAAGAGCTACCAACTCTTA');
|
|
4374
|
+
const exact = await run('molbio_contamination_screen', { sequence: mutated });
|
|
4375
|
+
assert.equal(exact.hits.some((hit) => hit.name === 'pUC/pBR322 origin'), false);
|
|
4376
|
+
const loose = await run('molbio_contamination_screen', { sequence: mutated, max_mismatches: 1 });
|
|
4377
|
+
const loosePuc = loose.hits.find((hit) => hit.name === 'pUC/pBR322 origin');
|
|
4378
|
+
assert.equal(loosePuc.best_mismatches, 1);
|
|
4379
|
+
assert.equal(loosePuc.example.mismatches, 1);
|
|
4380
|
+
|
|
4381
|
+
// FASTA/FASTQ inputs are scanned entry by entry, and max_reads caps the work.
|
|
4382
|
+
memFs.files.set(at('contam.fa'), '>a\n' + contaminated + '\n');
|
|
4383
|
+
const fromFasta = await run('molbio_contamination_screen', { path: at('contam.fa') });
|
|
4384
|
+
assert.equal(fromFasta.input_type, 'fasta');
|
|
4385
|
+
assert.equal(fromFasta.scanned_entries, 1);
|
|
4386
|
+
memFs.files.set(at('contam.fq'), '@a\n' + contaminated + '\n+\n' + 'I'.repeat(contaminated.length) + '\n');
|
|
4387
|
+
const fromFastq = await run('molbio_contamination_screen', { path: at('contam.fq') });
|
|
4388
|
+
assert.equal(fromFastq.input_type, 'fastq');
|
|
4389
|
+
assert.equal(fromFastq.hits.length, 2);
|
|
4390
|
+
const capped = await run('molbio_contamination_screen', { path: at('contam.fa'), max_reads: 1 });
|
|
4391
|
+
assert.equal(capped.scanned_entries, 1);
|
|
4392
|
+
assert.equal(capped.truncated, false);
|
|
4393
|
+
|
|
4394
|
+
await assert.rejects(() => run('molbio_contamination_screen', { sequence: contaminated, path: at('contam.fa') }), /exactly one/);
|
|
4395
|
+
await assert.rejects(() => run('molbio_contamination_screen', {}), /exactly one/);
|
|
4396
|
+
await assert.rejects(() => run('molbio_contamination_screen', { sequence: contaminated, kinds: 'plasmid' }), /must be one of/);
|
|
4397
|
+
|
|
4398
|
+
// ── FASTQ trim: known per-read outcomes ─────────────────────────────────
|
|
4399
|
+
const trimReadsInput = [
|
|
4400
|
+
'@r1', 'ACGT'.repeat(10), '+', 'I'.repeat(30) + '!'.repeat(10),
|
|
4401
|
+
'@r2', 'ACGT'.repeat(7) + 'AC' + 'AGATCGGAAGAG' + 'TTTTT', '+', 'I'.repeat(47),
|
|
4402
|
+
'@r3', 'ACGT'.repeat(5) + 'G'.repeat(10), '+', 'I'.repeat(30),
|
|
4403
|
+
'@r4', 'ACGT'.repeat(2) + 'AC', '+', 'I'.repeat(10),
|
|
4404
|
+
'',
|
|
4405
|
+
].join('\n');
|
|
4406
|
+
memFs.files.set(at('trim-in.fq'), trimReadsInput);
|
|
4407
|
+
const trim = await run('molbio_fastq_trim', {
|
|
4408
|
+
path: at('trim-in.fq'),
|
|
4409
|
+
output_path: at('trim-out.fq'),
|
|
4410
|
+
min_quality: 30,
|
|
4411
|
+
quality_window: 3,
|
|
4412
|
+
poly_g: true,
|
|
4413
|
+
adapter: 'AGATCGGAAGAG',
|
|
4414
|
+
min_length: 20,
|
|
4415
|
+
});
|
|
4416
|
+
assert.equal(trim.reads_in, 4);
|
|
4417
|
+
assert.equal(trim.reads_out, 3);
|
|
4418
|
+
assert.equal(trim.bases_in, 127);
|
|
4419
|
+
assert.equal(trim.bases_out, 80);
|
|
4420
|
+
assert.equal(trim.trimmed_reads, 3);
|
|
4421
|
+
assert.equal(trim.dropped_reads, 1);
|
|
4422
|
+
assert.deepEqual(trim.trim_counts, { quality: 1, adapter: 1, poly_g: 1, poly_a: 0, primer: 0 });
|
|
4423
|
+
assert.deepEqual(trim.drop_counts, { length: 1, n: 0 });
|
|
4424
|
+
assert.equal(trim.output_format, 'fastq');
|
|
4425
|
+
assert.equal(trim.saved_to, at('trim-out.fq'));
|
|
4426
|
+
const trimmedText = memFs.files.get(at('trim-out.fq'));
|
|
4427
|
+
assert.equal(trimmedText.split('\n').filter((line) => line.startsWith('@')).length, 3);
|
|
4428
|
+
const trimmedLines = trimmedText.trimEnd().split('\n');
|
|
4429
|
+
assert.equal(trimmedLines.length, 12, 'three four-line FASTQ records');
|
|
4430
|
+
for (let index = 0; index < trimmedLines.length; index += 4) {
|
|
4431
|
+
assert.equal(trimmedLines[index + 2], '+');
|
|
4432
|
+
assert.equal(trimmedLines[index + 1].length, trimmedLines[index + 3].length, 'quality length follows the trimmed sequence');
|
|
4433
|
+
}
|
|
4434
|
+
assert.ok(trimmedLines.includes('ACGTACGTACGTACGTACGT'), 'r3 lost exactly its 10-base poly-G tail');
|
|
4435
|
+
|
|
4436
|
+
const fastaTrim = await run('molbio_fastq_trim', {
|
|
4437
|
+
path: at('trim-in.fq'),
|
|
4438
|
+
output_path: at('trim-out.fa'),
|
|
4439
|
+
output_format: 'fasta',
|
|
4440
|
+
min_quality: 30,
|
|
4441
|
+
quality_window: 3,
|
|
4442
|
+
poly_g: true,
|
|
4443
|
+
adapter: 'AGATCGGAAGAG',
|
|
4444
|
+
min_length: 20,
|
|
4445
|
+
});
|
|
4446
|
+
assert.equal(fastaTrim.output_format, 'fasta');
|
|
4447
|
+
assert.ok(memFs.files.get(at('trim-out.fa')).startsWith('>r1\n'));
|
|
4448
|
+
|
|
4449
|
+
// Phred+64: 'h' is Q40 under that encoding; a high-quality read survives.
|
|
4450
|
+
memFs.files.set(at('trim64.fq'), '@p64\nACGTACGTACGT\n+\nhhhhhhhhhhhh\n');
|
|
4451
|
+
const phred64 = await run('molbio_fastq_trim', {
|
|
4452
|
+
path: at('trim64.fq'),
|
|
4453
|
+
output_path: at('trim64-out.fq'),
|
|
4454
|
+
phred: 64,
|
|
4455
|
+
min_quality: 30,
|
|
4456
|
+
quality_window: 3,
|
|
4457
|
+
});
|
|
4458
|
+
assert.equal(phred64.reads_out, 1);
|
|
4459
|
+
assert.equal(phred64.trim_counts.quality, 0);
|
|
4460
|
+
|
|
4461
|
+
await assert.rejects(() => run('molbio_fastq_trim', { path: at('trim-in.fq') }), /output_path/);
|
|
4462
|
+
await assert.rejects(() => run('molbio_fastq_trim', { path: at('seqs.fa'), output_path: at('x.fq') }), /FASTA/);
|
|
4463
|
+
await assert.rejects(() => run('molbio_fastq_trim', { path: at('trim-in.fq'), output_path: at('x.fq'), min_quality: 61 }), /min_quality/);
|
|
4464
|
+
await assert.rejects(() => run('molbio_fastq_trim', { path: at('trim-in.fq'), output_path: at('x.fq'), adapter: 'ACGTX' }), /invalid/);
|
|
4465
|
+
}
|
|
4466
|
+
|
|
4190
4467
|
// ── plugin surface ──────────────────────────────────────────────────────────
|
|
4191
4468
|
|
|
4192
4469
|
assert.equal(plugin.name, 'dsh-molbio-tools');
|