@datagrok/proteomics 1.2.0 → 1.3.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 +16 -0
- package/README.md +18 -5
- package/dist/package-test.js +1 -1
- package/dist/package-test.js.map +1 -1
- package/dist/package.js +1 -1
- package/dist/package.js.map +1 -1
- package/docs/personas-and-capabilities.md +16 -0
- package/docs/publishing-design-rationale.md +140 -0
- package/package.json +20 -5
- package/package.png +0 -0
- package/src/analysis/imputation.ts +3 -1
- package/src/analysis/normalization.ts +9 -4
- package/src/analysis/spc-storage.ts +1 -1
- package/src/menu.ts +5 -2
- package/src/package-api.ts +10 -2
- package/src/package-test.ts +3 -1
- package/src/package.g.ts +17 -3
- package/src/package.ts +31 -15
- package/src/panels/published-analysis-panel.ts +2 -2
- package/src/publishing/assert-published-shape.ts +2 -2
- package/src/publishing/package-settings-editor.ts +125 -0
- package/src/publishing/publish-project.ts +8 -8
- package/src/publishing/publish-settings.ts +57 -3
- package/src/publishing/publish-state.ts +45 -16
- package/src/publishing/reviewer-groups.ts +24 -0
- package/src/publishing/share-dialog.ts +42 -27
- package/src/publishing/trim-dataframe.ts +8 -1
- package/src/tests/abundance-correlation.ts +135 -0
- package/src/tests/analysis.ts +63 -0
- package/src/tests/enrichment-visualization.ts +95 -9
- package/src/tests/enrichment.ts +2 -1
- package/src/tests/project-vocabulary.ts +39 -0
- package/src/tests/publish-roundtrip.ts +40 -40
- package/src/tests/publish-spike.ts +2 -2
- package/src/tests/rank-abundance.ts +85 -0
- package/src/utils/abundance-detection.ts +145 -0
- package/src/viewers/abundance-correlation.ts +208 -0
- package/src/viewers/enrichment-viewers.ts +58 -24
- package/src/viewers/rank-abundance.ts +153 -0
- package/src/viewers/volcano.ts +3 -0
- package/test-console-output-1.log +1145 -1090
- package/test-record-1.mp4 +0 -0
- package/src/tests/group-mean-correlation.ts +0 -139
- package/src/viewers/group-mean-correlation.ts +0 -218
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import * as grok from 'datagrok-api/grok';
|
|
2
|
+
import * as DG from 'datagrok-api/dg';
|
|
3
|
+
import {category, test, expect} from '@datagrok-libraries/test/src/test';
|
|
4
|
+
import {createAbundanceCorrelation, correlationDescription, pearson, spearman} from '../viewers/abundance-correlation';
|
|
5
|
+
import {setGroups} from '../analysis/experiment-setup';
|
|
6
|
+
import {SEMTYPE} from '../utils/proteomics-types';
|
|
7
|
+
|
|
8
|
+
/** Candidates-shaped mock: the pre-computed AVG Group Quantity columns + a
|
|
9
|
+
* `significant` column. createAbundanceCorrelation resolves abundance from the
|
|
10
|
+
* quantity columns (no per-sample data needed). */
|
|
11
|
+
function makeCandidatesDf(num: number[], den: number[]): DG.DataFrame {
|
|
12
|
+
const df = DG.DataFrame.fromColumns([
|
|
13
|
+
DG.Column.fromStrings('ProteinGroups', num.map((_, i) => `P${i + 1}`)),
|
|
14
|
+
DG.Column.fromFloat32Array('AVG Group Quantity Numerator', new Float32Array(num)),
|
|
15
|
+
DG.Column.fromFloat32Array('AVG Group Quantity Denominator', new Float32Array(den)),
|
|
16
|
+
]);
|
|
17
|
+
df.col('ProteinGroups')!.semType = SEMTYPE.PROTEIN_ID;
|
|
18
|
+
setGroups(df, {group1: {name: 'DMD', columns: []}, group2: {name: 'WT', columns: []}});
|
|
19
|
+
return df;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Report-shaped mock: per-sample intensity columns assigned to two groups, plus
|
|
23
|
+
* a `direction` column so the color binding has a target. Values are log2-scale
|
|
24
|
+
* (all < 45), so the geometric-mean path rebases them as logs. */
|
|
25
|
+
function makeReportDf(): DG.DataFrame {
|
|
26
|
+
const cols: DG.Column[] = [
|
|
27
|
+
DG.Column.fromStrings('Primary Protein ID', ['P1', 'P2', 'P3', 'P4', 'P5']),
|
|
28
|
+
DG.Column.fromList('double' as DG.ColumnType, 'S1', [1, 2, 3, 4, 5]),
|
|
29
|
+
DG.Column.fromList('double' as DG.ColumnType, 'S2', [3, 4, 5, 6, 7]),
|
|
30
|
+
DG.Column.fromList('double' as DG.ColumnType, 'S3', [5, 6, 7, 8, 9]),
|
|
31
|
+
DG.Column.fromList('double' as DG.ColumnType, 'S4', [7, 8, 9, 10, 11]),
|
|
32
|
+
DG.Column.fromStrings('direction',
|
|
33
|
+
['Enriched in Control', 'Enriched in Treatment', 'Not significant',
|
|
34
|
+
'Enriched in Control', 'Enriched in Treatment']),
|
|
35
|
+
];
|
|
36
|
+
cols[0].semType = SEMTYPE.PROTEIN_ID;
|
|
37
|
+
const df = DG.DataFrame.fromColumns(cols);
|
|
38
|
+
setGroups(df, {
|
|
39
|
+
group1: {name: 'Control', columns: ['S1', 'S2']},
|
|
40
|
+
group2: {name: 'Treatment', columns: ['S3', 'S4']},
|
|
41
|
+
});
|
|
42
|
+
return df;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
category('Abundance-Correlation', () => {
|
|
46
|
+
test('pearson is 1 for a perfectly correlated series', async () => {
|
|
47
|
+
const {r, n} = pearson([1, 2, 3, 4, 5], [2, 4, 6, 8, 10]);
|
|
48
|
+
expect(n, 5);
|
|
49
|
+
expect(Math.abs(r - 1) < 1e-6, true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('pearson is -1 for a perfectly anti-correlated series', async () => {
|
|
53
|
+
const {r} = pearson([1, 2, 3, 4], [4, 3, 2, 1]);
|
|
54
|
+
expect(Math.abs(r + 1) < 1e-6, true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('pearson ignores rows with a null in either series', async () => {
|
|
58
|
+
const {n} = pearson([1, null, 3, 4], [2, 2, null, 8]);
|
|
59
|
+
expect(n, 2); // only rows 0 and 3 are pairwise-complete
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('spearman is 1 with rank ties', async () => {
|
|
63
|
+
const rho = spearman([1, 2, 2, 3, 4], [1, 2, 2, 3, 4]);
|
|
64
|
+
expect(Math.abs(rho - 1) < 1e-9, true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('builds a scatter on the log10 abundance columns (Candidates)', async () => {
|
|
68
|
+
const df = makeCandidatesDf([100, 1000, 10, 50], [50, 500, 5, 25]);
|
|
69
|
+
df.name = 'Abundance correlation candidates test';
|
|
70
|
+
const tv = grok.shell.addTableView(df);
|
|
71
|
+
try {
|
|
72
|
+
const sp = createAbundanceCorrelation(df);
|
|
73
|
+
expect(sp !== null, true);
|
|
74
|
+
// hidden log10 columns added for both conditions
|
|
75
|
+
expect(df.col('log10 abundance: DMD') !== null, true);
|
|
76
|
+
expect(df.col('log10 abundance: WT') !== null, true);
|
|
77
|
+
// axes: x = WT (denominator / group2), y = DMD (numerator / group1)
|
|
78
|
+
expect(sp!.props.xColumnName, 'log10 abundance: WT');
|
|
79
|
+
expect(sp!.props.yColumnName, 'log10 abundance: DMD');
|
|
80
|
+
// Pearson/Spearman annotation goes in the on-canvas description overlay
|
|
81
|
+
// (the title is not painted in a docked viewer).
|
|
82
|
+
const desc = correlationDescription('DMD', 'WT', df, 'log10 abundance: WT', 'log10 abundance: DMD');
|
|
83
|
+
expect(desc.includes('Pearson'), true);
|
|
84
|
+
expect(desc.includes('Spearman'), true);
|
|
85
|
+
expect(desc.includes('DMD vs WT'), true);
|
|
86
|
+
} finally {
|
|
87
|
+
tv.close();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('Report path uses the geometric mean and colors by direction', async () => {
|
|
92
|
+
const df = makeReportDf();
|
|
93
|
+
df.name = 'Abundance correlation report test';
|
|
94
|
+
const tv = grok.shell.addTableView(df);
|
|
95
|
+
try {
|
|
96
|
+
const sp = createAbundanceCorrelation(df);
|
|
97
|
+
expect(sp !== null, true);
|
|
98
|
+
expect(sp!.props.colorColumnName, 'direction');
|
|
99
|
+
// Geometric mean of a log2-scale group = log10(2) * mean(log2 values).
|
|
100
|
+
// Row 0 Control {S1=1, S2=3} → mean=2 → 2*log10(2) ≈ 0.60206.
|
|
101
|
+
const yCol = df.col('log10 abundance: Control')!;
|
|
102
|
+
expect(Math.abs((yCol.get(0) as number) - 2 * Math.log10(2)) < 1e-5, true);
|
|
103
|
+
// Row 0 Treatment {S3=5, S4=7} → mean=6 → 6*log10(2) ≈ 1.80618.
|
|
104
|
+
const xCol = df.col('log10 abundance: Treatment')!;
|
|
105
|
+
expect(Math.abs((xCol.get(0) as number) - 6 * Math.log10(2)) < 1e-5, true);
|
|
106
|
+
} finally {
|
|
107
|
+
tv.close();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('draws a y=x diagonal reference line', async () => {
|
|
112
|
+
const df = makeReportDf();
|
|
113
|
+
const tv = grok.shell.addTableView(df);
|
|
114
|
+
try {
|
|
115
|
+
createAbundanceCorrelation(df);
|
|
116
|
+
const lines = df.meta.formulaLines.items;
|
|
117
|
+
const diagonal = lines.find((l: any) => {
|
|
118
|
+
const f = (l.formula ?? '') as string;
|
|
119
|
+
return typeof f === 'string' &&
|
|
120
|
+
f.includes('log10 abundance: Control') && f.includes('log10 abundance: Treatment');
|
|
121
|
+
});
|
|
122
|
+
expect(diagonal !== undefined, true);
|
|
123
|
+
} finally {
|
|
124
|
+
tv.close();
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('returns null when no abundance is present', async () => {
|
|
129
|
+
const df = DG.DataFrame.fromColumns([
|
|
130
|
+
DG.Column.fromStrings('ProteinGroups', ['P1', 'P2']),
|
|
131
|
+
DG.Column.fromFloat32Array('log2FC', new Float32Array([1, -1])),
|
|
132
|
+
]);
|
|
133
|
+
expect(createAbundanceCorrelation(df), null);
|
|
134
|
+
});
|
|
135
|
+
});
|
package/src/tests/analysis.ts
CHANGED
|
@@ -292,6 +292,46 @@ category('Normalization', () => {
|
|
|
292
292
|
expect(Math.abs(df.col('s1')!.get(0) - 1) < 0.01, true);
|
|
293
293
|
});
|
|
294
294
|
|
|
295
|
+
test('quantile normalization is NaN/Infinity-safe when the fullest column has < 2 values', async () => {
|
|
296
|
+
// maxValid < 2 → the rank interpolation would divide by (maxValid - 1) = 0.
|
|
297
|
+
// The guard must bail out, leaving the few observed values untouched (no NaN).
|
|
298
|
+
const c1 = DG.Column.fromFloat32Array('s1', new Float32Array([5, DG.FLOAT_NULL, DG.FLOAT_NULL]));
|
|
299
|
+
const c2 = DG.Column.fromFloat32Array('s2', new Float32Array([DG.FLOAT_NULL, 8, DG.FLOAT_NULL]));
|
|
300
|
+
c1.semType = SEMTYPE.INTENSITY;
|
|
301
|
+
c2.semType = SEMTYPE.INTENSITY;
|
|
302
|
+
const df = DG.DataFrame.fromColumns([
|
|
303
|
+
DG.Column.fromStrings('id', ['P0', 'P1', 'P2']),
|
|
304
|
+
c1, c2,
|
|
305
|
+
]);
|
|
306
|
+
quantileNormalize(df, ['s1', 's2']);
|
|
307
|
+
// No NaN/Infinity; nulls preserved; the lone observed values survive.
|
|
308
|
+
expect(Number.isFinite(df.col('s1')!.get(0)), true);
|
|
309
|
+
expect(Number.isFinite(df.col('s2')!.get(1)), true);
|
|
310
|
+
expect(df.col('s1')!.isNone(1), true);
|
|
311
|
+
expect(df.col('s2')!.isNone(0), true);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('quantile normalization tolerates a single-value column next to a full one', async () => {
|
|
315
|
+
// s2 has exactly one observed value: without the length < 2 guards, its
|
|
316
|
+
// per-column rank division (n - 1) = 0 would write NaN into that cell.
|
|
317
|
+
const c1 = DG.Column.fromFloat32Array('s1', new Float32Array([5, 3, 6, 2]));
|
|
318
|
+
const c2 = DG.Column.fromFloat32Array('s2',
|
|
319
|
+
new Float32Array([8, DG.FLOAT_NULL, DG.FLOAT_NULL, DG.FLOAT_NULL]));
|
|
320
|
+
c1.semType = SEMTYPE.INTENSITY;
|
|
321
|
+
c2.semType = SEMTYPE.INTENSITY;
|
|
322
|
+
const df = DG.DataFrame.fromColumns([
|
|
323
|
+
DG.Column.fromStrings('id', ['P0', 'P1', 'P2', 'P3']),
|
|
324
|
+
c1, c2,
|
|
325
|
+
]);
|
|
326
|
+
quantileNormalize(df, ['s1', 's2']);
|
|
327
|
+
// s1 fully normalized without NaN; s2's single value preserved, nulls intact.
|
|
328
|
+
for (let i = 0; i < 4; i++)
|
|
329
|
+
expect(Number.isFinite(df.col('s1')!.get(i)), true);
|
|
330
|
+
expect(Number.isFinite(df.col('s2')!.get(0)), true);
|
|
331
|
+
expect(Math.abs(df.col('s2')!.get(0) - 8) < 0.01, true);
|
|
332
|
+
expect(df.col('s2')!.isNone(1), true);
|
|
333
|
+
});
|
|
334
|
+
|
|
295
335
|
test('vsnNormalize falls back to quantile on R failure', async () => {
|
|
296
336
|
const c1 = DG.Column.fromFloat32Array('log2(s1)', new Float32Array([5, 2, 3, 6]));
|
|
297
337
|
const c2 = DG.Column.fromFloat32Array('log2(s2)', new Float32Array([4, 14, 8, 2]));
|
|
@@ -458,6 +498,29 @@ category('Imputation', () => {
|
|
|
458
498
|
expect(Math.abs(df.col('s1')!.get(4) - 6) < 0.01, true);
|
|
459
499
|
expect(df.getTag('proteomics.imputed'), 'true');
|
|
460
500
|
});
|
|
501
|
+
|
|
502
|
+
test('MinProb imputation never yields NaN or Infinity', async () => {
|
|
503
|
+
// Box-Muller draws u1 in (0, 1]; were it ever 0, log(0) = -Infinity would
|
|
504
|
+
// poison an imputed cell. Impute a large column so randomNormal runs many
|
|
505
|
+
// hundreds of times, and assert every filled value is finite.
|
|
506
|
+
const n = 2000;
|
|
507
|
+
const values = new Float32Array(n);
|
|
508
|
+
for (let i = 0; i < n; i++)
|
|
509
|
+
values[i] = (i % 2 === 0) ? DG.FLOAT_NULL : (10 + (i % 7));
|
|
510
|
+
const col = DG.Column.fromFloat32Array('intensity', values);
|
|
511
|
+
col.semType = SEMTYPE.INTENSITY;
|
|
512
|
+
const df = DG.DataFrame.fromColumns([
|
|
513
|
+
DG.Column.fromStrings('id', Array.from({length: n}, (_, i) => `P${i}`)),
|
|
514
|
+
col,
|
|
515
|
+
]);
|
|
516
|
+
const imputed = imputeMinProb(df, ['intensity']);
|
|
517
|
+
expect(imputed > 0, true);
|
|
518
|
+
const c = df.col('intensity')!;
|
|
519
|
+
for (let i = 0; i < n; i++) {
|
|
520
|
+
expect(c.isNone(i), false);
|
|
521
|
+
expect(Number.isFinite(c.get(i)), true);
|
|
522
|
+
}
|
|
523
|
+
});
|
|
461
524
|
});
|
|
462
525
|
|
|
463
526
|
// ── Differential Expression ─────────────────────────────────────────
|
|
@@ -164,7 +164,7 @@ category('Enrichment Visualization', () => {
|
|
|
164
164
|
expect(sub === rxjs.Subscription.EMPTY, true);
|
|
165
165
|
});
|
|
166
166
|
|
|
167
|
-
test('wireEnrichmentToVolcano selects
|
|
167
|
+
test('wireEnrichmentToVolcano selects a term\'s member genes on the volcano', async () => {
|
|
168
168
|
const enrichDf = makeMockEnrichmentDf(3);
|
|
169
169
|
// Set intersection to specific genes for first row
|
|
170
170
|
enrichDf.col('Intersection')!.set(0, 'TP53, BRCA1');
|
|
@@ -173,8 +173,9 @@ category('Enrichment Visualization', () => {
|
|
|
173
173
|
const sub = wireEnrichmentToVolcano(enrichDf, proteinDf);
|
|
174
174
|
expect(sub !== rxjs.Subscription.EMPTY, true);
|
|
175
175
|
|
|
176
|
-
//
|
|
177
|
-
enrichDf.
|
|
176
|
+
// Select the first enrichment term (selection channel, not current row).
|
|
177
|
+
enrichDf.selection.set(0, true, false);
|
|
178
|
+
enrichDf.selection.fireChanged();
|
|
178
179
|
|
|
179
180
|
// Check that TP53 (row 0) and BRCA1 (row 1) are selected
|
|
180
181
|
expect(proteinDf.selection.get(0), true); // TP53
|
|
@@ -186,7 +187,29 @@ category('Enrichment Visualization', () => {
|
|
|
186
187
|
sub.unsubscribe();
|
|
187
188
|
});
|
|
188
189
|
|
|
189
|
-
test('wireEnrichmentToVolcano
|
|
190
|
+
test('wireEnrichmentToVolcano unions member genes across multiple selected terms', async () => {
|
|
191
|
+
const enrichDf = makeMockEnrichmentDf(3);
|
|
192
|
+
enrichDf.col('Intersection')!.set(0, 'TP53');
|
|
193
|
+
enrichDf.col('Intersection')!.set(1, 'EGFR');
|
|
194
|
+
const proteinDf = makeMockProteinDf();
|
|
195
|
+
|
|
196
|
+
const sub = wireEnrichmentToVolcano(enrichDf, proteinDf);
|
|
197
|
+
|
|
198
|
+
// Select two terms at once (the multi-select gesture the volcano must reflect).
|
|
199
|
+
enrichDf.selection.set(0, true, false);
|
|
200
|
+
enrichDf.selection.set(1, true, false);
|
|
201
|
+
enrichDf.selection.fireChanged();
|
|
202
|
+
|
|
203
|
+
// Union: TP53 (row 0) from term 0, EGFR (row 2) from term 1; nothing else.
|
|
204
|
+
expect(proteinDf.selection.get(0), true); // TP53
|
|
205
|
+
expect(proteinDf.selection.get(2), true); // EGFR
|
|
206
|
+
expect(proteinDf.selection.get(1), false); // BRCA1 — in neither term
|
|
207
|
+
expect(proteinDf.selection.trueCount, 2);
|
|
208
|
+
|
|
209
|
+
sub.unsubscribe();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('wireEnrichmentToVolcano clears prior volcano selection on a new term selection', async () => {
|
|
190
213
|
const enrichDf = makeMockEnrichmentDf(3);
|
|
191
214
|
enrichDf.col('Intersection')!.set(0, 'TP53');
|
|
192
215
|
enrichDf.col('Intersection')!.set(1, 'EGFR');
|
|
@@ -198,8 +221,9 @@ category('Enrichment Visualization', () => {
|
|
|
198
221
|
|
|
199
222
|
const sub = wireEnrichmentToVolcano(enrichDf, proteinDf);
|
|
200
223
|
|
|
201
|
-
// Select first enrichment
|
|
202
|
-
enrichDf.
|
|
224
|
+
// Select first enrichment term (TP53 only)
|
|
225
|
+
enrichDf.selection.set(0, true, false);
|
|
226
|
+
enrichDf.selection.fireChanged();
|
|
203
227
|
|
|
204
228
|
// AKT1 (row 4) should no longer be selected since selection was cleared
|
|
205
229
|
expect(proteinDf.selection.get(4), false);
|
|
@@ -209,6 +233,67 @@ category('Enrichment Visualization', () => {
|
|
|
209
233
|
sub.unsubscribe();
|
|
210
234
|
});
|
|
211
235
|
|
|
236
|
+
test('wireEnrichmentToVolcano clears the volcano selection when no terms are selected', async () => {
|
|
237
|
+
const enrichDf = makeMockEnrichmentDf(3);
|
|
238
|
+
enrichDf.col('Intersection')!.set(0, 'TP53');
|
|
239
|
+
const proteinDf = makeMockProteinDf();
|
|
240
|
+
|
|
241
|
+
const sub = wireEnrichmentToVolcano(enrichDf, proteinDf);
|
|
242
|
+
|
|
243
|
+
enrichDf.selection.set(0, true, false);
|
|
244
|
+
enrichDf.selection.fireChanged();
|
|
245
|
+
expect(proteinDf.selection.trueCount, 1);
|
|
246
|
+
|
|
247
|
+
// Deselecting every term clears the volcano selection.
|
|
248
|
+
enrichDf.selection.setAll(false, false);
|
|
249
|
+
enrichDf.selection.fireChanged();
|
|
250
|
+
expect(proteinDf.selection.trueCount, 0);
|
|
251
|
+
|
|
252
|
+
sub.unsubscribe();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('wireEnrichmentToVolcano current term selects its proteins when no terms are selected', async () => {
|
|
256
|
+
const enrichDf = makeMockEnrichmentDf(3);
|
|
257
|
+
enrichDf.col('Intersection')!.set(0, 'TP53, BRCA1');
|
|
258
|
+
const proteinDf = makeMockProteinDf();
|
|
259
|
+
|
|
260
|
+
const sub = wireEnrichmentToVolcano(enrichDf, proteinDf);
|
|
261
|
+
|
|
262
|
+
// No terms selected → making a term the current row selects its proteins
|
|
263
|
+
// (the visible single-click behavior).
|
|
264
|
+
enrichDf.currentRowIdx = 0;
|
|
265
|
+
|
|
266
|
+
expect(proteinDf.selection.get(0), true); // TP53
|
|
267
|
+
expect(proteinDf.selection.get(1), true); // BRCA1
|
|
268
|
+
expect(proteinDf.selection.trueCount, 2);
|
|
269
|
+
|
|
270
|
+
sub.unsubscribe();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('wireEnrichmentToVolcano current term is ignored while a term selection is active', async () => {
|
|
274
|
+
const enrichDf = makeMockEnrichmentDf(3);
|
|
275
|
+
enrichDf.col('Intersection')!.set(0, 'TP53'); // term 0 → TP53 (row 0)
|
|
276
|
+
enrichDf.col('Intersection')!.set(1, 'EGFR, MYC'); // term 1 → EGFR (row 2), MYC (row 3)
|
|
277
|
+
const proteinDf = makeMockProteinDf();
|
|
278
|
+
|
|
279
|
+
const sub = wireEnrichmentToVolcano(enrichDf, proteinDf);
|
|
280
|
+
|
|
281
|
+
// Active selection on term 1 → EGFR + MYC selected.
|
|
282
|
+
enrichDf.selection.set(1, true, false);
|
|
283
|
+
enrichDf.selection.fireChanged();
|
|
284
|
+
expect(proteinDf.selection.trueCount, 2);
|
|
285
|
+
expect(proteinDf.selection.get(2), true); // EGFR
|
|
286
|
+
expect(proteinDf.selection.get(3), true); // MYC
|
|
287
|
+
|
|
288
|
+
// Changing the current row to term 0 must NOT clobber the active selection.
|
|
289
|
+
enrichDf.currentRowIdx = 0;
|
|
290
|
+
expect(proteinDf.selection.get(0), false); // TP53 not added
|
|
291
|
+
expect(proteinDf.selection.get(2), true); // EGFR still selected
|
|
292
|
+
expect(proteinDf.selection.trueCount, 2); // unchanged
|
|
293
|
+
|
|
294
|
+
sub.unsubscribe();
|
|
295
|
+
});
|
|
296
|
+
|
|
212
297
|
// Test B (light-touch declutter, 2026-06): the directional-split Up/Down
|
|
213
298
|
// dot+bar viewers now dock on the ENRICHMENT TableView, NOT the protein view —
|
|
214
299
|
// the user's volcano tab stays clean. This reverses the 13-09 co-dock-on-
|
|
@@ -237,10 +322,11 @@ category('Enrichment Visualization', () => {
|
|
|
237
322
|
expect(countViewersBoundTo(enrichTv, proteinDf), 0,
|
|
238
323
|
'enrichment view must not carry a proteinDf-bound volcano');
|
|
239
324
|
|
|
240
|
-
// Data-layer cross-link still fires
|
|
241
|
-
enrichDf.
|
|
325
|
+
// Data-layer cross-link still fires: selecting a term marks its proteins.
|
|
326
|
+
enrichDf.selection.set(0, true, false);
|
|
327
|
+
enrichDf.selection.fireChanged();
|
|
242
328
|
expect(proteinDf.selection.trueCount >= 1, true,
|
|
243
|
-
'selecting an enrichment
|
|
329
|
+
'selecting an enrichment term should mark matching protein rows');
|
|
244
330
|
} finally {
|
|
245
331
|
enrichTv.close();
|
|
246
332
|
proteinTv.close();
|
package/src/tests/enrichment.ts
CHANGED
|
@@ -202,7 +202,8 @@ category('Enrichment', () => {
|
|
|
202
202
|
const proteinDf = DG.DataFrame.fromColumns([geneCol]);
|
|
203
203
|
|
|
204
204
|
const sub = wireEnrichmentToVolcano(enrichDf, proteinDf);
|
|
205
|
-
enrichDf.
|
|
205
|
+
enrichDf.selection.set(0, true, false);
|
|
206
|
+
enrichDf.selection.fireChanged();
|
|
206
207
|
// TP53 + EGFR selected (from Intersection); MYC not.
|
|
207
208
|
expect(proteinDf.selection.trueCount, 2);
|
|
208
209
|
expect(proteinDf.selection.get(2), false);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {category, expect, test} from '@datagrok-libraries/test/src/test';
|
|
2
|
+
|
|
3
|
+
import {parseProjectVocabulary} from '../publishing/publish-settings';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Pure-parser tests for the admin-maintained `projectVocabulary` package
|
|
7
|
+
* setting. The Share for Review dialog turns this list into its controlled
|
|
8
|
+
* Project dropdown, so parsing (comma / newline / array, trim, dedup, empty →
|
|
9
|
+
* gate) is the contract the dialog depends on.
|
|
10
|
+
*/
|
|
11
|
+
category('ProjectVocabulary', () => {
|
|
12
|
+
test('comma-separated string parses to trimmed list', async () => {
|
|
13
|
+
expect(JSON.stringify(parseProjectVocabulary('DMD-muscle-2026, SMA-cardiac-panel , ALS-cohort-2025')),
|
|
14
|
+
JSON.stringify(['DMD-muscle-2026', 'SMA-cardiac-panel', 'ALS-cohort-2025']));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('newline-separated string parses (admin pasted a list)', async () => {
|
|
18
|
+
expect(JSON.stringify(parseProjectVocabulary('Project A\nProject B\r\nProject C')),
|
|
19
|
+
JSON.stringify(['Project A', 'Project B', 'Project C']));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('array value parses (future string_list property)', async () => {
|
|
23
|
+
expect(JSON.stringify(parseProjectVocabulary(['P1', ' P2 ', 'P3'])),
|
|
24
|
+
JSON.stringify(['P1', 'P2', 'P3']));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('duplicates removed, order preserved', async () => {
|
|
28
|
+
expect(JSON.stringify(parseProjectVocabulary('B, A, B, C, A')),
|
|
29
|
+
JSON.stringify(['B', 'A', 'C']));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('empty / blank / nullish → [] (dialog treats as "ask an admin")', async () => {
|
|
33
|
+
expect(parseProjectVocabulary('').length, 0);
|
|
34
|
+
expect(parseProjectVocabulary(' , , \n ').length, 0);
|
|
35
|
+
expect(parseProjectVocabulary(undefined).length, 0);
|
|
36
|
+
expect(parseProjectVocabulary(null).length, 0);
|
|
37
|
+
expect(parseProjectVocabulary([]).length, 0);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -5,7 +5,7 @@ import {awaitCheck, category, delay, expect, test} from '@datagrok-libraries/tes
|
|
|
5
5
|
import {SEMTYPE} from '../utils/proteomics-types';
|
|
6
6
|
import {
|
|
7
7
|
META_COLUMNS, PUBLISHED_TAGS, PublishOptions, PublishedMetadata,
|
|
8
|
-
isPublished,
|
|
8
|
+
isPublished, slugifyProject,
|
|
9
9
|
} from '../publishing/publish-state';
|
|
10
10
|
import {publishAnalysis} from '../publishing/publish-project';
|
|
11
11
|
import {
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from '../publishing/assert-published-shape';
|
|
14
14
|
import {recoverPublishedProject} from '../publishing/post-open-recovery';
|
|
15
15
|
|
|
16
|
-
const
|
|
16
|
+
const DEFAULT_PROJECT_PREFIX = 'pub-roundtrip-';
|
|
17
17
|
const DEFAULT_FC = 1.0;
|
|
18
18
|
const DEFAULT_P = 0.05;
|
|
19
19
|
|
|
@@ -150,15 +150,15 @@ async function cleanupProject(project: DG.Project | null): Promise<void> {
|
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
/**
|
|
153
|
-
* publishAnalysis creates a per-
|
|
153
|
+
* publishAnalysis creates a per-project child Space `Proteomics-Review-<slug>`
|
|
154
154
|
* under the `Proteomics-Reviews` umbrella (publish-project.ts step 5) but
|
|
155
155
|
* returns only the leaf project. cleanupProject removes the leaf; this removes
|
|
156
156
|
* the otherwise-orphaned child Space. Best-effort and idempotent — runs in
|
|
157
157
|
* each test's finally after the leaf is deleted.
|
|
158
158
|
*/
|
|
159
|
-
async function cleanupChildSpace(
|
|
159
|
+
async function cleanupChildSpace(projectName: string): Promise<void> {
|
|
160
160
|
const dapiAny = grok.dapi as any;
|
|
161
|
-
const childName = `Proteomics-Review-${
|
|
161
|
+
const childName = `Proteomics-Review-${slugifyProject(projectName)}`;
|
|
162
162
|
try {
|
|
163
163
|
const all: any[] = await dapiAny.spaces.list();
|
|
164
164
|
const umbrella = (all ?? []).find((p) =>
|
|
@@ -196,7 +196,7 @@ function expectedAllowlistFromTrimmedView(): string[] {
|
|
|
196
196
|
function expectedMetaForTest(
|
|
197
197
|
project: DG.Project,
|
|
198
198
|
sourceDf: DG.DataFrame,
|
|
199
|
-
|
|
199
|
+
projectName: string,
|
|
200
200
|
includesEnrichment: boolean,
|
|
201
201
|
): PublishedMetadata {
|
|
202
202
|
// Build the expected contract from what was ACTUALLY published, not from
|
|
@@ -209,7 +209,7 @@ function expectedMetaForTest(
|
|
|
209
209
|
const publishId = ((project as any).options?.[PUBLISHED_TAGS.PUBLISHED_ID] as string | undefined) ?? '';
|
|
210
210
|
const deMethod = sourceDf.getTag('proteomics.de_method') ?? 't-test';
|
|
211
211
|
return {
|
|
212
|
-
|
|
212
|
+
project: projectName,
|
|
213
213
|
publishedAt: new Date(), // not asserted
|
|
214
214
|
publishedBy: (grok.shell.user as any)?.friendlyName ?? '',
|
|
215
215
|
publishedByEmail: (grok.shell.user as any)?.email ?? null, // not asserted
|
|
@@ -233,21 +233,21 @@ category('Publishing', () => {
|
|
|
233
233
|
try { (tv as any).scatterPlot?.({x: 'log2FC', y: 'adj.p-value'}); } catch { /* swallow */ }
|
|
234
234
|
await delay(100);
|
|
235
235
|
|
|
236
|
-
const
|
|
236
|
+
const projectName = `${DEFAULT_PROJECT_PREFIX}t1-${Date.now()}`;
|
|
237
237
|
const group = await pickAnyGroup();
|
|
238
|
-
const opts: PublishOptions = {
|
|
238
|
+
const opts: PublishOptions = {project: projectName, reviewerGroup: group, note: 'Phase 15 roundtrip Test 1', priorVersion: null};
|
|
239
239
|
|
|
240
240
|
let project: DG.Project | null = null;
|
|
241
241
|
try {
|
|
242
242
|
project = await publishAnalysis(df, opts);
|
|
243
243
|
expect(!!project, true);
|
|
244
244
|
|
|
245
|
-
const
|
|
246
|
-
const meta = expectedMetaForTest(project!, df,
|
|
245
|
+
const publishedName = (project as any).name as string;
|
|
246
|
+
const meta = expectedMetaForTest(project!, df, projectName, false);
|
|
247
247
|
const allowlist = expectedAllowlistFromTrimmedView();
|
|
248
248
|
const contract = buildExpectedContract(
|
|
249
249
|
`${df.name}_published_${new Date().toISOString().slice(0, 10)}`,
|
|
250
|
-
|
|
250
|
+
publishedName,
|
|
251
251
|
allowlist,
|
|
252
252
|
meta,
|
|
253
253
|
false,
|
|
@@ -282,7 +282,7 @@ category('Publishing', () => {
|
|
|
282
282
|
expect(hasPLine, true);
|
|
283
283
|
} finally {
|
|
284
284
|
await cleanupProject(project);
|
|
285
|
-
await cleanupChildSpace(
|
|
285
|
+
await cleanupChildSpace(projectName);
|
|
286
286
|
}
|
|
287
287
|
});
|
|
288
288
|
|
|
@@ -297,21 +297,21 @@ category('Publishing', () => {
|
|
|
297
297
|
try { (proteinTv as any).scatterPlot?.({x: 'log2FC', y: 'adj.p-value'}); } catch { /* swallow */ }
|
|
298
298
|
await delay(100);
|
|
299
299
|
|
|
300
|
-
const
|
|
300
|
+
const projectName = `${DEFAULT_PROJECT_PREFIX}t2-${Date.now()}`;
|
|
301
301
|
const group = await pickAnyGroup();
|
|
302
|
-
const opts: PublishOptions = {
|
|
302
|
+
const opts: PublishOptions = {project: projectName, reviewerGroup: group, note: 'Phase 15 roundtrip Test 2', priorVersion: null};
|
|
303
303
|
|
|
304
304
|
let project: DG.Project | null = null;
|
|
305
305
|
try {
|
|
306
306
|
project = await publishAnalysis(protein, opts);
|
|
307
307
|
expect(!!project, true);
|
|
308
308
|
|
|
309
|
-
const
|
|
310
|
-
const meta = expectedMetaForTest(project!, protein,
|
|
309
|
+
const publishedName = (project as any).name as string;
|
|
310
|
+
const meta = expectedMetaForTest(project!, protein, projectName, true);
|
|
311
311
|
const allowlist = expectedAllowlistFromTrimmedView();
|
|
312
312
|
const contract = buildExpectedContract(
|
|
313
313
|
`${protein.name}_published_${new Date().toISOString().slice(0, 10)}`,
|
|
314
|
-
|
|
314
|
+
publishedName,
|
|
315
315
|
allowlist,
|
|
316
316
|
meta,
|
|
317
317
|
true,
|
|
@@ -350,7 +350,7 @@ category('Publishing', () => {
|
|
|
350
350
|
expect(afterCount > beforeCount, true);
|
|
351
351
|
} finally {
|
|
352
352
|
await cleanupProject(project);
|
|
353
|
-
await cleanupChildSpace(
|
|
353
|
+
await cleanupChildSpace(projectName);
|
|
354
354
|
}
|
|
355
355
|
});
|
|
356
356
|
|
|
@@ -369,12 +369,12 @@ category('Publishing', () => {
|
|
|
369
369
|
try { grok.shell.v = proteinTv; } catch { /* swallow */ }
|
|
370
370
|
await delay(50);
|
|
371
371
|
|
|
372
|
-
const
|
|
372
|
+
const projectName = `${DEFAULT_PROJECT_PREFIX}restore-${Date.now()}`;
|
|
373
373
|
const group = await pickAnyGroup();
|
|
374
374
|
// verify:false keeps the test fast — Step 10 restore runs regardless of the
|
|
375
375
|
// round-trip verification toggle, and this test is specifically about restore.
|
|
376
376
|
const opts: PublishOptions = {
|
|
377
|
-
|
|
377
|
+
project: projectName, reviewerGroup: group, note: 'restore test', priorVersion: null, verify: false,
|
|
378
378
|
} as PublishOptions;
|
|
379
379
|
|
|
380
380
|
let project: DG.Project | null = null;
|
|
@@ -401,7 +401,7 @@ category('Publishing', () => {
|
|
|
401
401
|
expect((grok.shell.tv?.dataFrame as any)?.dart, proteinDart);
|
|
402
402
|
} finally {
|
|
403
403
|
await cleanupProject(project);
|
|
404
|
-
await cleanupChildSpace(
|
|
404
|
+
await cleanupChildSpace(projectName);
|
|
405
405
|
}
|
|
406
406
|
});
|
|
407
407
|
|
|
@@ -409,13 +409,13 @@ category('Publishing', () => {
|
|
|
409
409
|
async () => {
|
|
410
410
|
const df = createSyntheticDeFixture();
|
|
411
411
|
const baselineL2FC = df.col('log2FC')!.get(0);
|
|
412
|
-
const
|
|
412
|
+
const baselineProject = `${DEFAULT_PROJECT_PREFIX}t3-${Date.now()}`;
|
|
413
413
|
|
|
414
414
|
grok.shell.addTableView(df);
|
|
415
415
|
await delay(100);
|
|
416
416
|
|
|
417
417
|
const group = await pickAnyGroup();
|
|
418
|
-
const opts: PublishOptions = {
|
|
418
|
+
const opts: PublishOptions = {project: baselineProject, reviewerGroup: group, note: '', priorVersion: null};
|
|
419
419
|
|
|
420
420
|
let project: DG.Project | null = null;
|
|
421
421
|
try {
|
|
@@ -436,7 +436,7 @@ category('Publishing', () => {
|
|
|
436
436
|
// Mutate the SOURCE DF (the one we still hold in memory)
|
|
437
437
|
df.col('log2FC')!.set(0, 999.999);
|
|
438
438
|
try { df.columns.remove('Gene Name'); } catch { /* may have been allowlist-only */ }
|
|
439
|
-
df.setTag(PUBLISHED_TAGS.
|
|
439
|
+
df.setTag(PUBLISHED_TAGS.PUBLISHED_PROJECT, 'CHANGED-PROJECT');
|
|
440
440
|
|
|
441
441
|
// Reopen the project AGAIN — clone must be unchanged
|
|
442
442
|
grok.shell.closeAll();
|
|
@@ -449,25 +449,25 @@ category('Publishing', () => {
|
|
|
449
449
|
const reL2FC = reDf.col('log2FC')!.get(0);
|
|
450
450
|
expect(reL2FC, baselineL2FC);
|
|
451
451
|
expect(!!reDf.col('Gene Name'), true);
|
|
452
|
-
const
|
|
453
|
-
expect(
|
|
454
|
-
expect(
|
|
452
|
+
const reProject = reDf.getTag(PUBLISHED_TAGS.PUBLISHED_PROJECT);
|
|
453
|
+
expect(reProject === 'CHANGED-PROJECT', false);
|
|
454
|
+
expect(reProject === baselineProject, true);
|
|
455
455
|
} finally {
|
|
456
456
|
await cleanupProject(project);
|
|
457
|
-
await cleanupChildSpace(
|
|
457
|
+
await cleanupChildSpace(baselineProject);
|
|
458
458
|
}
|
|
459
459
|
});
|
|
460
460
|
|
|
461
461
|
test('republish creates v2 with bidirectional superseded_by + supersedes pointers (W-8 dual-write)',
|
|
462
462
|
async () => {
|
|
463
463
|
const df = createSyntheticDeFixture();
|
|
464
|
-
const
|
|
464
|
+
const projectName = `${DEFAULT_PROJECT_PREFIX}t4-${Date.now()}`;
|
|
465
465
|
|
|
466
466
|
grok.shell.addTableView(df);
|
|
467
467
|
await delay(100);
|
|
468
468
|
|
|
469
469
|
const group = await pickAnyGroup();
|
|
470
|
-
const optsV1: PublishOptions = {
|
|
470
|
+
const optsV1: PublishOptions = {project: projectName, reviewerGroup: group, note: 'v1', priorVersion: null};
|
|
471
471
|
|
|
472
472
|
let projectV1: DG.Project | null = null;
|
|
473
473
|
let projectV2: DG.Project | null = null;
|
|
@@ -481,7 +481,7 @@ category('Publishing', () => {
|
|
|
481
481
|
// Re-discover the source DF view (publishAnalysis opened the trimmed view)
|
|
482
482
|
await delay(200);
|
|
483
483
|
|
|
484
|
-
const optsV2: PublishOptions = {
|
|
484
|
+
const optsV2: PublishOptions = {project: projectName, reviewerGroup: group, note: 'v2', priorVersion: projectV1};
|
|
485
485
|
projectV2 = await publishAnalysis(df, optsV2);
|
|
486
486
|
expect(!!projectV2, true);
|
|
487
487
|
const v2Id = (projectV2 as any).id;
|
|
@@ -525,8 +525,8 @@ category('Publishing', () => {
|
|
|
525
525
|
} finally {
|
|
526
526
|
await cleanupProject(projectV2);
|
|
527
527
|
await cleanupProject(projectV1);
|
|
528
|
-
// Both versions share one
|
|
529
|
-
await cleanupChildSpace(
|
|
528
|
+
// Both versions share one project → one child Space to remove.
|
|
529
|
+
await cleanupChildSpace(projectName);
|
|
530
530
|
}
|
|
531
531
|
});
|
|
532
532
|
|
|
@@ -546,9 +546,9 @@ category('Publishing', () => {
|
|
|
546
546
|
grok.shell.addTableView(df);
|
|
547
547
|
await delay(100);
|
|
548
548
|
|
|
549
|
-
const
|
|
549
|
+
const projectName = `${DEFAULT_PROJECT_PREFIX}t5-${Date.now()}`;
|
|
550
550
|
const opts: PublishOptions = {
|
|
551
|
-
|
|
551
|
+
project: projectName,
|
|
552
552
|
reviewerGroup: userGroup,
|
|
553
553
|
note: 'negative test',
|
|
554
554
|
priorVersion: null,
|
|
@@ -566,13 +566,13 @@ category('Publishing', () => {
|
|
|
566
566
|
const hitsD03 = caughtMessage.includes('Reviewer group already has elevated access via Space inheritance');
|
|
567
567
|
expect(hitsD03, true);
|
|
568
568
|
|
|
569
|
-
// Verify NO Project was left behind under the
|
|
569
|
+
// Verify NO Project was left behind under the project slug
|
|
570
570
|
try {
|
|
571
571
|
const slugSearch = `Proteomics-Review-%`;
|
|
572
572
|
const lingering: any[] = await dapiAny.projects.filter(`name like "${slugSearch}"`).list();
|
|
573
|
-
const
|
|
574
|
-
typeof p?.name === 'string' && p.name.includes(
|
|
575
|
-
expect(
|
|
573
|
+
const matchesThisProject = (lingering ?? []).filter((p: any) =>
|
|
574
|
+
typeof p?.name === 'string' && p.name.includes(projectName));
|
|
575
|
+
expect(matchesThisProject.length, 0);
|
|
576
576
|
} catch { /* swallow — best-effort search */ }
|
|
577
577
|
|
|
578
578
|
// If somehow a project was returned (gate failed to throw), clean up so we don't leak.
|