@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,61 @@
|
|
|
1
|
+
import * as DG from 'datagrok-api/dg';
|
|
2
|
+
import {category, test, expect} from '@datagrok-libraries/test/src/test';
|
|
3
|
+
import {SEMTYPE} from '../utils/proteomics-types';
|
|
4
|
+
import {buildEnrichmentInputsCsv} from '../analysis/enrichment-export';
|
|
5
|
+
|
|
6
|
+
/** DE'd DataFrame: G1 up, G2 down, G3 not significant (background only). */
|
|
7
|
+
function makeDeDf(): DG.DataFrame {
|
|
8
|
+
const df = DG.DataFrame.fromColumns([
|
|
9
|
+
DG.Column.fromStrings('Gene names', ['G1', 'G2', 'G3']),
|
|
10
|
+
DG.Column.fromFloat32Array('log2FC', new Float32Array([2, -2, 0.1])),
|
|
11
|
+
DG.Column.fromFloat32Array('adj.p-value', new Float32Array([0.01, 0.01, 0.5])),
|
|
12
|
+
]);
|
|
13
|
+
df.col('Gene names')!.semType = SEMTYPE.GENE_SYMBOL;
|
|
14
|
+
df.col('log2FC')!.semType = SEMTYPE.LOG2FC;
|
|
15
|
+
df.col('adj.p-value')!.semType = SEMTYPE.P_VALUE;
|
|
16
|
+
return df;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
category('Enrichment Export', () => {
|
|
20
|
+
test('buildEnrichmentInputsCsv: up/down/background split matches thresholds', async () => {
|
|
21
|
+
const {csv, counts} = buildEnrichmentInputsCsv(makeDeDf(), 1.0, 0.05);
|
|
22
|
+
expect(counts.up, 1, 'one up gene');
|
|
23
|
+
expect(counts.down, 1, 'one down gene');
|
|
24
|
+
expect(counts.background, 3, 'all three genes in background');
|
|
25
|
+
|
|
26
|
+
const lines = csv.split('\n');
|
|
27
|
+
expect(lines[0], 'gene,list', 'tidy long header');
|
|
28
|
+
expect(lines.includes('G1,up'), true, 'G1 is up');
|
|
29
|
+
expect(lines.includes('G2,down'), true, 'G2 is down');
|
|
30
|
+
// Background is the full universe — every gene appears under background too.
|
|
31
|
+
expect(lines.includes('G1,background') && lines.includes('G2,background') &&
|
|
32
|
+
lines.includes('G3,background'), true, 'all genes listed as background');
|
|
33
|
+
// G3 is not significant → never up or down.
|
|
34
|
+
expect(lines.includes('G3,up') || lines.includes('G3,down'), false, 'G3 stays background-only');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('buildEnrichmentInputsCsv: looser thresholds pull more genes in', async () => {
|
|
38
|
+
// p ≤ 0.6 and |fc| ≥ 0 promotes G3 (fc 0.1, p 0.5) to up.
|
|
39
|
+
const {counts} = buildEnrichmentInputsCsv(makeDeDf(), 0.0, 0.6);
|
|
40
|
+
expect(counts.up, 2, 'G1 and G3 now up');
|
|
41
|
+
expect(counts.down, 1, 'G2 still down');
|
|
42
|
+
expect(counts.background, 3, 'background unchanged');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('buildEnrichmentInputsCsv: quotes a gene containing a comma', async () => {
|
|
46
|
+
const df = makeDeDf();
|
|
47
|
+
df.col('Gene names')!.set(0, 'G1,alias');
|
|
48
|
+
const {csv} = buildEnrichmentInputsCsv(df, 1.0, 0.05);
|
|
49
|
+
expect(csv.includes('"G1,alias",up'), true, 'comma-bearing field is CSV-quoted');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('buildEnrichmentInputsCsv: throws without DE columns', async () => {
|
|
53
|
+
const df = DG.DataFrame.fromColumns([
|
|
54
|
+
DG.Column.fromStrings('Gene names', ['G1']),
|
|
55
|
+
]);
|
|
56
|
+
df.col('Gene names')!.semType = SEMTYPE.GENE_SYMBOL;
|
|
57
|
+
let threw = false;
|
|
58
|
+
try { buildEnrichmentInputsCsv(df, 1.0, 0.05); } catch { threw = true; }
|
|
59
|
+
expect(threw, true, 'missing log2FC/adj.p-value is an error');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import * as grok from 'datagrok-api/grok';
|
|
2
|
+
import * as DG from 'datagrok-api/dg';
|
|
3
|
+
import * as rxjs from 'rxjs';
|
|
4
|
+
import {category, test, expect} from '@datagrok-libraries/test/src/test';
|
|
5
|
+
import {
|
|
6
|
+
CHART_TOP_MARKER_COL,
|
|
7
|
+
NEG_LOG10_FDR_COL,
|
|
8
|
+
openEnrichmentVisualization,
|
|
9
|
+
prepareEnrichmentChartColumns,
|
|
10
|
+
wireEnrichmentToVolcano,
|
|
11
|
+
} from '../viewers/enrichment-viewers';
|
|
12
|
+
import {SEMTYPE} from '../utils/proteomics-types';
|
|
13
|
+
|
|
14
|
+
// --- Helpers ---
|
|
15
|
+
|
|
16
|
+
/** Creates a mock enrichment DataFrame with the specified number of rows. */
|
|
17
|
+
function makeMockEnrichmentDf(rowCount: number, longTermName?: string): DG.DataFrame {
|
|
18
|
+
const sources: string[] = [];
|
|
19
|
+
const termIds: string[] = [];
|
|
20
|
+
const termNames: string[] = [];
|
|
21
|
+
const pValues: number[] = [];
|
|
22
|
+
const fdrs: number[] = [];
|
|
23
|
+
const geneCounts: number[] = [];
|
|
24
|
+
const geneRatios: number[] = [];
|
|
25
|
+
const intersections: string[] = [];
|
|
26
|
+
const significants: boolean[] = [];
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < rowCount; i++) {
|
|
29
|
+
sources.push(i % 2 === 0 ? 'GO:BP' : 'KEGG');
|
|
30
|
+
termIds.push(`GO:${String(i).padStart(7, '0')}`);
|
|
31
|
+
termNames.push(longTermName && i === 0 ? longTermName : `Term ${i + 1}`);
|
|
32
|
+
const fdr = 0.001 * (i + 1);
|
|
33
|
+
pValues.push(fdr);
|
|
34
|
+
fdrs.push(fdr);
|
|
35
|
+
geneCounts.push(10 - Math.min(i, 9));
|
|
36
|
+
geneRatios.push(0.2 - i * 0.005);
|
|
37
|
+
intersections.push('TP53, BRCA1');
|
|
38
|
+
significants.push(fdr < 0.05);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const df = DG.DataFrame.fromColumns([
|
|
42
|
+
DG.Column.fromStrings('Source', sources),
|
|
43
|
+
DG.Column.fromStrings('Term ID', termIds),
|
|
44
|
+
DG.Column.fromStrings('Term Name', termNames),
|
|
45
|
+
DG.Column.fromFloat32Array('P-value', new Float32Array(pValues)),
|
|
46
|
+
DG.Column.fromFloat32Array('FDR', new Float32Array(fdrs)),
|
|
47
|
+
DG.Column.fromInt32Array('Gene Count', new Int32Array(geneCounts)),
|
|
48
|
+
DG.Column.fromFloat32Array('Gene Ratio', new Float32Array(geneRatios)),
|
|
49
|
+
DG.Column.fromStrings('Intersection', intersections),
|
|
50
|
+
DG.Column.fromList('bool', 'Significant', significants),
|
|
51
|
+
]);
|
|
52
|
+
df.setTag('proteomics.enrichment', 'true');
|
|
53
|
+
return df;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Creates a mock protein DataFrame with gene symbols.
|
|
57
|
+
* Includes log2FC AND adj.p-value so createVolcanoPlot's column requirements
|
|
58
|
+
* (pickMetricColumn → adj.p-value) are satisfied. Tests B and C below construct
|
|
59
|
+
* a co-located volcano via openEnrichmentVisualization → createVolcanoPlot;
|
|
60
|
+
* without adj.p-value, the try/catch guard would swallow the dock and Tests B/C
|
|
61
|
+
* would falsely report missing layout instead of asserting layout presence. */
|
|
62
|
+
function makeMockProteinDf(): DG.DataFrame {
|
|
63
|
+
const genes = ['TP53', 'BRCA1', 'EGFR', 'MYC', 'AKT1'];
|
|
64
|
+
const geneCol = DG.Column.fromStrings('Gene Name', genes);
|
|
65
|
+
geneCol.semType = SEMTYPE.GENE_SYMBOL;
|
|
66
|
+
const df = DG.DataFrame.fromColumns([
|
|
67
|
+
geneCol,
|
|
68
|
+
DG.Column.fromFloat32Array('log2FC', new Float32Array([1.5, -2.0, 0.5, 3.0, -1.0])),
|
|
69
|
+
// Bulk-init via fromFloat32Array — memory feedback_dg_column_bulk_init.
|
|
70
|
+
DG.Column.fromFloat32Array(
|
|
71
|
+
'adj.p-value', new Float32Array([0.001, 0.01, 0.5, 0.0001, 0.04])),
|
|
72
|
+
]);
|
|
73
|
+
return df;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Adds a Direction column with the supplied Up/Down assignments. The
|
|
77
|
+
* directional-split branch of openEnrichmentVisualization keys on this. */
|
|
78
|
+
function addDirectionColumn(df: DG.DataFrame, dirs: ('Up' | 'Down')[]): void {
|
|
79
|
+
df.columns.add(DG.Column.fromStrings('Direction', dirs as string[]));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Counts viewers on the supplied table view bound to `target` (same Dart-handle
|
|
83
|
+
* equality the platform uses). */
|
|
84
|
+
function countViewersBoundTo(tv: DG.TableView, target: DG.DataFrame): number {
|
|
85
|
+
let n = 0;
|
|
86
|
+
for (const v of tv.viewers) {
|
|
87
|
+
if ((v.dataFrame as any)?.dart === (target as any).dart) n++;
|
|
88
|
+
}
|
|
89
|
+
return n;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Counts all viewers on the supplied table view. tv.viewers is Iterable, not
|
|
93
|
+
* an array, so we cannot read .length directly — iterate to count instead. */
|
|
94
|
+
function countAllViewers(tv: DG.TableView): number {
|
|
95
|
+
let n = 0;
|
|
96
|
+
for (const _ of tv.viewers) n++;
|
|
97
|
+
return n;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Counts `true` cells in a boolean column without relying on stats semantics. */
|
|
101
|
+
function trueCount(col: DG.Column): number {
|
|
102
|
+
let n = 0;
|
|
103
|
+
for (let i = 0; i < col.length; i++) {
|
|
104
|
+
if (col.get(i) === true) n++;
|
|
105
|
+
}
|
|
106
|
+
return n;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- Tests ---
|
|
110
|
+
|
|
111
|
+
category('Enrichment Visualization', () => {
|
|
112
|
+
test('prepareEnrichmentChartColumns adds negLog10FDR on the same frame', async () => {
|
|
113
|
+
const df = makeMockEnrichmentDf(20);
|
|
114
|
+
prepareEnrichmentChartColumns(df, 15);
|
|
115
|
+
const negLogCol = df.col(NEG_LOG10_FDR_COL);
|
|
116
|
+
expect(negLogCol !== null, true);
|
|
117
|
+
// Row 0 FDR = 0.001, -log10(0.001) = 3.0
|
|
118
|
+
const val = negLogCol!.get(0) as number;
|
|
119
|
+
expect(Math.abs(val - 3.0) < 0.01, true);
|
|
120
|
+
// The charts bind to the passed frame — no cloned subset is produced.
|
|
121
|
+
expect(df.rowCount, 20);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('prepareEnrichmentChartColumns marks exactly topN terms (no Direction)', async () => {
|
|
125
|
+
const df = makeMockEnrichmentDf(20);
|
|
126
|
+
prepareEnrichmentChartColumns(df, 15);
|
|
127
|
+
const marker = df.col(CHART_TOP_MARKER_COL)!;
|
|
128
|
+
expect(trueCount(marker), 15);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('prepareEnrichmentChartColumns marks topN per direction', async () => {
|
|
132
|
+
const df = makeMockEnrichmentDf(20);
|
|
133
|
+
// First 10 rows Up, last 10 Down (rows are FDR-ascending by construction).
|
|
134
|
+
addDirectionColumn(df, Array.from({length: 20}, (_, i) => (i < 10 ? 'Up' : 'Down')));
|
|
135
|
+
prepareEnrichmentChartColumns(df, 5);
|
|
136
|
+
const marker = df.col(CHART_TOP_MARKER_COL)!;
|
|
137
|
+
// 5 Up + 5 Down = 10 marked terms.
|
|
138
|
+
expect(trueCount(marker), 10);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('prepareEnrichmentChartColumns marks all when fewer rows than topN', async () => {
|
|
142
|
+
const df = makeMockEnrichmentDf(5);
|
|
143
|
+
prepareEnrichmentChartColumns(df, 15);
|
|
144
|
+
const marker = df.col(CHART_TOP_MARKER_COL)!;
|
|
145
|
+
expect(trueCount(marker), 5);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('prepareEnrichmentChartColumns is idempotent on re-run', async () => {
|
|
149
|
+
const df = makeMockEnrichmentDf(20);
|
|
150
|
+
prepareEnrichmentChartColumns(df, 15);
|
|
151
|
+
const colsAfterFirst = df.columns.length;
|
|
152
|
+
prepareEnrichmentChartColumns(df, 15);
|
|
153
|
+
// Re-run replaces (ensureFreshFloat pattern) rather than duplicating.
|
|
154
|
+
expect(df.columns.length, colsAfterFirst);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('wireEnrichmentToVolcano returns EMPTY without gene column', async () => {
|
|
158
|
+
const enrichDf = makeMockEnrichmentDf(3);
|
|
159
|
+
// Protein df without gene symbol semtype
|
|
160
|
+
const proteinDf = DG.DataFrame.fromColumns([
|
|
161
|
+
DG.Column.fromStrings('SomeColumn', ['A', 'B', 'C']),
|
|
162
|
+
]);
|
|
163
|
+
const sub = wireEnrichmentToVolcano(enrichDf, proteinDf);
|
|
164
|
+
expect(sub === rxjs.Subscription.EMPTY, true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('wireEnrichmentToVolcano selects matching genes on protein DataFrame', async () => {
|
|
168
|
+
const enrichDf = makeMockEnrichmentDf(3);
|
|
169
|
+
// Set intersection to specific genes for first row
|
|
170
|
+
enrichDf.col('Intersection')!.set(0, 'TP53, BRCA1');
|
|
171
|
+
const proteinDf = makeMockProteinDf();
|
|
172
|
+
|
|
173
|
+
const sub = wireEnrichmentToVolcano(enrichDf, proteinDf);
|
|
174
|
+
expect(sub !== rxjs.Subscription.EMPTY, true);
|
|
175
|
+
|
|
176
|
+
// Trigger row change
|
|
177
|
+
enrichDf.currentRowIdx = 0;
|
|
178
|
+
|
|
179
|
+
// Check that TP53 (row 0) and BRCA1 (row 1) are selected
|
|
180
|
+
expect(proteinDf.selection.get(0), true); // TP53
|
|
181
|
+
expect(proteinDf.selection.get(1), true); // BRCA1
|
|
182
|
+
expect(proteinDf.selection.get(2), false); // EGFR
|
|
183
|
+
expect(proteinDf.selection.get(3), false); // MYC
|
|
184
|
+
expect(proteinDf.selection.get(4), false); // AKT1
|
|
185
|
+
|
|
186
|
+
sub.unsubscribe();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('wireEnrichmentToVolcano clears selection before new selection', async () => {
|
|
190
|
+
const enrichDf = makeMockEnrichmentDf(3);
|
|
191
|
+
enrichDf.col('Intersection')!.set(0, 'TP53');
|
|
192
|
+
enrichDf.col('Intersection')!.set(1, 'EGFR');
|
|
193
|
+
const proteinDf = makeMockProteinDf();
|
|
194
|
+
|
|
195
|
+
// Pre-select row 4 (AKT1)
|
|
196
|
+
proteinDf.selection.set(4, true, false);
|
|
197
|
+
proteinDf.selection.fireChanged();
|
|
198
|
+
|
|
199
|
+
const sub = wireEnrichmentToVolcano(enrichDf, proteinDf);
|
|
200
|
+
|
|
201
|
+
// Select first enrichment row (TP53 only)
|
|
202
|
+
enrichDf.currentRowIdx = 0;
|
|
203
|
+
|
|
204
|
+
// AKT1 (row 4) should no longer be selected since selection was cleared
|
|
205
|
+
expect(proteinDf.selection.get(4), false);
|
|
206
|
+
// TP53 (row 0) should be selected
|
|
207
|
+
expect(proteinDf.selection.get(0), true);
|
|
208
|
+
|
|
209
|
+
sub.unsubscribe();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// Test B (light-touch declutter, 2026-06): the directional-split Up/Down
|
|
213
|
+
// dot+bar viewers now dock on the ENRICHMENT TableView, NOT the protein view —
|
|
214
|
+
// the user's volcano tab stays clean. This reverses the 13-09 co-dock-on-
|
|
215
|
+
// protein decision per the declutter request.
|
|
216
|
+
test('openEnrichmentVisualization docks dot/bar on enrichment view (directional)', async () => {
|
|
217
|
+
const enrichDf = makeMockEnrichmentDf(4);
|
|
218
|
+
addDirectionColumn(enrichDf, ['Up', 'Up', 'Down', 'Down']);
|
|
219
|
+
enrichDf.name = 'Enrichment (test B)';
|
|
220
|
+
const proteinDf = makeMockProteinDf();
|
|
221
|
+
proteinDf.name = 'Protein (test B)';
|
|
222
|
+
|
|
223
|
+
const proteinTv = grok.shell.addTableView(proteinDf);
|
|
224
|
+
const beforeViewerCount = countAllViewers(proteinTv);
|
|
225
|
+
const enrichTv = openEnrichmentVisualization(enrichDf, proteinDf);
|
|
226
|
+
try {
|
|
227
|
+
// The protein/volcano view must NOT gain the enrichment charts.
|
|
228
|
+
expect(countAllViewers(proteinTv), beforeViewerCount,
|
|
229
|
+
`protein view must not gain dot/bar viewers; gained ${countAllViewers(proteinTv) - beforeViewerCount}`);
|
|
230
|
+
|
|
231
|
+
// The 4 charts (Up dot + Up bar + Down dot + Down bar) live on the
|
|
232
|
+
// enrichment view alongside its grid (grid + 4 charts ⇒ ≥4).
|
|
233
|
+
expect(countAllViewers(enrichTv) >= 4, true,
|
|
234
|
+
`enrichment view should carry ≥4 dot/bar viewers; got ${countAllViewers(enrichTv)}`);
|
|
235
|
+
|
|
236
|
+
// No proteinDf-bound viewer should live on the enrichment view.
|
|
237
|
+
expect(countViewersBoundTo(enrichTv, proteinDf), 0,
|
|
238
|
+
'enrichment view must not carry a proteinDf-bound volcano');
|
|
239
|
+
|
|
240
|
+
// Data-layer cross-link still fires through the unchanged subscriptions.
|
|
241
|
+
enrichDf.currentRowIdx = 0;
|
|
242
|
+
expect(proteinDf.selection.trueCount >= 1, true,
|
|
243
|
+
'selecting an enrichment row should still highlight matching protein rows');
|
|
244
|
+
} finally {
|
|
245
|
+
enrichTv.close();
|
|
246
|
+
proteinTv.close();
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Test C (declutter): single-direction fallback path also docks on the enrichment view.
|
|
251
|
+
test('openEnrichmentVisualization docks dot/bar on enrichment view (single-direction)', async () => {
|
|
252
|
+
const enrichDf = makeMockEnrichmentDf(3);
|
|
253
|
+
// Intentionally NO Direction column — exercises the fallback branch.
|
|
254
|
+
enrichDf.name = 'Enrichment (test C)';
|
|
255
|
+
const proteinDf = makeMockProteinDf();
|
|
256
|
+
proteinDf.name = 'Protein (test C)';
|
|
257
|
+
|
|
258
|
+
const proteinTv = grok.shell.addTableView(proteinDf);
|
|
259
|
+
const beforeViewerCount = countAllViewers(proteinTv);
|
|
260
|
+
const enrichTv = openEnrichmentVisualization(enrichDf, proteinDf);
|
|
261
|
+
try {
|
|
262
|
+
// Protein/volcano view stays clean.
|
|
263
|
+
expect(countAllViewers(proteinTv), beforeViewerCount,
|
|
264
|
+
`protein view must not gain dot/bar viewers; gained ${countAllViewers(proteinTv) - beforeViewerCount}`);
|
|
265
|
+
// Single-direction fallback adds 2 charts (dot + bar) on the enrichment view.
|
|
266
|
+
expect(countAllViewers(enrichTv) >= 2, true,
|
|
267
|
+
`enrichment view should carry ≥2 dot/bar viewers; got ${countAllViewers(enrichTv)}`);
|
|
268
|
+
|
|
269
|
+
expect(countViewersBoundTo(enrichTv, proteinDf), 0,
|
|
270
|
+
'enrichment view must not carry a proteinDf-bound volcano');
|
|
271
|
+
} finally {
|
|
272
|
+
enrichTv.close();
|
|
273
|
+
proteinTv.close();
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// Test D (declutter): focus now lands on the ENRICHMENT view (its dot/bar
|
|
278
|
+
// charts), leaving the volcano untouched on the protein tab.
|
|
279
|
+
test('openEnrichmentVisualization switches focus to enrichment TableView', async () => {
|
|
280
|
+
const enrichDf = makeMockEnrichmentDf(4);
|
|
281
|
+
addDirectionColumn(enrichDf, ['Up', 'Up', 'Down', 'Down']);
|
|
282
|
+
enrichDf.name = 'Enrichment (test D)';
|
|
283
|
+
const proteinDf = makeMockProteinDf();
|
|
284
|
+
proteinDf.name = 'Protein (test D)';
|
|
285
|
+
|
|
286
|
+
const proteinTv = grok.shell.addTableView(proteinDf);
|
|
287
|
+
const enrichTv = openEnrichmentVisualization(enrichDf, proteinDf);
|
|
288
|
+
try {
|
|
289
|
+
// Duck-type via Dart-handle identity — toJs returns fresh JS wrappers.
|
|
290
|
+
// grok.shell.v is a ViewBase (no dataFrame field on the base type), so
|
|
291
|
+
// cast to any before reaching for it.
|
|
292
|
+
const focusedDart = ((grok.shell.v as any)?.dataFrame as any)?.dart;
|
|
293
|
+
expect(focusedDart === (enrichDf as any).dart, true,
|
|
294
|
+
'focused view should be the enrichment TableView after enrichment runs');
|
|
295
|
+
} finally {
|
|
296
|
+
enrichTv.close();
|
|
297
|
+
proteinTv.close();
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// --- Smart pathway filter banner (14-05 D-14) ---
|
|
302
|
+
|
|
303
|
+
test('enrichmentBannerRendersWhenTagSet', async () => {
|
|
304
|
+
const enrichDf = makeMockEnrichmentDf(3);
|
|
305
|
+
enrichDf.name = 'Enrichment (banner-on)';
|
|
306
|
+
enrichDf.setTag('proteomics.enrichment_smart_filtered', 'true');
|
|
307
|
+
enrichDf.setTag('proteomics.enrichment_smart_filtered_kept', '20');
|
|
308
|
+
enrichDf.setTag('proteomics.enrichment_smart_filtered_total', '47');
|
|
309
|
+
enrichDf.setTag('proteomics.enrichment_smart_filtered_dropped_parents', '3');
|
|
310
|
+
enrichDf.setTag('proteomics.enrichment_smart_filtered_cap', '15');
|
|
311
|
+
const proteinDf = makeMockProteinDf();
|
|
312
|
+
const tv = openEnrichmentVisualization(enrichDf, proteinDf);
|
|
313
|
+
try {
|
|
314
|
+
const banners = tv.root.querySelectorAll('[data-smart-filter-banner="true"]');
|
|
315
|
+
expect(banners.length >= 1, true, 'banner should be docked when tag is set');
|
|
316
|
+
const txt = (banners[0] as HTMLElement).textContent ?? '';
|
|
317
|
+
expect(txt.includes('Smart pathway filter active'), true);
|
|
318
|
+
expect(txt.includes('showing 20 of 47 terms'), true);
|
|
319
|
+
expect(txt.includes('dropped 3 generic parents'), true);
|
|
320
|
+
expect(txt.includes('capped at 15 per source'), true);
|
|
321
|
+
expect(txt.includes('Re-run with the filter off'), true);
|
|
322
|
+
} finally {
|
|
323
|
+
tv.close();
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test('enrichmentBannerAbsentWhenTagUnset', async () => {
|
|
328
|
+
const enrichDf = makeMockEnrichmentDf(3);
|
|
329
|
+
enrichDf.name = 'Enrichment (banner-off)';
|
|
330
|
+
// No smart-filter tags set.
|
|
331
|
+
const proteinDf = makeMockProteinDf();
|
|
332
|
+
const tv = openEnrichmentVisualization(enrichDf, proteinDf);
|
|
333
|
+
try {
|
|
334
|
+
const banners = tv.root.querySelectorAll('[data-smart-filter-banner="true"]');
|
|
335
|
+
expect(banners.length, 0);
|
|
336
|
+
} finally {
|
|
337
|
+
tv.close();
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import * as DG from 'datagrok-api/dg';
|
|
2
|
+
import {category, test, expect} from '@datagrok-libraries/test/src/test';
|
|
3
|
+
import {buildEnrichmentDf, countSignificantProteins, splitGenesByDirection,
|
|
4
|
+
ORGANISM_LIST, GostResult} from '../analysis/enrichment';
|
|
5
|
+
import {wireEnrichmentToVolcano} from '../viewers/enrichment-viewers';
|
|
6
|
+
import {SEMTYPE} from '../utils/proteomics-types';
|
|
7
|
+
|
|
8
|
+
// --- Helpers ---
|
|
9
|
+
|
|
10
|
+
function makeMockResults(): GostResult[] {
|
|
11
|
+
return [
|
|
12
|
+
{
|
|
13
|
+
native: 'GO:0006915',
|
|
14
|
+
name: 'apoptotic process',
|
|
15
|
+
source: 'GO:BP',
|
|
16
|
+
p_value: 0.001,
|
|
17
|
+
significant: true,
|
|
18
|
+
term_size: 500,
|
|
19
|
+
query_size: 50,
|
|
20
|
+
intersection_size: 10,
|
|
21
|
+
effective_domain_size: 20000,
|
|
22
|
+
precision: 0.2,
|
|
23
|
+
recall: 0.02,
|
|
24
|
+
intersections: [['IEA'], ['IDA'], [], ['TAS']],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
native: 'KEGG:04210',
|
|
28
|
+
name: 'Apoptosis',
|
|
29
|
+
source: 'KEGG',
|
|
30
|
+
p_value: 0.03,
|
|
31
|
+
significant: true,
|
|
32
|
+
term_size: 120,
|
|
33
|
+
query_size: 50,
|
|
34
|
+
intersection_size: 5,
|
|
35
|
+
effective_domain_size: 20000,
|
|
36
|
+
precision: 0.1,
|
|
37
|
+
recall: 0.042,
|
|
38
|
+
intersections: [[], ['evidence'], [], []],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
native: 'REAC:R-HSA-109581',
|
|
42
|
+
name: 'Apoptosis',
|
|
43
|
+
source: 'REAC',
|
|
44
|
+
p_value: 0.12,
|
|
45
|
+
significant: false,
|
|
46
|
+
term_size: 80,
|
|
47
|
+
query_size: 50,
|
|
48
|
+
intersection_size: 3,
|
|
49
|
+
effective_domain_size: 20000,
|
|
50
|
+
precision: 0.06,
|
|
51
|
+
recall: 0.0375,
|
|
52
|
+
intersections: [['IEA'], [], [], []],
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --- Tests ---
|
|
58
|
+
|
|
59
|
+
category('Enrichment', () => {
|
|
60
|
+
test('buildEnrichmentDf creates correct schema', async () => {
|
|
61
|
+
const results = makeMockResults();
|
|
62
|
+
const queryGenes = ['TP53', 'BRCA1', 'EGFR', 'MYC'];
|
|
63
|
+
const df = buildEnrichmentDf(results, queryGenes);
|
|
64
|
+
|
|
65
|
+
// 7 data cols (Source/Term ID/Term Name/FDR/Gene Count/Gene Ratio/Intersection)
|
|
66
|
+
// + 1 Significant. g:GOSt with significance_threshold_method='fdr' returns the
|
|
67
|
+
// FDR-corrected p-value with no separate raw p-value, so the schema exposes
|
|
68
|
+
// only FDR — see buildEnrichmentDf in src/analysis/enrichment.ts.
|
|
69
|
+
expect(df.columns.length, 8);
|
|
70
|
+
expect(df.col('Source') !== null, true);
|
|
71
|
+
expect(df.col('Term ID') !== null, true);
|
|
72
|
+
expect(df.col('Term Name') !== null, true);
|
|
73
|
+
expect(df.col('FDR') !== null, true);
|
|
74
|
+
expect(df.col('Gene Count') !== null, true);
|
|
75
|
+
expect(df.col('Gene Ratio') !== null, true);
|
|
76
|
+
expect(df.col('Intersection') !== null, true);
|
|
77
|
+
expect(df.col('Significant') !== null, true);
|
|
78
|
+
expect(df.rowCount, 3);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('buildEnrichmentDf sets enrichment tag', async () => {
|
|
82
|
+
const results = makeMockResults();
|
|
83
|
+
const df = buildEnrichmentDf(results, ['TP53', 'BRCA1', 'EGFR', 'MYC']);
|
|
84
|
+
expect(df.getTag('proteomics.enrichment'), 'true');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('buildEnrichmentDf marks significant terms', async () => {
|
|
88
|
+
const results = makeMockResults(); // first two < 0.05, third > 0.05
|
|
89
|
+
const df = buildEnrichmentDf(results, ['TP53', 'BRCA1', 'EGFR', 'MYC']);
|
|
90
|
+
const sigCol = df.col('Significant')!;
|
|
91
|
+
expect(sigCol.get(0), true); // p=0.001
|
|
92
|
+
expect(sigCol.get(1), true); // p=0.03
|
|
93
|
+
expect(sigCol.get(2), false); // p=0.12
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('buildEnrichmentDf extracts intersection genes', async () => {
|
|
97
|
+
const queryGenes = ['TP53', 'BRCA1', 'EGFR', 'MYC'];
|
|
98
|
+
const results: GostResult[] = [{
|
|
99
|
+
native: 'GO:0006915',
|
|
100
|
+
name: 'apoptotic process',
|
|
101
|
+
source: 'GO:BP',
|
|
102
|
+
p_value: 0.01,
|
|
103
|
+
significant: true,
|
|
104
|
+
term_size: 500,
|
|
105
|
+
query_size: 4,
|
|
106
|
+
intersection_size: 2,
|
|
107
|
+
effective_domain_size: 20000,
|
|
108
|
+
precision: 0.5,
|
|
109
|
+
recall: 0.004,
|
|
110
|
+
intersections: [['IEA'], [], ['TAS'], []],
|
|
111
|
+
}];
|
|
112
|
+
const df = buildEnrichmentDf(results, queryGenes);
|
|
113
|
+
const intersectionVal = df.col('Intersection')!.get(0) as string;
|
|
114
|
+
expect(intersectionVal, 'TP53, EGFR');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('countSignificantProteins counts correctly', async () => {
|
|
118
|
+
const df = DG.DataFrame.fromColumns([
|
|
119
|
+
DG.Column.fromFloat32Array('log2FC', new Float32Array([2.0, 0.5, -1.5, 3.0])),
|
|
120
|
+
DG.Column.fromFloat32Array('adjP', new Float32Array([0.01, 0.001, 0.1, 0.03])),
|
|
121
|
+
]);
|
|
122
|
+
const result = countSignificantProteins(
|
|
123
|
+
df, 1.0, 0.05, df.col('log2FC')!, df.col('adjP')!,
|
|
124
|
+
);
|
|
125
|
+
// |2.0|>=1 && 0.01<=0.05 => yes; |0.5|<1 => no; |-1.5|>=1 && 0.1>0.05 => no; |3.0|>=1 && 0.03<=0.05 => yes
|
|
126
|
+
expect(result.significant, 2);
|
|
127
|
+
expect(result.total, 4);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('countSignificantProteins handles null values', async () => {
|
|
131
|
+
const fcCol = DG.Column.fromFloat32Array('log2FC', new Float32Array([2.0, 0, -1.5]));
|
|
132
|
+
const pCol = DG.Column.fromFloat32Array('adjP', new Float32Array([0.01, 0, 0.03]));
|
|
133
|
+
// Set index 1 to null
|
|
134
|
+
fcCol.set(1, DG.FLOAT_NULL);
|
|
135
|
+
pCol.set(1, DG.FLOAT_NULL);
|
|
136
|
+
|
|
137
|
+
const df = DG.DataFrame.fromColumns([fcCol, pCol]);
|
|
138
|
+
const result = countSignificantProteins(df, 1.0, 0.05, fcCol, pCol);
|
|
139
|
+
// row 0: |2.0|>=1 && 0.01<=0.05 => yes; row 1: null => skip; row 2: |-1.5|>=1 && 0.03<=0.05 => yes
|
|
140
|
+
expect(result.significant, 2);
|
|
141
|
+
expect(result.total, 3); // all rows counted in total
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// --- R2: directional split (13-02) ---
|
|
145
|
+
|
|
146
|
+
test('splitGenesByDirection splits significant genes by fc sign, shared background', async () => {
|
|
147
|
+
const geneForRow = new Map<number, string>([[0, 'A'], [1, 'B'], [2, 'C'], [3, 'D']]);
|
|
148
|
+
const fc = new Float32Array([2.0, -2.0, 0.5, -3.0]);
|
|
149
|
+
const p = new Float32Array([0.01, 0.01, 0.001, 0.2]);
|
|
150
|
+
const {upGenes, downGenes, background} = splitGenesByDirection(geneForRow, fc, p, 1.0, 0.05);
|
|
151
|
+
// A: |2.0|>=1 & .01<=.05 → up. B: |-2.0|>=1 & .01<=.05 → down.
|
|
152
|
+
// C: |0.5|<1 → neither. D: .2>.05 → neither.
|
|
153
|
+
expect(upGenes.length, 1);
|
|
154
|
+
expect(upGenes[0], 'A');
|
|
155
|
+
expect(downGenes.length, 1);
|
|
156
|
+
expect(downGenes[0], 'B');
|
|
157
|
+
// Background = all detected genes (both directional queries share it).
|
|
158
|
+
expect(background.length, 4);
|
|
159
|
+
expect(background.includes('A') && background.includes('B') &&
|
|
160
|
+
background.includes('C') && background.includes('D'), true);
|
|
161
|
+
// Up/Down are disjoint.
|
|
162
|
+
expect(upGenes.some((g) => downGenes.includes(g)), false);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('splitGenesByDirection skips null fc/p rows', async () => {
|
|
166
|
+
const geneForRow = new Map<number, string>([[0, 'A'], [1, 'B']]);
|
|
167
|
+
const fc = new Float32Array([2.0, 0]);
|
|
168
|
+
const p = new Float32Array([0.01, 0]);
|
|
169
|
+
fc[1] = DG.FLOAT_NULL;
|
|
170
|
+
p[1] = DG.FLOAT_NULL;
|
|
171
|
+
const {upGenes, downGenes, background} = splitGenesByDirection(geneForRow, fc, p, 1.0, 0.05);
|
|
172
|
+
expect(upGenes.length, 1);
|
|
173
|
+
expect(downGenes.length, 0);
|
|
174
|
+
expect(background.length, 2); // both rows still counted as detected background
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('buildEnrichmentDf adds Direction column when label given', async () => {
|
|
178
|
+
const df = buildEnrichmentDf(makeMockResults(), ['TP53', 'BRCA1', 'EGFR', 'MYC'], 0.05, 'Up');
|
|
179
|
+
const dir = df.col('Direction');
|
|
180
|
+
expect(dir !== null, true);
|
|
181
|
+
expect(dir!.get(0), 'Up');
|
|
182
|
+
expect(dir!.get(1), 'Up');
|
|
183
|
+
// Phase-9 schema preserved + Direction = 9 columns.
|
|
184
|
+
expect(df.columns.length, 9);
|
|
185
|
+
expect(df.col('Intersection') !== null, true);
|
|
186
|
+
expect(df.getTag('proteomics.enrichment'), 'true');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('buildEnrichmentDf omits Direction column when no label', async () => {
|
|
190
|
+
const df = buildEnrichmentDf(makeMockResults(), ['TP53', 'BRCA1', 'EGFR', 'MYC']);
|
|
191
|
+
expect(df.col('Direction'), null);
|
|
192
|
+
expect(df.columns.length, 8);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('wireEnrichmentToVolcano selects matching proteins, source DF unmutated', async () => {
|
|
196
|
+
const enrichDf = DG.DataFrame.fromColumns([
|
|
197
|
+
DG.Column.fromStrings('Intersection', ['TP53, EGFR']),
|
|
198
|
+
DG.Column.fromStrings('Direction', ['Up']),
|
|
199
|
+
]);
|
|
200
|
+
const geneCol = DG.Column.fromStrings('Gene Symbol', ['TP53', 'EGFR', 'MYC']);
|
|
201
|
+
geneCol.semType = SEMTYPE.GENE_SYMBOL;
|
|
202
|
+
const proteinDf = DG.DataFrame.fromColumns([geneCol]);
|
|
203
|
+
|
|
204
|
+
const sub = wireEnrichmentToVolcano(enrichDf, proteinDf);
|
|
205
|
+
enrichDf.currentRowIdx = 0;
|
|
206
|
+
// TP53 + EGFR selected (from Intersection); MYC not.
|
|
207
|
+
expect(proteinDf.selection.trueCount, 2);
|
|
208
|
+
expect(proteinDf.selection.get(2), false);
|
|
209
|
+
// Cross-link must not mutate either source frame's shape.
|
|
210
|
+
expect(enrichDf.rowCount, 1);
|
|
211
|
+
expect(proteinDf.rowCount, 3);
|
|
212
|
+
sub.unsubscribe();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('ORGANISM_LIST contains 9 entries with valid codes', async () => {
|
|
216
|
+
expect(ORGANISM_LIST.length, 9);
|
|
217
|
+
expect(ORGANISM_LIST[0].display, 'Homo sapiens (Human)');
|
|
218
|
+
expect(ORGANISM_LIST[0].code, 'hsapiens');
|
|
219
|
+
for (const org of ORGANISM_LIST) {
|
|
220
|
+
expect(org.display.length > 0, true);
|
|
221
|
+
expect(org.code.length > 0, true);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
});
|