@datagrok/proteomics 1.0.1 → 1.2.1
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 +52 -3
- package/CLAUDE.md +178 -0
- package/README.md +195 -3
- package/detectors.js +152 -9
- package/dist/package-test.js +1 -1744
- package/dist/package-test.js.map +1 -1
- package/dist/package.js +1 -146
- package/dist/package.js.map +1 -1
- package/docs/personas-and-capabilities.md +165 -0
- package/files/demo/README.md +264 -0
- package/files/demo/cptac-spike-in.txt +1571 -0
- package/files/demo/enrichment-demo.csv +120 -0
- package/files/demo/fragpipe-smoke-test.tsv +11 -0
- package/files/demo/proteinGroups.txt +28 -0
- package/files/demo/spectronaut-hye-candidates.tsv +94 -0
- package/files/demo/spectronaut-hye-demo.tsv +8761 -0
- package/files/demo/spectronaut-hye-mix.tsv +8761 -0
- package/files/demo/spectronaut-hye-precursor-golden.json +938 -0
- package/files/demo/spectronaut-hye-precursor-golden.tsv +235 -0
- package/files/demo/spectronaut-hye-precursor.tsv +493 -0
- package/images/enrichment-crosslink.png +0 -0
- package/images/enrichment-term-selected.png +0 -0
- package/images/hero.png +0 -0
- package/images/pipeline.svg +80 -0
- package/package.json +88 -63
- package/scripts/deqms_de.R +60 -0
- package/scripts/limma_de.R +42 -0
- package/scripts/vsn_normalize.R +19 -0
- package/src/analysis/differential-expression.ts +450 -0
- package/src/analysis/enrichment-export.ts +101 -0
- package/src/analysis/enrichment.ts +602 -0
- package/src/analysis/experiment-setup.ts +199 -0
- package/src/analysis/imputation.ts +407 -0
- package/src/analysis/log2-scale.ts +139 -0
- package/src/analysis/normalization.ts +255 -0
- package/src/analysis/pca.ts +254 -0
- package/src/analysis/spc-storage.ts +515 -0
- package/src/analysis/spc.ts +544 -0
- package/src/analysis/subcellular-location.ts +431 -0
- package/src/demo/enrichment-demo.ts +94 -0
- package/src/demo/proteomics-demo.ts +123 -0
- package/src/global.d.ts +15 -0
- package/src/menu.ts +133 -0
- package/src/package-api.ts +136 -14
- package/src/package-test.ts +45 -20
- package/src/package.g.ts +161 -0
- package/src/package.ts +1029 -17
- package/src/panels/protein-focus.ts +63 -0
- package/src/panels/published-analysis-panel.ts +151 -0
- package/src/panels/uniprot-panel.ts +349 -0
- package/src/parsers/fragpipe-parser.ts +200 -0
- package/src/parsers/generic-parser.ts +197 -0
- package/src/parsers/maxquant-parser.ts +162 -0
- package/src/parsers/shared-utils.ts +163 -0
- package/src/parsers/spectronaut-candidates-parser.ts +307 -0
- package/src/parsers/spectronaut-parser.ts +604 -0
- package/src/publishing/assert-published-shape.ts +260 -0
- package/src/publishing/post-open-recovery.ts +104 -0
- package/src/publishing/publish-project.ts +515 -0
- package/src/publishing/publish-settings.ts +59 -0
- package/src/publishing/publish-state.ts +316 -0
- package/src/publishing/share-dialog.ts +171 -0
- package/src/publishing/trim-dataframe.ts +247 -0
- package/src/tests/analysis.ts +658 -0
- package/src/tests/enrichment-export.ts +61 -0
- package/src/tests/enrichment-visualization.ts +340 -0
- package/src/tests/enrichment.ts +224 -0
- package/src/tests/fragpipe-e2e.ts +74 -0
- package/src/tests/fragpipe-parser.ts +147 -0
- package/src/tests/gene-label-resolver.ts +387 -0
- package/src/tests/generic-parser.ts +152 -0
- package/src/tests/group-mean-correlation.ts +139 -0
- package/src/tests/log2-scale.ts +93 -0
- package/src/tests/organisms.ts +56 -0
- package/src/tests/parsers.ts +182 -0
- package/src/tests/publish-roundtrip.ts +584 -0
- package/src/tests/publish-spike.ts +377 -0
- package/src/tests/qc-dashboard.ts +210 -0
- package/src/tests/smart-pathway-filter.ts +193 -0
- package/src/tests/spc-formula-lines-spike.ts +129 -0
- package/src/tests/spc.ts +640 -0
- package/src/tests/spectronaut-candidates-e2e.ts +140 -0
- package/src/tests/spectronaut-candidates-parser.ts +398 -0
- package/src/tests/spectronaut-parser.ts +668 -0
- package/src/tests/subcellular-location.ts +361 -0
- package/src/tests/uniprot-panel.ts +202 -0
- package/src/tests/volcano.ts +603 -0
- package/src/utils/column-detection.ts +28 -0
- package/src/utils/gene-label-resolver.ts +443 -0
- package/src/utils/organisms.ts +82 -0
- package/src/utils/proteomics-types.ts +30 -0
- package/src/viewers/enrichment-viewers.ts +274 -0
- package/src/viewers/group-mean-correlation.ts +218 -0
- package/src/viewers/heatmap.ts +168 -0
- package/src/viewers/pca-plot.ts +169 -0
- package/src/viewers/qc-computations.ts +266 -0
- package/src/viewers/qc-dashboard.ts +176 -0
- package/src/viewers/spc-dashboard.ts +755 -0
- package/src/viewers/volcano.ts +690 -0
- package/test-console-output-1.log +2073 -0
- package/test-record-1.mp4 +0 -0
- package/tools/derive-precursor-golden-sidecar.mjs +81 -0
- package/tools/generate-enrichment-fixture.sh +160 -0
- package/tools/generate-spectronaut-candidates-fixture.mjs +212 -0
- package/tools/generate-spectronaut-precursor-fixture.mjs +128 -0
- package/tools/spectronaut-aggregate.sh +46 -0
- package/tools/spectronaut-aggregate.sql +80 -0
- package/tsconfig.json +18 -71
- package/webpack.config.js +86 -45
- package/.eslintignore +0 -1
- package/.eslintrc.json +0 -56
- package/LICENSE +0 -674
- package/package.png +0 -0
- package/scripts/number_antibody.py +0 -190
- package/scripts/number_antibody_abnumber.py +0 -177
- package/scripts/number_antibody_anarci.py +0 -200
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {category, test, expect} from '@datagrok-libraries/test/src/test';
|
|
2
|
+
import {parseFragPipeText} from '../parsers/fragpipe-parser';
|
|
3
|
+
import {setGroups} from '../analysis/experiment-setup';
|
|
4
|
+
import {medianNormalize} from '../analysis/normalization';
|
|
5
|
+
import {imputeMean} from '../analysis/imputation';
|
|
6
|
+
import {runDifferentialExpression} from '../analysis/differential-expression';
|
|
7
|
+
import {SEMTYPE} from '../utils/proteomics-types';
|
|
8
|
+
import {_package} from '../package-test';
|
|
9
|
+
|
|
10
|
+
const FIXTURE_PATH = 'demo/fragpipe-smoke-test.tsv';
|
|
11
|
+
|
|
12
|
+
const GROUPS = {
|
|
13
|
+
group1: {name: 'Control', columns: ['log2(Ctrl_1 MaxLFQ Intensity)', 'log2(Ctrl_2 MaxLFQ Intensity)']},
|
|
14
|
+
group2: {name: 'Treatment', columns: ['log2(Treat_1 MaxLFQ Intensity)', 'log2(Treat_2 MaxLFQ Intensity)']},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const UP_GENES = ['TP53', 'AKT1', 'F2'];
|
|
18
|
+
const DOWN_GENES = ['BRCA1', 'PRKCD'];
|
|
19
|
+
const UNCHANGED_GENES = ['EGFR', 'MTOR', 'HRAS'];
|
|
20
|
+
|
|
21
|
+
category('FragPipe E2E', () => {
|
|
22
|
+
test('full pipeline (parse → normalize → impute → DE) on fragpipe-smoke-test.tsv', async () => {
|
|
23
|
+
const text = await _package.files.readAsText(FIXTURE_PATH);
|
|
24
|
+
const df = await parseFragPipeText(text);
|
|
25
|
+
|
|
26
|
+
expect(df.rowCount, 8);
|
|
27
|
+
expect(df.getTag('proteomics.source'), 'fragpipe');
|
|
28
|
+
expect(df.col('Protein ID')!.semType, SEMTYPE.PROTEIN_ID);
|
|
29
|
+
expect(df.col('Gene')!.semType, SEMTYPE.GENE_SYMBOL);
|
|
30
|
+
expect(df.col('log2(Ctrl_1 MaxLFQ Intensity)') !== null, true);
|
|
31
|
+
expect(df.col('log2(Treat_2 MaxLFQ Intensity)') !== null, true);
|
|
32
|
+
|
|
33
|
+
setGroups(df, GROUPS);
|
|
34
|
+
expect(df.getTag('proteomics.groups') !== null, true);
|
|
35
|
+
|
|
36
|
+
const log2Cols = [...GROUPS.group1.columns, ...GROUPS.group2.columns];
|
|
37
|
+
medianNormalize(df, log2Cols);
|
|
38
|
+
expect(df.getTag('proteomics.normalized'), 'true');
|
|
39
|
+
for (const name of log2Cols)
|
|
40
|
+
expect(Math.abs(df.col(name)!.stats.med) < 1e-4, true);
|
|
41
|
+
|
|
42
|
+
imputeMean(df, log2Cols);
|
|
43
|
+
expect(df.getTag('proteomics.imputed'), 'true');
|
|
44
|
+
|
|
45
|
+
runDifferentialExpression(df, GROUPS.group1.columns, GROUPS.group2.columns,
|
|
46
|
+
'Control', 'Treatment', 1.0, 0.05);
|
|
47
|
+
expect(df.getTag('proteomics.de_complete'), 'true');
|
|
48
|
+
expect(df.col('log2FC') !== null, true);
|
|
49
|
+
expect(df.col('p-value') !== null, true);
|
|
50
|
+
expect(df.col('adj.p-value') !== null, true);
|
|
51
|
+
expect(df.col('significant') !== null, true);
|
|
52
|
+
|
|
53
|
+
const geneRow: Record<string, number> = {};
|
|
54
|
+
const geneCol = df.col('Gene')!;
|
|
55
|
+
for (let i = 0; i < df.rowCount; i++)
|
|
56
|
+
geneRow[geneCol.get(i) as string] = i;
|
|
57
|
+
|
|
58
|
+
const fc = df.col('log2FC')!;
|
|
59
|
+
const sig = df.col('significant')!;
|
|
60
|
+
|
|
61
|
+
for (const g of UP_GENES) {
|
|
62
|
+
expect(fc.get(geneRow[g]) > 1.0, true);
|
|
63
|
+
expect(sig.get(geneRow[g]), true);
|
|
64
|
+
}
|
|
65
|
+
for (const g of DOWN_GENES) {
|
|
66
|
+
expect(fc.get(geneRow[g]) < -1.0, true);
|
|
67
|
+
expect(sig.get(geneRow[g]), true);
|
|
68
|
+
}
|
|
69
|
+
for (const g of UNCHANGED_GENES) {
|
|
70
|
+
expect(Math.abs(fc.get(geneRow[g])) < 0.5, true);
|
|
71
|
+
expect(sig.get(geneRow[g]), false);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import {category, test, expect} from '@datagrok-libraries/test/src/test';
|
|
2
|
+
import {parseFragPipeText} from '../parsers/fragpipe-parser';
|
|
3
|
+
import {SEMTYPE} from '../utils/proteomics-types';
|
|
4
|
+
|
|
5
|
+
/** Builds a minimal FragPipe combined_protein.tsv string. */
|
|
6
|
+
function makeTsv(rows: string[][], headers: string[]): string {
|
|
7
|
+
return [headers.join('\t'), ...rows.map((r) => r.join('\t'))].join('\n');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const BASE_HEADERS = [
|
|
11
|
+
'Protein', 'Protein ID', 'Entry Name', 'Gene', 'Length', 'Organism', 'Protein Description',
|
|
12
|
+
'Sample1 Spectral Count', 'Sample1 Intensity', 'Sample1 MaxLFQ Intensity',
|
|
13
|
+
'Sample2 Spectral Count', 'Sample2 Intensity', 'Sample2 MaxLFQ Intensity',
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
function baseRow(proteinId: string, gene: string,
|
|
17
|
+
s1Int: string, s1Lfq: string, s2Int: string, s2Lfq: string): string[] {
|
|
18
|
+
return [
|
|
19
|
+
`sp|${proteinId}|${gene}_HUMAN`, proteinId, `${gene}_HUMAN`, gene, '500', 'Homo sapiens', `${gene} protein`,
|
|
20
|
+
'12', s1Int, s1Lfq, '15', s2Int, s2Lfq,
|
|
21
|
+
];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
category('FragPipe', () => {
|
|
25
|
+
test('parses standard rows', async () => {
|
|
26
|
+
const tsv = makeTsv([
|
|
27
|
+
baseRow('P12345', 'BRCA1', '10000', '12000', '20000', '24000'),
|
|
28
|
+
baseRow('Q67890', 'TP53', '30000', '36000', '40000', '48000'),
|
|
29
|
+
], BASE_HEADERS);
|
|
30
|
+
const df = await parseFragPipeText(tsv);
|
|
31
|
+
expect(df.rowCount, 2);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('filters contam_ rows', async () => {
|
|
35
|
+
const tsv = makeTsv([
|
|
36
|
+
baseRow('P12345', 'BRCA1', '10000', '12000', '20000', '24000'),
|
|
37
|
+
baseRow('contam_P99999', 'KRT1', '30000', '36000', '40000', '48000'),
|
|
38
|
+
], BASE_HEADERS);
|
|
39
|
+
const df = await parseFragPipeText(tsv);
|
|
40
|
+
expect(df.rowCount, 1);
|
|
41
|
+
expect(df.col('Protein ID')!.get(0), 'P12345');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('filters rev_ rows', async () => {
|
|
45
|
+
const tsv = makeTsv([
|
|
46
|
+
baseRow('P12345', 'BRCA1', '10000', '12000', '20000', '24000'),
|
|
47
|
+
baseRow('rev_P99999', 'DECOY', '30000', '36000', '40000', '48000'),
|
|
48
|
+
], BASE_HEADERS);
|
|
49
|
+
const df = await parseFragPipeText(tsv);
|
|
50
|
+
expect(df.rowCount, 1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('filters CON__/REV__ rows for MaxQuant-style FASTAs', async () => {
|
|
54
|
+
const tsv = makeTsv([
|
|
55
|
+
baseRow('P12345', 'BRCA1', '10000', '12000', '20000', '24000'),
|
|
56
|
+
baseRow('CON__P99999', 'KRT1', '30000', '36000', '40000', '48000'),
|
|
57
|
+
baseRow('REV__P88888', 'FAKE1', '50000', '60000', '70000', '80000'),
|
|
58
|
+
], BASE_HEADERS);
|
|
59
|
+
const df = await parseFragPipeText(tsv);
|
|
60
|
+
expect(df.rowCount, 1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('assigns semantic types', async () => {
|
|
64
|
+
const tsv = makeTsv([
|
|
65
|
+
baseRow('P12345', 'BRCA1', '10000', '12000', '20000', '24000'),
|
|
66
|
+
], BASE_HEADERS);
|
|
67
|
+
const df = await parseFragPipeText(tsv);
|
|
68
|
+
expect(df.col('Protein ID')?.semType, SEMTYPE.PROTEIN_ID);
|
|
69
|
+
expect(df.col('Gene')?.semType, SEMTYPE.GENE_SYMBOL);
|
|
70
|
+
// Only the chosen primary quant (MaxLFQ) gets SEMTYPE.INTENSITY. Redundant
|
|
71
|
+
// bare-Intensity / Razor columns are intentionally NOT tagged so they don't
|
|
72
|
+
// show up in downstream intensity-column pickers.
|
|
73
|
+
expect(df.col('Sample1 MaxLFQ Intensity')?.semType, SEMTYPE.INTENSITY);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('prefers MaxLFQ for log2 transform', async () => {
|
|
77
|
+
const tsv = makeTsv([
|
|
78
|
+
baseRow('P12345', 'BRCA1', '1024', '2048', '4096', '8192'),
|
|
79
|
+
], BASE_HEADERS);
|
|
80
|
+
const df = await parseFragPipeText(tsv);
|
|
81
|
+
// MaxLFQ columns get log2-transformed; bare Intensity ones do not
|
|
82
|
+
expect(df.col('log2(Sample1 MaxLFQ Intensity)') !== null, true);
|
|
83
|
+
expect(df.col('log2(Sample2 MaxLFQ Intensity)') !== null, true);
|
|
84
|
+
expect(df.col('log2(Sample1 Intensity)'), null);
|
|
85
|
+
const log2Col = df.col('log2(Sample1 MaxLFQ Intensity)')!;
|
|
86
|
+
expect(Math.abs(log2Col.get(0)! - 11.0) < 0.001, true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('falls back to bare Intensity when no MaxLFQ present', async () => {
|
|
90
|
+
const headers = [
|
|
91
|
+
'Protein', 'Protein ID', 'Gene',
|
|
92
|
+
'Sample1 Spectral Count', 'Sample1 Intensity',
|
|
93
|
+
'Sample2 Spectral Count', 'Sample2 Intensity',
|
|
94
|
+
];
|
|
95
|
+
const tsv = makeTsv([
|
|
96
|
+
['sp|P12345|BRCA1_HUMAN', 'P12345', 'BRCA1', '12', '1024', '15', '4096'],
|
|
97
|
+
], headers);
|
|
98
|
+
const df = await parseFragPipeText(tsv);
|
|
99
|
+
expect(df.col('log2(Sample1 Intensity)') !== null, true);
|
|
100
|
+
expect(df.col('log2(Sample2 Intensity)') !== null, true);
|
|
101
|
+
const log2Col = df.col('log2(Sample1 Intensity)')!;
|
|
102
|
+
expect(Math.abs(log2Col.get(0)! - 10.0) < 0.001, true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('zero intensity produces FLOAT_NULL in log2', async () => {
|
|
106
|
+
const tsv = makeTsv([
|
|
107
|
+
baseRow('P12345', 'BRCA1', '0', '0', '4096', '8192'),
|
|
108
|
+
], BASE_HEADERS);
|
|
109
|
+
const df = await parseFragPipeText(tsv);
|
|
110
|
+
const log2Col = df.col('log2(Sample1 MaxLFQ Intensity)')!;
|
|
111
|
+
expect(log2Col.isNone(0), true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('primary protein ID extracted from semicolon-delimited', async () => {
|
|
115
|
+
const tsv = makeTsv([
|
|
116
|
+
['sp|P12345|BRCA1_HUMAN', 'P12345;Q67890', 'BRCA1_HUMAN', 'BRCA1;BRCA2', '500',
|
|
117
|
+
'Homo sapiens', 'BRCA1 protein',
|
|
118
|
+
'12', '10000', '12000', '15', '20000', '24000'],
|
|
119
|
+
], BASE_HEADERS);
|
|
120
|
+
const df = await parseFragPipeText(tsv);
|
|
121
|
+
const primaryId = df.col('Primary Protein ID');
|
|
122
|
+
expect(primaryId !== null, true);
|
|
123
|
+
if (primaryId)
|
|
124
|
+
expect(primaryId.get(0), 'P12345');
|
|
125
|
+
const primaryGene = df.col('Primary Gene Name');
|
|
126
|
+
expect(primaryGene !== null, true);
|
|
127
|
+
if (primaryGene)
|
|
128
|
+
expect(primaryGene.get(0), 'BRCA1');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('proteomics.source tag set to fragpipe', async () => {
|
|
132
|
+
const tsv = makeTsv([
|
|
133
|
+
baseRow('P12345', 'BRCA1', '10000', '12000', '20000', '24000'),
|
|
134
|
+
], BASE_HEADERS);
|
|
135
|
+
const df = await parseFragPipeText(tsv);
|
|
136
|
+
expect(df.getTag('proteomics.source'), 'fragpipe');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('intensity columns survive as raw plus log2 pair', async () => {
|
|
140
|
+
const tsv = makeTsv([
|
|
141
|
+
baseRow('P12345', 'BRCA1', '10000', '12000', '20000', '24000'),
|
|
142
|
+
], BASE_HEADERS);
|
|
143
|
+
const df = await parseFragPipeText(tsv);
|
|
144
|
+
expect(df.col('Sample1 MaxLFQ Intensity') !== null, true);
|
|
145
|
+
expect(df.col('log2(Sample1 MaxLFQ Intensity)') !== null, true);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import * as DG from 'datagrok-api/dg';
|
|
2
|
+
import * as grok from 'datagrok-api/grok';
|
|
3
|
+
import {category, test, expect} from '@datagrok-libraries/test/src/test';
|
|
4
|
+
import {SEMTYPE} from '../utils/proteomics-types';
|
|
5
|
+
import {
|
|
6
|
+
resolveGeneLabels, STORE_GENE_LABELS, SCHEMA_V_GENE_LABELS,
|
|
7
|
+
detectSpecies, isEnsemblEligible, isPredicted,
|
|
8
|
+
extractReadableDescription, applyMarkerRules,
|
|
9
|
+
lookupEnsemblBatch, type EnsemblEntry,
|
|
10
|
+
} from '../utils/gene-label-resolver';
|
|
11
|
+
import {parseMaxQuantText} from '../parsers/maxquant-parser';
|
|
12
|
+
import {parseFragPipeText} from '../parsers/fragpipe-parser';
|
|
13
|
+
import {parseSpectronautCandidatesText} from '../parsers/spectronaut-candidates-parser';
|
|
14
|
+
import {parseSpectronautText} from '../parsers/spectronaut-parser';
|
|
15
|
+
|
|
16
|
+
/** Build a fake Response that the test mock for grok.dapi.fetchProxy can return. */
|
|
17
|
+
function makeResponse(body: any, opts: {status?: number; retryAfter?: string} = {}): Response {
|
|
18
|
+
const status = opts.status ?? 200;
|
|
19
|
+
const headers = new Headers();
|
|
20
|
+
if (opts.retryAfter != null) headers.set('Retry-After', opts.retryAfter);
|
|
21
|
+
// node-fetch / browser Response constructors accept (BodyInit, ResponseInit).
|
|
22
|
+
return new Response(body == null ? null : JSON.stringify(body), {status, headers});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Replaces grok.dapi.fetchProxy with `fn` for the duration of `run`, then
|
|
26
|
+
* restores the original. Patches BOTH the dapi instance (own property) and its
|
|
27
|
+
* shared prototype: older Datagrok exposes `fetchProxy` on the prototype, while
|
|
28
|
+
* newer builds define it as an own/bound property on the instance — where a
|
|
29
|
+
* prototype-only patch is invisible to the source's `grok.dapi.fetchProxy`
|
|
30
|
+
* call and the real network gets hit (CI "latest" failure mode). Patching both
|
|
31
|
+
* covers either shape. (See project memory reference_dapi_fresh_instance_patch.) */
|
|
32
|
+
async function withFetchProxy(
|
|
33
|
+
fn: (url: string, init?: RequestInit) => Promise<Response>,
|
|
34
|
+
run: () => Promise<void>,
|
|
35
|
+
): Promise<void> {
|
|
36
|
+
const dapi: any = grok.dapi;
|
|
37
|
+
const proto = Object.getPrototypeOf(dapi);
|
|
38
|
+
const hadOwn = Object.prototype.hasOwnProperty.call(dapi, 'fetchProxy');
|
|
39
|
+
const origOwn = hadOwn ? dapi.fetchProxy : undefined;
|
|
40
|
+
const origProto = proto.fetchProxy;
|
|
41
|
+
dapi.fetchProxy = fn;
|
|
42
|
+
proto.fetchProxy = fn;
|
|
43
|
+
try {
|
|
44
|
+
await run();
|
|
45
|
+
} finally {
|
|
46
|
+
if (hadOwn) dapi.fetchProxy = origOwn;
|
|
47
|
+
else delete dapi.fetchProxy;
|
|
48
|
+
proto.fetchProxy = origProto;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Replaces grok.dapi.userDataStorage methods for the duration of `run`. */
|
|
53
|
+
async function withUserDataStorage(
|
|
54
|
+
store: Map<string, any>,
|
|
55
|
+
run: () => Promise<void>,
|
|
56
|
+
): Promise<void> {
|
|
57
|
+
const uds: any = grok.dapi.userDataStorage;
|
|
58
|
+
const proto = Object.getPrototypeOf(uds);
|
|
59
|
+
const origGet = proto.get;
|
|
60
|
+
const origPut = proto.put;
|
|
61
|
+
proto.get = async (key: string) => store.get(key) ?? null;
|
|
62
|
+
proto.put = async (key: string, value: any) => { store.set(key, value); };
|
|
63
|
+
try {
|
|
64
|
+
await run();
|
|
65
|
+
} finally {
|
|
66
|
+
proto.get = origGet;
|
|
67
|
+
proto.put = origPut;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Replaces grok.shell.warning with a no-op for the duration of `run` and
|
|
72
|
+
* returns the captured warnings. */
|
|
73
|
+
async function captureWarnings(run: () => Promise<void>): Promise<string[]> {
|
|
74
|
+
const captured: string[] = [];
|
|
75
|
+
const shell: any = grok.shell;
|
|
76
|
+
const original = shell.warning;
|
|
77
|
+
shell.warning = (msg: string) => { captured.push(msg); };
|
|
78
|
+
try {
|
|
79
|
+
await run();
|
|
80
|
+
} finally {
|
|
81
|
+
shell.warning = original;
|
|
82
|
+
}
|
|
83
|
+
return captured;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
category('Proteomics: 14-01', () => {
|
|
87
|
+
test('geneLabelResolverSemTypes: SEMTYPE constants are the locked strings', async () => {
|
|
88
|
+
expect(SEMTYPE.DISPLAY_NAME, 'Proteomics-DisplayName');
|
|
89
|
+
expect(SEMTYPE.SOURCE_ID, 'Proteomics-SourceId');
|
|
90
|
+
expect(SEMTYPE.NUMERATOR_MEAN, 'Proteomics-NumeratorMean');
|
|
91
|
+
expect(SEMTYPE.DENOMINATOR_MEAN, 'Proteomics-DenominatorMean');
|
|
92
|
+
const all = new Set([
|
|
93
|
+
SEMTYPE.DISPLAY_NAME, SEMTYPE.SOURCE_ID,
|
|
94
|
+
SEMTYPE.NUMERATOR_MEAN, SEMTYPE.DENOMINATOR_MEAN,
|
|
95
|
+
SEMTYPE.PROTEIN_ID, SEMTYPE.GENE_SYMBOL, SEMTYPE.INTENSITY,
|
|
96
|
+
SEMTYPE.LOG2FC, SEMTYPE.P_VALUE, SEMTYPE.SUBCELLULAR_LOCATION,
|
|
97
|
+
]);
|
|
98
|
+
expect(all.size, 10);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('geneLabelResolverDetectorMirror: SEMTYPE values are valid Column.semType strings', async () => {
|
|
102
|
+
const df = DG.DataFrame.fromColumns([
|
|
103
|
+
DG.Column.fromStrings('Display Name', ['Myh7†', 'Tnnt3']),
|
|
104
|
+
DG.Column.fromStrings('Source ID', ['ENSRNOG0001', '']),
|
|
105
|
+
DG.Column.fromFloat32Array('Numerator Mean', new Float32Array([1.0, 2.0])),
|
|
106
|
+
DG.Column.fromFloat32Array('Denominator Mean', new Float32Array([1.5, 2.5])),
|
|
107
|
+
]);
|
|
108
|
+
df.col('Display Name')!.semType = SEMTYPE.DISPLAY_NAME;
|
|
109
|
+
df.col('Source ID')!.semType = SEMTYPE.SOURCE_ID;
|
|
110
|
+
df.col('Numerator Mean')!.semType = SEMTYPE.NUMERATOR_MEAN;
|
|
111
|
+
df.col('Denominator Mean')!.semType = SEMTYPE.DENOMINATOR_MEAN;
|
|
112
|
+
expect(df.col('Display Name')!.semType, 'Proteomics-DisplayName');
|
|
113
|
+
expect(df.col('Source ID')!.semType, 'Proteomics-SourceId');
|
|
114
|
+
expect(df.col('Numerator Mean')!.semType, 'Proteomics-NumeratorMean');
|
|
115
|
+
expect(df.col('Denominator Mean')!.semType, 'Proteomics-DenominatorMean');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('detectSpecies: maps prefixes per CK-omics species table', async () => {
|
|
119
|
+
expect(detectSpecies('ENSG00000123'), 'homo_sapiens');
|
|
120
|
+
expect(detectSpecies('ENSMUSG00000456'), 'mus_musculus');
|
|
121
|
+
expect(detectSpecies('MGP_C57BL6NJ_G0000001'), 'mus_musculus');
|
|
122
|
+
expect(detectSpecies('ENSRNOG00000789'), 'rattus_norvegicus');
|
|
123
|
+
expect(detectSpecies('ENSRNO00000001'), 'rattus_norvegicus');
|
|
124
|
+
expect(detectSpecies('ENSDARG00000222'), 'danio_rerio');
|
|
125
|
+
expect(detectSpecies('LOC123456'), null);
|
|
126
|
+
expect(detectSpecies('RGD7890'), null);
|
|
127
|
+
expect(detectSpecies('AABR07012345'), null);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('isEnsemblEligible: filters LOC/RGD/AABR per CK-omics line 933', async () => {
|
|
131
|
+
expect(isEnsemblEligible('ENSG00000001'), true);
|
|
132
|
+
expect(isEnsemblEligible('ENSMUSG00000123'), true);
|
|
133
|
+
expect(isEnsemblEligible('ENSRNOG00000456'), true);
|
|
134
|
+
expect(isEnsemblEligible('ENSDARG00000789'), true);
|
|
135
|
+
expect(isEnsemblEligible('MGP_C57BL6NJ_G0000001'), true);
|
|
136
|
+
expect(isEnsemblEligible('LOC123456'), false);
|
|
137
|
+
expect(isEnsemblEligible('RGD789'), false);
|
|
138
|
+
expect(isEnsemblEligible('AABR07012345'), false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('isPredicted: locked CK-omics prefix list', async () => {
|
|
142
|
+
expect(isPredicted('ENSRNOG00000001'), true);
|
|
143
|
+
expect(isPredicted('LOC987'), true);
|
|
144
|
+
expect(isPredicted('AABR07'), true);
|
|
145
|
+
expect(isPredicted('Myh7'), false);
|
|
146
|
+
expect(isPredicted('Tnnt3'), false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('extractReadableDescription: strips Predicted-to prefixes and species suffixes', async () => {
|
|
150
|
+
expect(extractReadableDescription('Predicted to enable actin binding [Rattus norvegicus]'),
|
|
151
|
+
'Actin binding');
|
|
152
|
+
expect(extractReadableDescription('Predicted to be involved in transport. Additional sentence.'),
|
|
153
|
+
'Transport');
|
|
154
|
+
expect(extractReadableDescription('Myosin heavy chain_RAT'), 'Myosin heavy chain');
|
|
155
|
+
expect(extractReadableDescription(''), null);
|
|
156
|
+
expect(extractReadableDescription(null), null);
|
|
157
|
+
expect(extractReadableDescription('uncharacterized protein'), null);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('applyMarkerRules: appends * and † per CK-omics line 1011', async () => {
|
|
161
|
+
expect(applyMarkerRules('Myh7', false, true), 'Myh7†');
|
|
162
|
+
expect(applyMarkerRules('Myh7', true, true), 'Myh7*†');
|
|
163
|
+
expect(applyMarkerRules('LOC123', false, true), 'LOC123†');
|
|
164
|
+
expect(applyMarkerRules('Tnnt3', false, false), 'Tnnt3');
|
|
165
|
+
expect(applyMarkerRules('Tnnt3', true, false), 'Tnnt3*');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('lookupEnsemblBatch: POSTs ids and parses response', async () => {
|
|
169
|
+
let capturedUrl = '';
|
|
170
|
+
let capturedInit: RequestInit | undefined;
|
|
171
|
+
await withFetchProxy(
|
|
172
|
+
async (url, init) => {
|
|
173
|
+
capturedUrl = url;
|
|
174
|
+
capturedInit = init;
|
|
175
|
+
return makeResponse({
|
|
176
|
+
'ENSRNOG00000001': {external_name: 'Myh7', description: 'Myosin heavy chain 7'},
|
|
177
|
+
'ENSRNOG00000002': null,
|
|
178
|
+
});
|
|
179
|
+
},
|
|
180
|
+
async () => {
|
|
181
|
+
const out = await lookupEnsemblBatch(['ENSRNOG00000001', 'ENSRNOG00000002']);
|
|
182
|
+
expect(capturedUrl, 'https://rest.ensembl.org/lookup/id');
|
|
183
|
+
expect(capturedInit?.method, 'POST');
|
|
184
|
+
const body = JSON.parse((capturedInit?.body as string) ?? '{}');
|
|
185
|
+
expect(Array.isArray(body.ids), true);
|
|
186
|
+
expect(body.ids.length, 2);
|
|
187
|
+
expect(out.size, 1);
|
|
188
|
+
expect(out.get('ENSRNOG00000001')?.external_name, 'Myh7');
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('lookupEnsemblBatch: retries once on 429 with Retry-After', async () => {
|
|
194
|
+
let calls = 0;
|
|
195
|
+
await withFetchProxy(
|
|
196
|
+
async () => {
|
|
197
|
+
calls++;
|
|
198
|
+
if (calls === 1) return makeResponse(null, {status: 429, retryAfter: '0'});
|
|
199
|
+
return makeResponse({'ENSG00000001': {external_name: 'TP53'}});
|
|
200
|
+
},
|
|
201
|
+
async () => {
|
|
202
|
+
const out = await lookupEnsemblBatch(['ENSG00000001']);
|
|
203
|
+
expect(calls, 2);
|
|
204
|
+
expect(out.get('ENSG00000001')?.external_name, 'TP53');
|
|
205
|
+
},
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('resolveGeneLabels: no-op path still creates Display Name + Source ID columns', async () => {
|
|
210
|
+
const df = DG.DataFrame.fromColumns([
|
|
211
|
+
DG.Column.fromStrings('Protein ID', ['P12345', 'Q67890', 'O11111']),
|
|
212
|
+
DG.Column.fromStrings('Gene name', ['Myh7', 'Tnnt3', 'Actb']),
|
|
213
|
+
]);
|
|
214
|
+
df.col('Gene name')!.semType = SEMTYPE.GENE_SYMBOL;
|
|
215
|
+
df.col('Protein ID')!.semType = SEMTYPE.PROTEIN_ID;
|
|
216
|
+
await withFetchProxy(async () => { throw new Error('should not fetch'); }, async () => {
|
|
217
|
+
await resolveGeneLabels(df);
|
|
218
|
+
});
|
|
219
|
+
const dn = df.col('Display Name');
|
|
220
|
+
const sid = df.col('Source ID');
|
|
221
|
+
expect(dn != null, true);
|
|
222
|
+
expect(sid != null, true);
|
|
223
|
+
expect(dn!.semType, 'Proteomics-DisplayName');
|
|
224
|
+
expect(sid!.semType, 'Proteomics-SourceId');
|
|
225
|
+
expect(dn!.get(0), 'Myh7');
|
|
226
|
+
expect(dn!.get(1), 'Tnnt3');
|
|
227
|
+
expect(sid!.get(0), '');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('resolveGeneLabels: LOC/RGD/AABR keep raw ID + †, never sent to Ensembl', async () => {
|
|
231
|
+
const df = DG.DataFrame.fromColumns([
|
|
232
|
+
DG.Column.fromStrings('Gene name', ['LOC123', 'RGD456', 'AABR789']),
|
|
233
|
+
]);
|
|
234
|
+
df.col('Gene name')!.semType = SEMTYPE.GENE_SYMBOL;
|
|
235
|
+
let postCount = 0;
|
|
236
|
+
await withUserDataStorage(new Map(), async () => {
|
|
237
|
+
await withFetchProxy(
|
|
238
|
+
async () => { postCount++; return makeResponse({}); },
|
|
239
|
+
async () => {
|
|
240
|
+
await resolveGeneLabels(df);
|
|
241
|
+
},
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
expect(postCount, 0);
|
|
245
|
+
expect(df.col('Display Name')!.get(0), 'LOC123†');
|
|
246
|
+
expect(df.col('Display Name')!.get(1), 'RGD456†');
|
|
247
|
+
expect(df.col('Display Name')!.get(2), 'AABR789†');
|
|
248
|
+
expect(df.col('Source ID')!.get(0), 'LOC123');
|
|
249
|
+
expect(df.col('Source ID')!.get(1), 'RGD456');
|
|
250
|
+
expect(df.col('Source ID')!.get(2), 'AABR789');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('resolveGeneLabels: disambiguates duplicates with (Source ID) suffix and warns', async () => {
|
|
254
|
+
const df = DG.DataFrame.fromColumns([
|
|
255
|
+
DG.Column.fromStrings('Gene name', ['ENSRNOG00000001', 'ENSRNOG00000002', 'Actb']),
|
|
256
|
+
]);
|
|
257
|
+
df.col('Gene name')!.semType = SEMTYPE.GENE_SYMBOL;
|
|
258
|
+
const warnings = await captureWarnings(async () => {
|
|
259
|
+
await withUserDataStorage(new Map(), async () => {
|
|
260
|
+
await withFetchProxy(
|
|
261
|
+
async () => makeResponse({
|
|
262
|
+
'ENSRNOG00000001': {external_name: 'Myh7'},
|
|
263
|
+
'ENSRNOG00000002': {external_name: 'Myh7'},
|
|
264
|
+
}),
|
|
265
|
+
async () => {
|
|
266
|
+
await resolveGeneLabels(df);
|
|
267
|
+
},
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
expect(df.col('Display Name')!.get(0), 'Myh7† (ENSRNOG00000001)');
|
|
272
|
+
expect(df.col('Display Name')!.get(1), 'Myh7† (ENSRNOG00000002)');
|
|
273
|
+
expect(df.col('Display Name')!.get(2), 'Actb');
|
|
274
|
+
const dupWarn = warnings.find((w) => w.includes('duplicate gene names'));
|
|
275
|
+
expect(dupWarn != null, true);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('resolveGeneLabels: cache short-circuits second call', async () => {
|
|
279
|
+
const store = new Map<string, any>();
|
|
280
|
+
let postCount = 0;
|
|
281
|
+
const mockFetch = async () => {
|
|
282
|
+
postCount++;
|
|
283
|
+
return makeResponse({'ENSRNOG00000001': {external_name: 'Myh7'}});
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const df1 = DG.DataFrame.fromColumns([
|
|
287
|
+
DG.Column.fromStrings('Gene name', ['ENSRNOG00000001']),
|
|
288
|
+
]);
|
|
289
|
+
df1.col('Gene name')!.semType = SEMTYPE.GENE_SYMBOL;
|
|
290
|
+
|
|
291
|
+
const df2 = DG.DataFrame.fromColumns([
|
|
292
|
+
DG.Column.fromStrings('Gene name', ['ENSRNOG00000001']),
|
|
293
|
+
]);
|
|
294
|
+
df2.col('Gene name')!.semType = SEMTYPE.GENE_SYMBOL;
|
|
295
|
+
|
|
296
|
+
await withUserDataStorage(store, async () => {
|
|
297
|
+
await withFetchProxy(mockFetch, async () => {
|
|
298
|
+
await resolveGeneLabels(df1);
|
|
299
|
+
const firstCount = postCount;
|
|
300
|
+
await resolveGeneLabels(df2);
|
|
301
|
+
expect(postCount, firstCount); // second call must NOT POST again
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
expect(df2.col('Display Name')!.get(0), 'Myh7†');
|
|
305
|
+
// Cache write happened against our store under the correct schema key.
|
|
306
|
+
const cached = store.get(STORE_GENE_LABELS) ?? {};
|
|
307
|
+
expect(cached['__schema_v'], SCHEMA_V_GENE_LABELS);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('resolveGeneLabels: degrades gracefully on fetchProxy throw', async () => {
|
|
311
|
+
const df = DG.DataFrame.fromColumns([
|
|
312
|
+
DG.Column.fromStrings('Gene name', ['ENSRNOG00000001', 'Actb']),
|
|
313
|
+
]);
|
|
314
|
+
df.col('Gene name')!.semType = SEMTYPE.GENE_SYMBOL;
|
|
315
|
+
const warnings = await captureWarnings(async () => {
|
|
316
|
+
await withUserDataStorage(new Map(), async () => {
|
|
317
|
+
await withFetchProxy(async () => { throw new Error('network down'); }, async () => {
|
|
318
|
+
await resolveGeneLabels(df);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
expect(df.col('Display Name')!.get(0), 'ENSRNOG00000001†');
|
|
323
|
+
expect(df.col('Display Name')!.get(1), 'Actb');
|
|
324
|
+
expect(df.col('Source ID')!.get(0), 'ENSRNOG00000001');
|
|
325
|
+
const failWarn = warnings.find((w) => w.includes('Ensembl gene-label resolution unavailable'));
|
|
326
|
+
expect(failWarn != null, true);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test('parser integration: every parser produces Display Name + Source ID columns', async () => {
|
|
330
|
+
// The 4 sync-text parsers all go through the resolver before return. Spectronaut
|
|
331
|
+
// streaming has its own test elsewhere; the text path shares finalizeSpectronaut
|
|
332
|
+
// with the stream path so we cover both via parseSpectronautText.
|
|
333
|
+
const mq = await parseMaxQuantText([
|
|
334
|
+
'Protein IDs\tMajority protein IDs\tGene names\tPotential contaminant\tReverse\t' +
|
|
335
|
+
'Only identified by site\tLFQ intensity Sample1\tLFQ intensity Sample2\tiBAQ',
|
|
336
|
+
'P12345\tP12345\tBRCA1\t\t\t\t1000\t2000\t500',
|
|
337
|
+
].join('\n'));
|
|
338
|
+
expect(mq.col('Display Name')?.semType, 'Proteomics-DisplayName');
|
|
339
|
+
expect(mq.col('Source ID')?.semType, 'Proteomics-SourceId');
|
|
340
|
+
|
|
341
|
+
const fp = await parseFragPipeText([
|
|
342
|
+
'Protein\tGene\tDescription\tMaxLFQ Intensity Sample1\tMaxLFQ Intensity Sample2',
|
|
343
|
+
'P12345\tBRCA1\tProtein BRCA1\t1000\t2000',
|
|
344
|
+
].join('\n'));
|
|
345
|
+
expect(fp.col('Display Name')?.semType, 'Proteomics-DisplayName');
|
|
346
|
+
expect(fp.col('Source ID')?.semType, 'Proteomics-SourceId');
|
|
347
|
+
|
|
348
|
+
const sc = await parseSpectronautCandidatesText([
|
|
349
|
+
'PG.ProteinGroups\tPG.Genes\tComparison (group1/group2)\tAVG Log2 Ratio\tQvalue\tPvalue',
|
|
350
|
+
'P12345\tBRCA1\tA / B\t2.5\t0.001\t0.0005',
|
|
351
|
+
].join('\n'));
|
|
352
|
+
expect(sc.col('Display Name')?.semType, 'Proteomics-DisplayName');
|
|
353
|
+
expect(sc.col('Source ID')?.semType, 'Proteomics-SourceId');
|
|
354
|
+
|
|
355
|
+
const sp = await parseSpectronautText([
|
|
356
|
+
'PG.ProteinGroups\tPG.Genes\tR.Condition\tR.Replicate\tR.FileName\tPG.Qvalue\tPG.Quantity',
|
|
357
|
+
'P12345\tBRCA1\tCtrl\t1\tF1\t0.0001\t1000',
|
|
358
|
+
'P12345\tBRCA1\tCtrl\t2\tF2\t0.0001\t1200',
|
|
359
|
+
'P12345\tBRCA1\tTrt\t1\tF3\t0.0001\t2000',
|
|
360
|
+
'P12345\tBRCA1\tTrt\t2\tF4\t0.0001\t2400',
|
|
361
|
+
].join('\n'));
|
|
362
|
+
expect(sp.col('Display Name')?.semType, 'Proteomics-DisplayName');
|
|
363
|
+
expect(sp.col('Source ID')?.semType, 'Proteomics-SourceId');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test('resolveGeneLabels: three-level fallback uses description when external_name missing', async () => {
|
|
367
|
+
const df = DG.DataFrame.fromColumns([
|
|
368
|
+
DG.Column.fromStrings('Gene name', ['ENSRNOG00000099']),
|
|
369
|
+
]);
|
|
370
|
+
df.col('Gene name')!.semType = SEMTYPE.GENE_SYMBOL;
|
|
371
|
+
await withUserDataStorage(new Map(), async () => {
|
|
372
|
+
await withFetchProxy(
|
|
373
|
+
async () => makeResponse({
|
|
374
|
+
'ENSRNOG00000099': {description: 'Predicted to enable kinase activity [Rattus norvegicus]'},
|
|
375
|
+
}),
|
|
376
|
+
async () => { await resolveGeneLabels(df); },
|
|
377
|
+
);
|
|
378
|
+
});
|
|
379
|
+
// pickBestName uses entry.description.split('[')[0].trim() → 'Predicted to enable kinase activity'
|
|
380
|
+
// which still starts with the literal 'Predicted to' — but pickBestName only rejects on the locked
|
|
381
|
+
// PREDICTED_PREFIXES list (ENSRNOG, etc.), not on free-text 'Predicted to'. CK-omics behavior:
|
|
382
|
+
// such a candidate IS accepted by pickBestName; only the readable-description fallback runs the
|
|
383
|
+
// 'Predicted to ...' regex cleanup. Asserting the verbatim CK-omics behavior here.
|
|
384
|
+
expect(df.col('Display Name')!.get(0), 'Predicted to enable kinase activity†');
|
|
385
|
+
expect(df.col('Source ID')!.get(0), 'ENSRNOG00000099');
|
|
386
|
+
});
|
|
387
|
+
});
|