@datagrok/proteomics 1.0.0 → 1.2.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 (116) hide show
  1. package/CHANGELOG.md +52 -3
  2. package/CLAUDE.md +178 -0
  3. package/README.md +195 -3
  4. package/detectors.js +152 -9
  5. package/dist/package-test.js +1 -1744
  6. package/dist/package-test.js.map +1 -1
  7. package/dist/package.js +1 -146
  8. package/dist/package.js.map +1 -1
  9. package/docs/personas-and-capabilities.md +165 -0
  10. package/files/demo/README.md +264 -0
  11. package/files/demo/cptac-spike-in.txt +1571 -0
  12. package/files/demo/enrichment-demo.csv +120 -0
  13. package/files/demo/fragpipe-smoke-test.tsv +11 -0
  14. package/files/demo/proteinGroups.txt +28 -0
  15. package/files/demo/spectronaut-hye-candidates.tsv +94 -0
  16. package/files/demo/spectronaut-hye-demo.tsv +8761 -0
  17. package/files/demo/spectronaut-hye-mix.tsv +8761 -0
  18. package/files/demo/spectronaut-hye-precursor-golden.json +938 -0
  19. package/files/demo/spectronaut-hye-precursor-golden.tsv +235 -0
  20. package/files/demo/spectronaut-hye-precursor.tsv +493 -0
  21. package/images/enrichment-crosslink.png +0 -0
  22. package/images/enrichment-term-selected.png +0 -0
  23. package/images/hero.png +0 -0
  24. package/images/pipeline.svg +80 -0
  25. package/package.json +87 -62
  26. package/scripts/deqms_de.R +60 -0
  27. package/scripts/limma_de.R +42 -0
  28. package/scripts/vsn_normalize.R +19 -0
  29. package/src/analysis/differential-expression.ts +450 -0
  30. package/src/analysis/enrichment-export.ts +101 -0
  31. package/src/analysis/enrichment.ts +602 -0
  32. package/src/analysis/experiment-setup.ts +199 -0
  33. package/src/analysis/imputation.ts +407 -0
  34. package/src/analysis/log2-scale.ts +139 -0
  35. package/src/analysis/normalization.ts +255 -0
  36. package/src/analysis/pca.ts +254 -0
  37. package/src/analysis/spc-storage.ts +515 -0
  38. package/src/analysis/spc.ts +544 -0
  39. package/src/analysis/subcellular-location.ts +431 -0
  40. package/src/demo/enrichment-demo.ts +94 -0
  41. package/src/demo/proteomics-demo.ts +123 -0
  42. package/src/global.d.ts +15 -0
  43. package/src/menu.ts +133 -0
  44. package/src/package-api.ts +136 -14
  45. package/src/package-test.ts +45 -20
  46. package/src/package.g.ts +161 -0
  47. package/src/package.ts +1029 -17
  48. package/src/panels/protein-focus.ts +63 -0
  49. package/src/panels/published-analysis-panel.ts +151 -0
  50. package/src/panels/uniprot-panel.ts +349 -0
  51. package/src/parsers/fragpipe-parser.ts +200 -0
  52. package/src/parsers/generic-parser.ts +197 -0
  53. package/src/parsers/maxquant-parser.ts +162 -0
  54. package/src/parsers/shared-utils.ts +163 -0
  55. package/src/parsers/spectronaut-candidates-parser.ts +307 -0
  56. package/src/parsers/spectronaut-parser.ts +604 -0
  57. package/src/publishing/assert-published-shape.ts +260 -0
  58. package/src/publishing/post-open-recovery.ts +104 -0
  59. package/src/publishing/publish-project.ts +515 -0
  60. package/src/publishing/publish-settings.ts +59 -0
  61. package/src/publishing/publish-state.ts +316 -0
  62. package/src/publishing/share-dialog.ts +171 -0
  63. package/src/publishing/trim-dataframe.ts +247 -0
  64. package/src/tests/analysis.ts +658 -0
  65. package/src/tests/enrichment-export.ts +61 -0
  66. package/src/tests/enrichment-visualization.ts +340 -0
  67. package/src/tests/enrichment.ts +224 -0
  68. package/src/tests/fragpipe-e2e.ts +74 -0
  69. package/src/tests/fragpipe-parser.ts +147 -0
  70. package/src/tests/gene-label-resolver.ts +387 -0
  71. package/src/tests/generic-parser.ts +152 -0
  72. package/src/tests/group-mean-correlation.ts +139 -0
  73. package/src/tests/log2-scale.ts +93 -0
  74. package/src/tests/organisms.ts +56 -0
  75. package/src/tests/parsers.ts +182 -0
  76. package/src/tests/publish-roundtrip.ts +584 -0
  77. package/src/tests/publish-spike.ts +377 -0
  78. package/src/tests/qc-dashboard.ts +210 -0
  79. package/src/tests/smart-pathway-filter.ts +193 -0
  80. package/src/tests/spc-formula-lines-spike.ts +129 -0
  81. package/src/tests/spc.ts +640 -0
  82. package/src/tests/spectronaut-candidates-e2e.ts +140 -0
  83. package/src/tests/spectronaut-candidates-parser.ts +398 -0
  84. package/src/tests/spectronaut-parser.ts +668 -0
  85. package/src/tests/subcellular-location.ts +361 -0
  86. package/src/tests/uniprot-panel.ts +202 -0
  87. package/src/tests/volcano.ts +603 -0
  88. package/src/utils/column-detection.ts +28 -0
  89. package/src/utils/gene-label-resolver.ts +443 -0
  90. package/src/utils/organisms.ts +82 -0
  91. package/src/utils/proteomics-types.ts +30 -0
  92. package/src/viewers/enrichment-viewers.ts +274 -0
  93. package/src/viewers/group-mean-correlation.ts +218 -0
  94. package/src/viewers/heatmap.ts +168 -0
  95. package/src/viewers/pca-plot.ts +169 -0
  96. package/src/viewers/qc-computations.ts +266 -0
  97. package/src/viewers/qc-dashboard.ts +176 -0
  98. package/src/viewers/spc-dashboard.ts +755 -0
  99. package/src/viewers/volcano.ts +690 -0
  100. package/test-console-output-1.log +2055 -0
  101. package/test-record-1.mp4 +0 -0
  102. package/tools/derive-precursor-golden-sidecar.mjs +81 -0
  103. package/tools/generate-enrichment-fixture.sh +160 -0
  104. package/tools/generate-spectronaut-candidates-fixture.mjs +212 -0
  105. package/tools/generate-spectronaut-precursor-fixture.mjs +128 -0
  106. package/tools/spectronaut-aggregate.sh +46 -0
  107. package/tools/spectronaut-aggregate.sql +80 -0
  108. package/tsconfig.json +18 -71
  109. package/webpack.config.js +86 -45
  110. package/.eslintignore +0 -1
  111. package/.eslintrc.json +0 -56
  112. package/LICENSE +0 -674
  113. package/package.png +0 -0
  114. package/scripts/number_antibody.py +0 -190
  115. package/scripts/number_antibody_abnumber.py +0 -177
  116. package/scripts/number_antibody_anarci.py +0 -200
@@ -0,0 +1,361 @@
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 {SEMTYPE} from '../utils/proteomics-types';
5
+ import {
6
+ getSubcellularLocations,
7
+ LOCATION_COLORS,
8
+ mergeStreamTsv,
9
+ ORGANISM_TAXONOMY,
10
+ parseSubcellularLocation,
11
+ ProgressCb,
12
+ runWithConcurrency,
13
+ STORE,
14
+ } from '../analysis/subcellular-location';
15
+ import {ORGANISM_LIST} from '../analysis/enrichment';
16
+ import {ensureLocationColumn} from '../viewers/volcano';
17
+
18
+ // CRITICAL: `grok.dapi.userDataStorage` returns a NEW UserDataStorage instance
19
+ // per access (see js-api dapi.ts: `get userDataStorage() { return new ... }`).
20
+ // Patching the instance's `put`/`get` would only stick for one call, then the
21
+ // next access creates a fresh instance with the original methods. Patch the
22
+ // PROTOTYPE so every fresh instance picks up our spy. Restore the originals in
23
+ // finally to avoid leaking across tests.
24
+ function patchUserDataStorage(opts: {
25
+ get?: (name: string, currentUser?: boolean) => Promise<any>;
26
+ put?: (name: string, data: any, currentUser?: boolean) => Promise<void>;
27
+ }): () => void {
28
+ const proto = (grok.dapi.userDataStorage as any).constructor.prototype;
29
+ const origGet = proto.get;
30
+ const origPut = proto.put;
31
+ if (opts.get) proto.get = opts.get;
32
+ if (opts.put) proto.put = opts.put;
33
+ return () => {
34
+ proto.get = origGet;
35
+ proto.put = origPut;
36
+ };
37
+ }
38
+
39
+ // Locked CK-omics hex palette (Subcellular_Location_Classification_README.txt §COLOR
40
+ // SCHEME / CKomics_tool2.py get_location_colors). ARGB = 0xFF000000 | RRGGBB.
41
+ const EXPECTED_HEX: Record<string, string> = {
42
+ 'Nucleus': '#FF6B6B', 'Cytoplasm': '#ECDC44', 'Mitochondria': '#45B7D1',
43
+ 'ER': '#96CEB4', 'Golgi': '#FAAFFE', 'Plasma Membrane': '#DDA0DD',
44
+ 'Lysosome': '#F39C12', 'Peroxisome': '#E17055', 'Ribosome': '#A29BFE',
45
+ 'Extracellular': '#FD79A8', 'Vesicles': '#FDCB6E', 'Unknown': '#CCCCCC',
46
+ };
47
+
48
+ category('SubcellularLocation', () => {
49
+ test('SEMTYPE.SUBCELLULAR_LOCATION literal is stable', async () => {
50
+ expect(SEMTYPE.SUBCELLULAR_LOCATION, 'Proteomics-SubcellularLocation');
51
+ });
52
+
53
+ // H1 guard: every organism the enrichment dialog offers must have a taxonomy
54
+ // id, or its gene-fallback subcellular lookup silently runs unfiltered and can
55
+ // resolve to the wrong species. Locks the two lists in sync against drift.
56
+ test('every ORGANISM_LIST code has an ORGANISM_TAXONOMY mapping', async () => {
57
+ for (const org of ORGANISM_LIST) {
58
+ expect(typeof ORGANISM_TAXONOMY[org.code], 'number');
59
+ expect(ORGANISM_TAXONOMY[org.code] > 0, true);
60
+ }
61
+ });
62
+
63
+ test('classifier: subcellular field used before GO fallback', async () => {
64
+ // Subcell says Nucleus, GO says mitochondrion — subcell wins (tried first).
65
+ expect(parseSubcellularLocation('Nucleus {ECO:0000269}.', 'mitochondrion [GO:0005739]'),
66
+ 'Nucleus');
67
+ });
68
+
69
+ test('classifier: GO used only when subcellular field has no match', async () => {
70
+ expect(parseSubcellularLocation('', 'cytoplasm [GO:0005737]'), 'Cytoplasm');
71
+ expect(parseSubcellularLocation('phosphopyruvate hydratase complex',
72
+ 'ribosome [GO:0005840]'), 'Ribosome');
73
+ });
74
+
75
+ test('classifier: earliest character position wins across categories', async () => {
76
+ // "Mitochondrion" appears before "Nucleus" → Mitochondria.
77
+ expect(parseSubcellularLocation('Mitochondrion. Nucleus.', ''), 'Mitochondria');
78
+ // Reverse order → Nucleus.
79
+ expect(parseSubcellularLocation('Nucleus. Mitochondrion.', ''), 'Nucleus');
80
+ });
81
+
82
+ test('classifier: ties broken by keyword-map insertion order (Nucleus before Cytoplasm)', async () => {
83
+ // Both keywords start at position 0 of their own scan; Nucleus is iterated
84
+ // first (strict < on position keeps the first-iterated category).
85
+ expect(parseSubcellularLocation('nucleus', ''), 'Nucleus');
86
+ expect(parseSubcellularLocation('cytoplasm', ''), 'Cytoplasm');
87
+ });
88
+
89
+ test('classifier: raw string not pre-stripped (matches inside SUBCELLULAR LOCATION: ... {ECO})', async () => {
90
+ expect(parseSubcellularLocation(
91
+ 'SUBCELLULAR LOCATION: Cytoplasm {ECO:0000269|PubMed:1234}. Nucleus {ECO:0000250}.', ''),
92
+ 'Cytoplasm');
93
+ });
94
+
95
+ test('classifier: empty / no-match → Unknown', async () => {
96
+ expect(parseSubcellularLocation('', ''), 'Unknown');
97
+ expect(parseSubcellularLocation('some unrelated free text', 'nothing spatial here'),
98
+ 'Unknown');
99
+ });
100
+
101
+ test('LOCATION_COLORS: all 12 ARGB ints equal the locked hex conversion', async () => {
102
+ const keys = Object.keys(EXPECTED_HEX);
103
+ expect(keys.length, 12);
104
+ for (const k of keys) {
105
+ const argb = 0xFF000000 | parseInt(EXPECTED_HEX[k].slice(1), 16);
106
+ expect(LOCATION_COLORS[k], argb);
107
+ }
108
+ expect(Object.keys(LOCATION_COLORS).length, 12);
109
+ });
110
+
111
+ test('mergeStreamTsv: header skipped, positional parse, reviewed beats unreviewed', async () => {
112
+ const tsv = [
113
+ 'Entry\tSubcellular location [CC]\tGene Ontology (cellular component)\tReviewed\tGene Names (primary)',
114
+ 'P1\tNucleus.\t\tunreviewed\tGENA',
115
+ 'P1\tMitochondrion.\t\treviewed\tGENA',
116
+ 'P2\t\t\tunreviewed\tGENB',
117
+ ].join('\n');
118
+ const {locByAcc, geneByAcc} = mergeStreamTsv(tsv);
119
+ // P1: reviewed Mitochondria overwrites the earlier unreviewed Nucleus.
120
+ expect(locByAcc.get('P1'), 'Mitochondria');
121
+ // P2: no location anywhere → Unknown.
122
+ expect(locByAcc.get('P2'), 'Unknown');
123
+ expect(geneByAcc.get('P1'), 'GENA');
124
+ expect(geneByAcc.get('P2'), 'GENB');
125
+ });
126
+
127
+ test('mergeStreamTsv: existing Unknown is overwritten by a later non-Unknown', async () => {
128
+ const tsv = [
129
+ 'Entry\tSubcellular location [CC]\tGO\tReviewed\tGene',
130
+ 'P9\t\t\tunreviewed\tG9',
131
+ 'P9\tGolgi apparatus.\t\tunreviewed\tG9',
132
+ ].join('\n');
133
+ const {locByAcc} = mergeStreamTsv(tsv);
134
+ expect(locByAcc.get('P9'), 'Golgi');
135
+ });
136
+
137
+ // --- 13-08 gap-closure tests ---
138
+
139
+ // Test A: runWithConcurrency holds the in-flight cap. No network — uses
140
+ // deferred Promises around a tracked concurrentMax counter.
141
+ test('runWithConcurrency caps in-flight tasks at the supplied limit', async () => {
142
+ let inFlight = 0;
143
+ let concurrentMax = 0;
144
+ const items = new Array(20).fill(0).map((_, i) => i);
145
+ await runWithConcurrency(items, 6, async () => {
146
+ inFlight++;
147
+ if (inFlight > concurrentMax) concurrentMax = inFlight;
148
+ await new Promise((r) => setTimeout(r, 5));
149
+ inFlight--;
150
+ return null;
151
+ });
152
+ expect(concurrentMax <= 6, true, 'concurrentMax should be ≤ 6');
153
+ expect(concurrentMax >= 2, true, 'should have observed parallel execution');
154
+ });
155
+
156
+ // Test B: getSubcellularLocations emits progress callbacks with non-decreasing
157
+ // `done` within phase. Monkey-patches fetchProxy, userDataStorage.put, AND
158
+ // userDataStorage.get — the last one is critical: a previous test run may
159
+ // have populated real on-disk storage, which would silently mark every
160
+ // accession as a cache hit and skip the fetch loop entirely.
161
+ test('getSubcellularLocations emits non-decreasing progress per phase', async () => {
162
+ const tsvBody = (acc: string) =>
163
+ 'Entry\tSubcellular location [CC]\tGO\tReviewed\tGene Names (primary)\n' +
164
+ `${acc}\tNucleus.\t\treviewed\tGENE1\n`;
165
+
166
+ const origFetch = grok.dapi.fetchProxy;
167
+ (grok.dapi as any).fetchProxy = async (url: string) => {
168
+ // Extract the first accession out of the query so each chunk returns
169
+ // a single resolved entry — that's enough to exercise progress.
170
+ const m = /accession%3A([A-Z0-9]+)/.exec(url);
171
+ const acc = m ? m[1] : 'P00000';
172
+ return {ok: true, status: 200, text: async () => tsvBody(acc)} as Response;
173
+ };
174
+ const restore = patchUserDataStorage({
175
+ get: async () => ({}),
176
+ put: async () => {},
177
+ });
178
+
179
+ try {
180
+ const accessions: string[] = [];
181
+ for (let i = 0; i < 250; i++) accessions.push(`TESTB${String(i).padStart(5, '0')}`);
182
+ const calls: {done: number; total: number; phase: string}[] = [];
183
+ const progress: ProgressCb = (done, total, phase) => calls.push({done, total, phase});
184
+
185
+ await getSubcellularLocations(accessions, 'hsapiens', progress);
186
+
187
+ // 250/100 ⇒ 3 accession chunks.
188
+ const accCalls = calls.filter((c) => c.phase === 'fetch-acc');
189
+ expect(accCalls.length, 3);
190
+ expect(accCalls[0].total, 3);
191
+ // Non-decreasing within phase (worker-interleaved → not strictly monotonic).
192
+ for (let i = 1; i < accCalls.length; i++)
193
+ expect(accCalls[i].done >= accCalls[i - 1].done, true,
194
+ 'fetch-acc done should be non-decreasing');
195
+ // Final tuple of the last entered phase reaches done === total.
196
+ const last = accCalls[accCalls.length - 1];
197
+ expect(last.done, last.total, 'last fetch-acc tuple should have done === total');
198
+ } finally {
199
+ (grok.dapi as any).fetchProxy = origFetch;
200
+ restore();
201
+ }
202
+ });
203
+
204
+ // Test C: userDataStorage.put is called at least twice during a fetch lasting
205
+ // longer than CACHE_FLUSH_INTERVAL_MS (5 s default). Spy via wrapping.
206
+ test('getSubcellularLocations writes the cache incrementally during fetch', async () => {
207
+ // We can't shorten CACHE_FLUSH_INTERVAL_MS without exporting it (and the
208
+ // plan locks it as a tuning knob), so this test runs a fetch that lasts
209
+ // ~6 s — long enough for the timer to fire at least once mid-fetch plus
210
+ // the final finally-flush. Per-chunk delay × chunk count is what stretches
211
+ // wall-clock; with 3 chunks and a 2.2 s per-chunk delay, total ≈ 6 s with
212
+ // FETCH_CONCURRENCY=6 (all parallel) or longer if serialized.
213
+ //
214
+ // The robustness invariant we assert is: put was called >= 2 times. The
215
+ // exact count depends on timer-vs-fetch interleaving; 2 covers (a) one
216
+ // timer fire mid-fetch + (b) the finally flush. We intentionally do not
217
+ // assert >2 — a CI VM might run the entire fetch inside one tick.
218
+ const tsvBody = (acc: string) =>
219
+ 'Entry\tSubcellular location [CC]\tGO\tReviewed\tGene Names (primary)\n' +
220
+ `${acc}\tNucleus.\t\treviewed\tGENE1\n`;
221
+
222
+ const origFetch = grok.dapi.fetchProxy;
223
+ // First chunk completes at ~1 s (populates `fetched`); second chunk runs
224
+ // until ~6 s. The 5 s flush timer fires at t=5 s while the second chunk
225
+ // is still in flight — `fetched` is already non-empty from chunk 1, so
226
+ // flushCache actually writes (put #1). Finally flushes (put #2). This is
227
+ // the only interleaving that proves the timer-driven incremental cache
228
+ // works, not just the post-pass single flush.
229
+ let callIdx = 0;
230
+ (grok.dapi as any).fetchProxy = async (url: string) => {
231
+ const myIdx = callIdx++;
232
+ const delayMs = myIdx === 0 ? 1000 : 6000;
233
+ await new Promise((r) => setTimeout(r, delayMs));
234
+ const m = /accession%3A([A-Z0-9]+)/.exec(url);
235
+ const acc = m ? m[1] : 'P00000';
236
+ return {ok: true, status: 200, text: async () => tsvBody(acc)} as Response;
237
+ };
238
+ let putCalls = 0;
239
+ const restore = patchUserDataStorage({
240
+ // Empty cache → all accessions go to fetch loop → flushCache has work.
241
+ get: async () => ({}),
242
+ put: async () => { putCalls++; },
243
+ });
244
+
245
+ try {
246
+ // 150 accessions → 2 ACC_CHUNK groups (size 100). Worker pool with
247
+ // concurrency=6 starts both chunks at t=0; staggered delays handle the
248
+ // interleaving above.
249
+ const accessions: string[] = [];
250
+ for (let i = 0; i < 150; i++) accessions.push(`TESTC${String(i).padStart(5, '0')}`);
251
+ await getSubcellularLocations(accessions, 'hsapiens');
252
+ expect(putCalls >= 2, true,
253
+ `userDataStorage.put should be called >= 2 times (got ${putCalls})`);
254
+ } finally {
255
+ (grok.dapi as any).fetchProxy = origFetch;
256
+ restore();
257
+ }
258
+ }, {timeout: 30000});
259
+
260
+ // Make sure STORE is exported and matches the documented key — guards
261
+ // against accidental rename that would silently fork the on-disk cache
262
+ // from the volcanoOptions toast-cache-peek path.
263
+ test('STORE export matches the documented userDataStorage key', async () => {
264
+ expect(STORE, 'proteomics-subcell-loc');
265
+ });
266
+
267
+ // Test D: ensureLocationColumn short-circuits when the accession-set hash
268
+ // tag matches the current set. We don't know the FNV-1a hash from the test,
269
+ // so we exercise the contract end-to-end: (1) first call populates the
270
+ // column AND stamps the hash tag; (2) second call with fetchProxy spy that
271
+ // would throw if invoked still completes — proving the short-circuit fires;
272
+ // (3) mutating the accession set invalidates the hash and re-triggers fetch.
273
+ test('ensureLocationColumn short-circuits on accession-set hash match', async () => {
274
+ const tsvBody = (acc: string) =>
275
+ 'Entry\tSubcellular location [CC]\tGO\tReviewed\tGene Names (primary)\n' +
276
+ `${acc}\tNucleus.\t\treviewed\tGENE1\n`;
277
+
278
+ const origFetch = grok.dapi.fetchProxy;
279
+ let fetchCalled = 0;
280
+ (grok.dapi as any).fetchProxy = async (url: string) => {
281
+ fetchCalled++;
282
+ const m = /accession%3A([A-Z0-9]+)/.exec(url);
283
+ const acc = m ? m[1] : 'TESTD00';
284
+ return {ok: true, status: 200, text: async () => tsvBody(acc)} as Response;
285
+ };
286
+ // Empty cache → first call goes through fetch path so the hash gets stamped.
287
+ const restore = patchUserDataStorage({
288
+ get: async () => ({}),
289
+ put: async () => {},
290
+ });
291
+
292
+ try {
293
+ const idCol = DG.Column.fromStrings('Primary Protein ID',
294
+ ['TESTD01', 'TESTD02', 'TESTD03', 'TESTD04', 'TESTD05']);
295
+ idCol.semType = SEMTYPE.PROTEIN_ID;
296
+ const df = DG.DataFrame.fromColumns([idCol]);
297
+
298
+ // (1) Populate + stamp the hash tag.
299
+ await ensureLocationColumn(df);
300
+ const firstFetchCount = fetchCalled;
301
+ expect(firstFetchCount >= 1, true, 'first call should fetch from UniProt');
302
+
303
+ // (2) Same accession set → short-circuit, no new fetch.
304
+ await ensureLocationColumn(df);
305
+ expect(fetchCalled, firstFetchCount,
306
+ 'second call on same accession set should short-circuit (no new fetch)');
307
+
308
+ // (3) Mutate one accession → hash invalidates → fetch resumes.
309
+ idCol.set(0, 'TESTD99');
310
+ await ensureLocationColumn(df);
311
+ expect(fetchCalled > firstFetchCount, true,
312
+ 'third call with mutated accession should fetch again');
313
+ } finally {
314
+ (grok.dapi as any).fetchProxy = origFetch;
315
+ restore();
316
+ }
317
+ });
318
+
319
+ // Test E: ensureLocationColumn forwards the progress callback through to
320
+ // getSubcellularLocations AND fires 'init-column' after bulk-init.
321
+ test('ensureLocationColumn forwards progress through fetch + emits init-column', async () => {
322
+ const tsvBody = (acc: string) =>
323
+ 'Entry\tSubcellular location [CC]\tGO\tReviewed\tGene Names (primary)\n' +
324
+ `${acc}\tNucleus.\t\treviewed\tGENE1\n`;
325
+
326
+ const origFetch = grok.dapi.fetchProxy;
327
+ (grok.dapi as any).fetchProxy = async (url: string) => {
328
+ const m = /accession%3A([A-Z0-9]+)/.exec(url);
329
+ const acc = m ? m[1] : 'TESTE00';
330
+ return {ok: true, status: 200, text: async () => tsvBody(acc)} as Response;
331
+ };
332
+ const restore = patchUserDataStorage({
333
+ get: async () => ({}),
334
+ put: async () => {},
335
+ });
336
+
337
+ try {
338
+ const idCol = DG.Column.fromStrings('Primary Protein ID',
339
+ ['TESTE01', 'TESTE02', 'TESTE03']);
340
+ idCol.semType = SEMTYPE.PROTEIN_ID;
341
+ const df = DG.DataFrame.fromColumns([idCol]);
342
+
343
+ const phases: string[] = [];
344
+ const progress: ProgressCb = (_done, _total, phase) => phases.push(phase);
345
+
346
+ await ensureLocationColumn(df, progress);
347
+
348
+ expect(phases.includes('fetch-acc'), true,
349
+ 'progress should fire at least once with phase=fetch-acc');
350
+ expect(phases.includes('init-column'), true,
351
+ 'progress should fire with phase=init-column after bulk-init');
352
+ // 'init-column' should be the last tick — tests can rely on this for
353
+ // closing the dialog's pi cleanly.
354
+ expect(phases[phases.length - 1], 'init-column',
355
+ 'init-column should be the final progress phase');
356
+ } finally {
357
+ (grok.dapi as any).fetchProxy = origFetch;
358
+ restore();
359
+ }
360
+ });
361
+ });
@@ -0,0 +1,202 @@
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 {
5
+ renderUniProtWidget,
6
+ renderPerGroupBars,
7
+ findHostDataFrameForProtein,
8
+ } from '../panels/uniprot-panel';
9
+ import {setGroups} from '../analysis/experiment-setup';
10
+ import {SEMTYPE} from '../utils/proteomics-types';
11
+
12
+ /** Builds a fixture DataFrame mimicking a 4-sample, 3-protein post-DE table.
13
+ * Cells indexed by row 0 (first protein P11111) have known values so the
14
+ * test can assert mean/SD computation exactly. */
15
+ function makeFixtureDf(opts: {
16
+ groupColsExist?: boolean;
17
+ withGroupsTag?: boolean;
18
+ withProteinId?: boolean;
19
+ row0AllNan?: boolean;
20
+ } = {}): DG.DataFrame {
21
+ const {
22
+ groupColsExist = true,
23
+ withGroupsTag = true,
24
+ withProteinId = true,
25
+ row0AllNan = false,
26
+ } = opts;
27
+
28
+ const cols: DG.Column[] = [];
29
+ if (withProteinId) {
30
+ const pid = DG.Column.fromStrings('Primary Protein ID', ['P11111', 'P22222', 'P33333']);
31
+ pid.semType = SEMTYPE.PROTEIN_ID;
32
+ cols.push(pid);
33
+ }
34
+ if (groupColsExist) {
35
+ // Row 0 — g1: [1, 3] → mean=2, sd=√2; g2: [5, 7] → mean=6, sd=√2
36
+ const s1 = row0AllNan ? [NaN, 1.0, 1.0] : [1.0, 1.5, 2.0];
37
+ const s2 = row0AllNan ? [NaN, 3.0, 1.0] : [3.0, 2.5, 2.5];
38
+ const s3 = row0AllNan ? [NaN, 4.0, 4.0] : [5.0, 4.0, 5.0];
39
+ const s4 = row0AllNan ? [NaN, 6.0, 4.0] : [7.0, 6.0, 5.5];
40
+ const c1 = DG.Column.fromList('double' as DG.ColumnType, 'Sample1', s1);
41
+ const c2 = DG.Column.fromList('double' as DG.ColumnType, 'Sample2', s2);
42
+ const c3 = DG.Column.fromList('double' as DG.ColumnType, 'Sample3', s3);
43
+ const c4 = DG.Column.fromList('double' as DG.ColumnType, 'Sample4', s4);
44
+ if (row0AllNan) {
45
+ c1.set(0, null); c2.set(0, null); c3.set(0, null); c4.set(0, null);
46
+ }
47
+ cols.push(c1, c2, c3, c4);
48
+ }
49
+ const df = DG.DataFrame.fromColumns(cols);
50
+ if (withGroupsTag && groupColsExist) {
51
+ setGroups(df, {
52
+ group1: {name: 'Control', columns: ['Sample1', 'Sample2']},
53
+ group2: {name: 'Treatment', columns: ['Sample3', 'Sample4']},
54
+ });
55
+ }
56
+ df.name = `panel-fixture-${Math.random().toString(36).slice(2, 8)}`;
57
+ return df;
58
+ }
59
+
60
+ /** Mock UniProtEntry compatible with renderUniProtWidget's expected fields. */
61
+ function makeMockEntry(accession: string): any {
62
+ return {
63
+ accession,
64
+ proteinDescription: {recommendedName: {fullName: {value: 'Mock Protein'}}},
65
+ genes: [{geneName: {value: 'MOCK1'}}],
66
+ organism: {scientificName: 'Homo sapiens'},
67
+ comments: [],
68
+ uniProtKBCrossReferences: [],
69
+ };
70
+ }
71
+
72
+ category('Proteomics: 14-04', () => {
73
+ test('uniprotPanelPerGroupBarsRender', async () => {
74
+ const df = makeFixtureDf();
75
+ const tv = grok.shell.addTableView(df);
76
+ try {
77
+ const dom = renderUniProtWidget(makeMockEntry('P11111'), 'P11111');
78
+ // Header
79
+ expect(dom.textContent?.includes('Per-Group Quantities'), true);
80
+ // Two SVG rect bars
81
+ const rects = dom.querySelectorAll('rect');
82
+ expect(rects.length, 2);
83
+ // Two `(n=2)` labels
84
+ const texts = Array.from(dom.querySelectorAll('text'))
85
+ .map((t) => t.textContent ?? '');
86
+ const nTwoCount = texts.filter((t) => t.includes('(n=2)')).length;
87
+ expect(nTwoCount, 2);
88
+ // Two mean/SD text rows in 0.85em — one per non-empty group. Match on
89
+ // exact-leaf divs (no nested div children) so the parent container's
90
+ // concatenated textContent doesn't double-count.
91
+ const meanLines = Array.from(dom.querySelectorAll('div'))
92
+ .filter((d) => d.querySelector(':scope > div') === null)
93
+ .map((d) => d.textContent ?? '')
94
+ .filter((t) => /mean=.*SD=/.test(t));
95
+ expect(meanLines.length, 2);
96
+ } finally {
97
+ tv.close();
98
+ }
99
+ });
100
+
101
+ test('uniprotPanelPerGroupBarsEmptyState', async () => {
102
+ // No groups tag — findHostDataFrameForProtein returns null, so the section is omitted entirely.
103
+ const df = makeFixtureDf({withGroupsTag: false});
104
+ const tv = grok.shell.addTableView(df);
105
+ try {
106
+ const dom = renderUniProtWidget(makeMockEntry('P11111'), 'P11111');
107
+ // The section is not even rendered when no host DataFrame is found, so the
108
+ // SVG rect count is zero and the header is absent.
109
+ const rects = dom.querySelectorAll('rect');
110
+ expect(rects.length, 0);
111
+ expect(dom.textContent?.includes('Per-Group Quantities'), false);
112
+ } finally {
113
+ tv.close();
114
+ }
115
+ });
116
+
117
+ test('uniprotPanelPerGroupBarsColors', async () => {
118
+ const df = makeFixtureDf();
119
+ const tv = grok.shell.addTableView(df);
120
+ try {
121
+ const dom = renderUniProtWidget(makeMockEntry('P11111'), 'P11111');
122
+ const rects = dom.querySelectorAll('rect');
123
+ expect(rects.length, 2);
124
+ expect(rects[0].getAttribute('fill'), '#FF00FF'); // group1 magenta
125
+ expect(rects[1].getAttribute('fill'), '#00FFFF'); // group2 cyan
126
+ } finally {
127
+ tv.close();
128
+ }
129
+ });
130
+
131
+ test('uniprotPanelPerGroupMeanSDComputation', async () => {
132
+ // Custom fixture: row 0 group1=[1,3], group2=[5,7] → mean=2/6, SD=√2≈1.414.
133
+ const cols: DG.Column[] = [];
134
+ const pid = DG.Column.fromStrings('Primary Protein ID', ['P11111']);
135
+ pid.semType = SEMTYPE.PROTEIN_ID;
136
+ cols.push(pid);
137
+ cols.push(DG.Column.fromList('double' as DG.ColumnType, 'S1', [1.0]));
138
+ cols.push(DG.Column.fromList('double' as DG.ColumnType, 'S2', [3.0]));
139
+ cols.push(DG.Column.fromList('double' as DG.ColumnType, 'S3', [5.0]));
140
+ cols.push(DG.Column.fromList('double' as DG.ColumnType, 'S4', [7.0]));
141
+ const df = DG.DataFrame.fromColumns(cols);
142
+ setGroups(df, {
143
+ group1: {name: 'Control', columns: ['S1', 'S2']},
144
+ group2: {name: 'Treatment', columns: ['S3', 'S4']},
145
+ });
146
+ df.name = 'mean-sd-fixture';
147
+ const tv = grok.shell.addTableView(df);
148
+ try {
149
+ const dom = renderPerGroupBars(df, 'P11111');
150
+ expect(dom !== null, true);
151
+ const meanLines = Array.from(dom!.querySelectorAll('div'))
152
+ .map((d) => d.textContent ?? '')
153
+ .filter((t) => /mean=.*SD=/.test(t));
154
+ // Two non-empty groups produce two mean/SD lines.
155
+ expect(meanLines.length, 2);
156
+ // Sample SD (n-1 denominator): variance = ((1-2)^2 + (3-2)^2)/(2-1) = 2 → SD = √2 ≈ 1.41
157
+ const g1Match = meanLines.find((l) => l.startsWith('Control:'));
158
+ const g2Match = meanLines.find((l) => l.startsWith('Treatment:'));
159
+ expect(g1Match !== undefined, true);
160
+ expect(g2Match !== undefined, true);
161
+ expect(g1Match!.includes('mean=2.00'), true);
162
+ expect(g1Match!.includes('SD=1.41'), true);
163
+ expect(g2Match!.includes('mean=6.00'), true);
164
+ expect(g2Match!.includes('SD=1.41'), true);
165
+ } finally {
166
+ tv.close();
167
+ }
168
+ });
169
+
170
+ test('uniprotPanelPerGroupBarsAllNanProtein', async () => {
171
+ const df = makeFixtureDf({row0AllNan: true});
172
+ const tv = grok.shell.addTableView(df);
173
+ try {
174
+ const dom = renderPerGroupBars(df, 'P11111');
175
+ // Returns the empty-state element (ui.divText), no SVG.
176
+ expect(dom !== null, true);
177
+ expect(dom!.tagName.toLowerCase(), 'div');
178
+ expect((dom!.textContent ?? '').includes('No per-group quantities available'), true);
179
+ expect(dom!.querySelectorAll('rect').length, 0);
180
+ } finally {
181
+ tv.close();
182
+ }
183
+ });
184
+
185
+ test('findHostDataFrameForProteinPrefersActiveTable', async () => {
186
+ const df1 = makeFixtureDf();
187
+ const df2 = makeFixtureDf();
188
+ const tv1 = grok.shell.addTableView(df1);
189
+ const tv2 = grok.shell.addTableView(df2);
190
+ try {
191
+ // tv2 is the most recently added, so it is active.
192
+ const host = findHostDataFrameForProtein('P11111');
193
+ expect(host !== null, true);
194
+ // Both DFs match — preference is the active one (tv.dataFrame).
195
+ const activeDf = grok.shell.tv?.dataFrame;
196
+ expect(host === activeDf, true);
197
+ } finally {
198
+ tv1.close();
199
+ tv2.close();
200
+ }
201
+ });
202
+ });