@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +18 -5
  3. package/dist/package-test.js +1 -1
  4. package/dist/package-test.js.map +1 -1
  5. package/dist/package.js +1 -1
  6. package/dist/package.js.map +1 -1
  7. package/docs/personas-and-capabilities.md +16 -0
  8. package/docs/publishing-design-rationale.md +140 -0
  9. package/package.json +20 -5
  10. package/package.png +0 -0
  11. package/src/analysis/imputation.ts +3 -1
  12. package/src/analysis/normalization.ts +9 -4
  13. package/src/analysis/spc-storage.ts +1 -1
  14. package/src/menu.ts +5 -2
  15. package/src/package-api.ts +10 -2
  16. package/src/package-test.ts +3 -1
  17. package/src/package.g.ts +17 -3
  18. package/src/package.ts +31 -15
  19. package/src/panels/published-analysis-panel.ts +2 -2
  20. package/src/publishing/assert-published-shape.ts +2 -2
  21. package/src/publishing/package-settings-editor.ts +125 -0
  22. package/src/publishing/publish-project.ts +8 -8
  23. package/src/publishing/publish-settings.ts +57 -3
  24. package/src/publishing/publish-state.ts +45 -16
  25. package/src/publishing/reviewer-groups.ts +24 -0
  26. package/src/publishing/share-dialog.ts +42 -27
  27. package/src/publishing/trim-dataframe.ts +8 -1
  28. package/src/tests/abundance-correlation.ts +135 -0
  29. package/src/tests/analysis.ts +63 -0
  30. package/src/tests/enrichment-visualization.ts +95 -9
  31. package/src/tests/enrichment.ts +2 -1
  32. package/src/tests/project-vocabulary.ts +39 -0
  33. package/src/tests/publish-roundtrip.ts +40 -40
  34. package/src/tests/publish-spike.ts +2 -2
  35. package/src/tests/rank-abundance.ts +85 -0
  36. package/src/utils/abundance-detection.ts +145 -0
  37. package/src/viewers/abundance-correlation.ts +208 -0
  38. package/src/viewers/enrichment-viewers.ts +58 -24
  39. package/src/viewers/rank-abundance.ts +153 -0
  40. package/src/viewers/volcano.ts +3 -0
  41. package/test-console-output-1.log +1145 -1090
  42. package/test-record-1.mp4 +0 -0
  43. package/src/tests/group-mean-correlation.ts +0 -139
  44. 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
+ });
@@ -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 matching genes on protein DataFrame', async () => {
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
- // Trigger row change
177
- enrichDf.currentRowIdx = 0;
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 clears selection before new selection', async () => {
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 row (TP53 only)
202
- enrichDf.currentRowIdx = 0;
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 through the unchanged subscriptions.
241
- enrichDf.currentRowIdx = 0;
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 row should still highlight matching protein rows');
329
+ 'selecting an enrichment term should mark matching protein rows');
244
330
  } finally {
245
331
  enrichTv.close();
246
332
  proteinTv.close();
@@ -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.currentRowIdx = 0;
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, slugifyTarget,
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 DEFAULT_TARGET_PREFIX = 'pub-roundtrip-';
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-target child Space `Proteomics-Review-<slug>`
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(target: string): Promise<void> {
159
+ async function cleanupChildSpace(projectName: string): Promise<void> {
160
160
  const dapiAny = grok.dapi as any;
161
- const childName = `Proteomics-Review-${slugifyTarget(target)}`;
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
- target: string,
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
- target,
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 target = `${DEFAULT_TARGET_PREFIX}t1-${Date.now()}`;
236
+ const projectName = `${DEFAULT_PROJECT_PREFIX}t1-${Date.now()}`;
237
237
  const group = await pickAnyGroup();
238
- const opts: PublishOptions = {target, reviewerGroup: group, note: 'Phase 15 roundtrip Test 1', priorVersion: null};
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 projectName = (project as any).name as string;
246
- const meta = expectedMetaForTest(project!, df, target, false);
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
- projectName,
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(target);
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 target = `${DEFAULT_TARGET_PREFIX}t2-${Date.now()}`;
300
+ const projectName = `${DEFAULT_PROJECT_PREFIX}t2-${Date.now()}`;
301
301
  const group = await pickAnyGroup();
302
- const opts: PublishOptions = {target, reviewerGroup: group, note: 'Phase 15 roundtrip Test 2', priorVersion: null};
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 projectName = (project as any).name as string;
310
- const meta = expectedMetaForTest(project!, protein, target, true);
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
- projectName,
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(target);
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 target = `${DEFAULT_TARGET_PREFIX}restore-${Date.now()}`;
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
- target, reviewerGroup: group, note: 'restore test', priorVersion: null, verify: false,
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(target);
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 baselineTarget = `${DEFAULT_TARGET_PREFIX}t3-${Date.now()}`;
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 = {target: baselineTarget, reviewerGroup: group, note: '', priorVersion: null};
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.PUBLISHED_TARGET, 'CHANGED-TARGET');
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 reTarget = reDf.getTag(PUBLISHED_TAGS.PUBLISHED_TARGET);
453
- expect(reTarget === 'CHANGED-TARGET', false);
454
- expect(reTarget === baselineTarget, true);
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(baselineTarget);
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 target = `${DEFAULT_TARGET_PREFIX}t4-${Date.now()}`;
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 = {target, reviewerGroup: group, note: 'v1', priorVersion: null};
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 = {target, reviewerGroup: group, note: 'v2', priorVersion: projectV1};
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 target → one child Space to remove.
529
- await cleanupChildSpace(target);
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 target = `${DEFAULT_TARGET_PREFIX}t5-${Date.now()}`;
549
+ const projectName = `${DEFAULT_PROJECT_PREFIX}t5-${Date.now()}`;
550
550
  const opts: PublishOptions = {
551
- target,
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 target slug
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 matchesThisTarget = (lingering ?? []).filter((p: any) =>
574
- typeof p?.name === 'string' && p.name.includes(target));
575
- expect(matchesThisTarget.length, 0);
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.