@datagrok/proteomics 1.0.1 → 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 -63
  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,140 @@
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 {parseSpectronautCandidatesText} from '../parsers/spectronaut-candidates-parser';
5
+ import {dockComparisonFilterIfMultiContrast, openCandidatesAnalysisView} from '../package';
6
+ import {SEMTYPE} from '../utils/proteomics-types';
7
+ import {_package} from '../package-test';
8
+
9
+ /** Builds a minimal Candidates TSV. */
10
+ function candidatesTsv(rows: string[][]): string {
11
+ const headers = ['Comparison (group1/group2)', 'PG.ProteinGroups', 'PG.Genes',
12
+ 'AVG Log2 Ratio', 'Qvalue', 'Pvalue'];
13
+ return [headers.join('\t'), ...rows.map((r) => r.join('\t'))].join('\n');
14
+ }
15
+
16
+ function hasFiltersViewer(tv: DG.TableView): boolean {
17
+ for (const v of tv.viewers) {
18
+ if (v.type === DG.VIEWER.FILTERS) return true;
19
+ }
20
+ return false;
21
+ }
22
+
23
+ const FIXTURE_PATH = 'demo/spectronaut-hye-candidates.tsv';
24
+
25
+ // Expected from the deterministic generator (tools/generate-spectronaut-candidates-fixture.mjs)
26
+ // run against files/demo/spectronaut-hye-mix.tsv.
27
+ const EXPECTED_ROWS = 93;
28
+ const EXPECTED_SIG_UP = 26;
29
+ const EXPECTED_SIG_DOWN = 17;
30
+ const EXPECTED_NOT_SIG = 50;
31
+ const EXPECTED_ORGANISMS_AT_LEAST = ['Homo sapiens', 'Saccharomyces cerevisiae', 'Escherichia coli'];
32
+
33
+ category('SpectronautCandidates E2E', () => {
34
+ test('parses HYE candidates fixture and reproduces expected DE signal', async () => {
35
+ const text = await _package.files.readAsText(FIXTURE_PATH);
36
+ const df = await parseSpectronautCandidatesText(text);
37
+
38
+ expect(df.rowCount, EXPECTED_ROWS);
39
+
40
+ expect(df.getTag('proteomics.source'), 'spectronaut-candidates');
41
+ expect(df.getTag('proteomics.de_complete'), 'true');
42
+ expect(df.getTag('proteomics.de_method'), 'spectronaut');
43
+
44
+ expect(df.col('log2FC') !== null, true);
45
+ expect(df.col('adj.p-value') !== null, true);
46
+ expect(df.col('p-value') !== null, true);
47
+ expect(df.col('significant') !== null, true);
48
+ expect(df.col('AVG Log2 Ratio'), null);
49
+ expect(df.col('Qvalue'), null);
50
+ expect(df.col('Pvalue'), null);
51
+
52
+ expect(df.col('ProteinGroups')!.semType, SEMTYPE.PROTEIN_ID);
53
+ expect(df.col('log2FC')!.semType, SEMTYPE.LOG2FC);
54
+ expect(df.col('p-value')!.semType, SEMTYPE.P_VALUE);
55
+ expect(df.col('adj.p-value')!.semType, SEMTYPE.P_VALUE);
56
+
57
+ const fc = df.col('log2FC')!;
58
+ const sig = df.col('significant')!;
59
+ let up = 0, down = 0, ns = 0;
60
+ for (let i = 0; i < df.rowCount; i++) {
61
+ if (sig.get(i)) {
62
+ if ((fc.get(i) as number) > 0) up++; else down++;
63
+ } else ns++;
64
+ }
65
+ expect(up, EXPECTED_SIG_UP);
66
+ expect(down, EXPECTED_SIG_DOWN);
67
+ expect(ns, EXPECTED_NOT_SIG);
68
+
69
+ const orgCol = df.col('Organisms')!;
70
+ const seenOrganisms = new Set<string>();
71
+ for (let i = 0; i < df.rowCount; i++) {
72
+ const v = orgCol.get(i);
73
+ if (typeof v === 'string') seenOrganisms.add(v);
74
+ }
75
+ for (const expected of EXPECTED_ORGANISMS_AT_LEAST) {
76
+ const found = Array.from(seenOrganisms).some((o) => o.includes(expected));
77
+ expect(found, true);
78
+ }
79
+ });
80
+
81
+ // --- R4/D-07: Comparison Filter viewer docking (13-03 Task 2) ---
82
+
83
+ test('multi-comparison file docks a Comparison Filters viewer', async () => {
84
+ const df = await parseSpectronautCandidatesText(candidatesTsv([
85
+ ['T1 / Control', 'P1', 'G1', '2.0', '0.001', '0.0001'],
86
+ ['T2 / Control', 'P2', 'G2', '-1.7', '0.002', '0.0005'],
87
+ ['T1 / Control', 'P3', 'G3', '1.2', '0.01', '0.005'],
88
+ ]));
89
+ const tv = grok.shell.addTableView(df);
90
+ try {
91
+ const docked = dockComparisonFilterIfMultiContrast(tv, df);
92
+ expect(docked, true);
93
+ expect(hasFiltersViewer(tv), true);
94
+ } finally {
95
+ tv.close();
96
+ }
97
+ });
98
+
99
+ test('single-comparison file docks no Filters viewer', async () => {
100
+ const df = await parseSpectronautCandidatesText(candidatesTsv([
101
+ ['T1 / Control', 'P1', 'G1', '2.0', '0.001', '0.0001'],
102
+ ['T1 / Control', 'P2', 'G2', '-1.7', '0.002', '0.0005'],
103
+ ]));
104
+ const tv = grok.shell.addTableView(df);
105
+ try {
106
+ const docked = dockComparisonFilterIfMultiContrast(tv, df);
107
+ expect(docked, false);
108
+ expect(hasFiltersViewer(tv), false);
109
+ } finally {
110
+ tv.close();
111
+ }
112
+ });
113
+
114
+ // --- Auto-volcano on import (parity with the Report path's DE-completion
115
+ // volcano; Candidates skip DE because it's already computed). ---
116
+
117
+ test('candidates import auto-opens the volcano', async () => {
118
+ const df = await parseSpectronautCandidatesText(candidatesTsv([
119
+ ['T1 / Control', 'P1', 'G1', '2.0', '0.001', '0.0001'],
120
+ ['T1 / Control', 'P2', 'G2', '-1.7', '0.002', '0.0005'],
121
+ ['T1 / Control', 'P3', 'G3', '0.1', '0.9', '0.8'],
122
+ ]));
123
+ const tv = openCandidatesAnalysisView(df);
124
+ try {
125
+ let scatterCount = 0;
126
+ let volcano: DG.ScatterPlotViewer | null = null;
127
+ for (const v of tv.viewers) {
128
+ if (v.type === DG.VIEWER.SCATTER_PLOT) {
129
+ scatterCount++;
130
+ volcano = v as DG.ScatterPlotViewer;
131
+ }
132
+ }
133
+ expect(scatterCount, 1); // exactly one auto-volcano, no duplicates
134
+ // It is the DE volcano: X is the fold-change column.
135
+ expect(volcano!.props.xColumnName, 'log2FC');
136
+ } finally {
137
+ tv.close();
138
+ }
139
+ });
140
+ });
@@ -0,0 +1,398 @@
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 {parseSpectronautCandidatesText} from '../parsers/spectronaut-candidates-parser';
5
+ import {dockComparisonFilterIfMultiContrast} from '../package';
6
+ import {SEMTYPE} from '../utils/proteomics-types';
7
+ import {getGroups} from '../analysis/experiment-setup';
8
+
9
+ /** Builds a minimal Spectronaut Candidates TSV string. */
10
+ function makeTsv(rows: string[][], headers: string[]): string {
11
+ return [headers.join('\t'), ...rows.map((r) => r.join('\t'))].join('\n');
12
+ }
13
+
14
+ const BASE_HEADERS = [
15
+ 'Comparison (group1/group2)', 'PG.ProteinGroups', 'PG.Genes',
16
+ 'AVG Log2 Ratio', 'Qvalue', 'Pvalue',
17
+ ];
18
+
19
+ function baseRow(comparison: string, protein: string, gene: string,
20
+ log2fc: string, qval: string, pval: string): string[] {
21
+ return [comparison, protein, gene, log2fc, qval, pval];
22
+ }
23
+
24
+ category('SpectronautCandidates', () => {
25
+ test('parses standard rows', async () => {
26
+ const tsv = makeTsv([
27
+ baseRow('Treatment / Control', 'P04637', 'TP53', '2.5', '1e-5', '1e-6'),
28
+ baseRow('Treatment / Control', 'P38398', 'BRCA1', '-2.3', '1e-4', '1e-5'),
29
+ ], BASE_HEADERS);
30
+ const df = await parseSpectronautCandidatesText(tsv);
31
+ expect(df.rowCount, 2);
32
+ });
33
+
34
+ test('sets proteomics.source to spectronaut-candidates', async () => {
35
+ const tsv = makeTsv([
36
+ baseRow('A / B', 'P04637', 'TP53', '2.5', '1e-5', '1e-6'),
37
+ ], BASE_HEADERS);
38
+ const df = await parseSpectronautCandidatesText(tsv);
39
+ expect(df.getTag('proteomics.source'), 'spectronaut-candidates');
40
+ });
41
+
42
+ test('sets de_complete and de_method tags', async () => {
43
+ const tsv = makeTsv([
44
+ baseRow('A / B', 'P04637', 'TP53', '2.5', '1e-5', '1e-6'),
45
+ ], BASE_HEADERS);
46
+ const df = await parseSpectronautCandidatesText(tsv);
47
+ expect(df.getTag('proteomics.de_complete'), 'true');
48
+ expect(df.getTag('proteomics.de_method'), 'spectronaut');
49
+ });
50
+
51
+ test('names contrast groups from the Comparison string (volcano legend)', async () => {
52
+ const tsv = makeTsv([
53
+ baseRow('DMT / WT', 'P04637', 'TP53', '2.5', '1e-5', '1e-6'),
54
+ baseRow('DMT / WT', 'P38398', 'BRCA1', '-2.3', '1e-4', '1e-5'),
55
+ ], BASE_HEADERS);
56
+ const df = await parseSpectronautCandidatesText(tsv);
57
+ const groups = getGroups(df);
58
+ expect(groups != null, true);
59
+ // Assert the name SET (order depends on the parked sign-direction default).
60
+ const names = [groups!.group1.name, groups!.group2.name].sort();
61
+ expect(names[0], 'DMT');
62
+ expect(names[1], 'WT');
63
+ // Candidates carries no per-sample intensities → column lists stay empty.
64
+ expect(groups!.group1.columns.length, 0);
65
+ expect(groups!.group2.columns.length, 0);
66
+ });
67
+
68
+ test('renames AVG Log2 Ratio to log2FC', async () => {
69
+ const tsv = makeTsv([
70
+ baseRow('A / B', 'P04637', 'TP53', '2.5', '1e-5', '1e-6'),
71
+ ], BASE_HEADERS);
72
+ const df = await parseSpectronautCandidatesText(tsv);
73
+ expect(df.col('log2FC') !== null, true);
74
+ expect(df.col('AVG Log2 Ratio'), null);
75
+ expect(Math.abs((df.col('log2FC')!.get(0) as number) - 2.5) < 0.01, true);
76
+ });
77
+
78
+ test('renames Qvalue to adj.p-value', async () => {
79
+ const tsv = makeTsv([
80
+ baseRow('A / B', 'P04637', 'TP53', '2.5', '0.001', '0.0001'),
81
+ ], BASE_HEADERS);
82
+ const df = await parseSpectronautCandidatesText(tsv);
83
+ expect(df.col('adj.p-value') !== null, true);
84
+ expect(df.col('Qvalue'), null);
85
+ expect(Math.abs((df.col('adj.p-value')!.get(0) as number) - 0.001) < 1e-6, true);
86
+ });
87
+
88
+ test('renames Pvalue to p-value when present', async () => {
89
+ const tsv = makeTsv([
90
+ baseRow('A / B', 'P04637', 'TP53', '2.5', '0.001', '0.0001'),
91
+ ], BASE_HEADERS);
92
+ const df = await parseSpectronautCandidatesText(tsv);
93
+ expect(df.col('p-value') !== null, true);
94
+ expect(df.col('Pvalue'), null);
95
+ });
96
+
97
+ test('works without a Pvalue column', async () => {
98
+ const headers = ['PG.ProteinGroups', 'PG.Genes', 'AVG Log2 Ratio', 'Qvalue'];
99
+ const tsv = makeTsv([
100
+ ['P04637', 'TP53', '2.5', '0.001'],
101
+ ], headers);
102
+ const df = await parseSpectronautCandidatesText(tsv);
103
+ expect(df.rowCount, 1);
104
+ expect(df.col('p-value'), null);
105
+ expect(df.col('adj.p-value') !== null, true);
106
+ });
107
+
108
+ test('assigns canonical semantic types', async () => {
109
+ const tsv = makeTsv([
110
+ baseRow('A / B', 'P04637', 'TP53', '2.5', '0.001', '0.0001'),
111
+ ], BASE_HEADERS);
112
+ const df = await parseSpectronautCandidatesText(tsv);
113
+ expect(df.col('PG.ProteinGroups')!.semType, SEMTYPE.PROTEIN_ID);
114
+ expect(df.col('PG.Genes')!.semType, SEMTYPE.GENE_SYMBOL);
115
+ expect(df.col('log2FC')!.semType, SEMTYPE.LOG2FC);
116
+ expect(df.col('adj.p-value')!.semType, SEMTYPE.P_VALUE);
117
+ expect(df.col('p-value')!.semType, SEMTYPE.P_VALUE);
118
+ });
119
+
120
+ test('computes significant column from |log2FC| >= 1 and adj.p <= 0.05', async () => {
121
+ const tsv = makeTsv([
122
+ baseRow('A / B', 'P_sig_up', 'A', '2.5', '0.001', '0.0001'),
123
+ baseRow('A / B', 'P_sig_down', 'B', '-1.5', '0.001', '0.0001'),
124
+ baseRow('A / B', 'P_small_fc', 'C', '0.3', '0.001', '0.0001'),
125
+ baseRow('A / B', 'P_big_qvalue', 'D', '2.0', '0.5', '0.4'),
126
+ baseRow('A / B', 'P_just_inside', 'E', '1.01', '0.04', '0.03'),
127
+ ], BASE_HEADERS);
128
+ const df = await parseSpectronautCandidatesText(tsv);
129
+ const sig = df.col('significant')!;
130
+ expect(sig.get(0), true); // big FC, tiny q
131
+ expect(sig.get(1), true); // big negative FC, tiny q
132
+ expect(sig.get(2), false); // FC below threshold
133
+ expect(sig.get(3), false); // q above threshold
134
+ expect(sig.get(4), true); // just inside both thresholds
135
+ });
136
+
137
+ test('filters contam_ rows', async () => {
138
+ const tsv = makeTsv([
139
+ baseRow('A / B', 'P04637', 'TP53', '2.5', '0.001', '0.0001'),
140
+ baseRow('A / B', 'contam_P99999', 'KRT1', '0.1', '0.5', '0.4'),
141
+ ], BASE_HEADERS);
142
+ const df = await parseSpectronautCandidatesText(tsv);
143
+ expect(df.rowCount, 1);
144
+ expect(df.col('PG.ProteinGroups')!.get(0), 'P04637');
145
+ });
146
+
147
+ test('filters rev_ decoy rows', async () => {
148
+ const tsv = makeTsv([
149
+ baseRow('A / B', 'P04637', 'TP53', '2.5', '0.001', '0.0001'),
150
+ baseRow('A / B', 'rev_P12345', 'FAKE', '0.1', '0.5', '0.4'),
151
+ ], BASE_HEADERS);
152
+ const df = await parseSpectronautCandidatesText(tsv);
153
+ expect(df.rowCount, 1);
154
+ });
155
+
156
+ test('throws on missing protein-group column', async () => {
157
+ const headers = ['Comparison', 'AVG Log2 Ratio', 'Qvalue'];
158
+ const tsv = makeTsv([['A / B', '2.5', '0.001']], headers);
159
+ let threw = false;
160
+ try {
161
+ await parseSpectronautCandidatesText(tsv);
162
+ } catch (e: any) {
163
+ threw = true;
164
+ expect(String(e.message).includes('protein-group'), true);
165
+ }
166
+ expect(threw, true);
167
+ });
168
+
169
+ test('throws on missing log2 ratio column', async () => {
170
+ const headers = ['PG.ProteinGroups', 'PG.Genes', 'Qvalue'];
171
+ const tsv = makeTsv([['P04637', 'TP53', '0.001']], headers);
172
+ let threw = false;
173
+ try {
174
+ await parseSpectronautCandidatesText(tsv);
175
+ } catch (e: any) {
176
+ threw = true;
177
+ expect(String(e.message).includes('log2 ratio'), true);
178
+ }
179
+ expect(threw, true);
180
+ });
181
+
182
+ test('throws on missing q-value column', async () => {
183
+ const headers = ['PG.ProteinGroups', 'PG.Genes', 'AVG Log2 Ratio'];
184
+ const tsv = makeTsv([['P04637', 'TP53', '2.5']], headers);
185
+ let threw = false;
186
+ try {
187
+ await parseSpectronautCandidatesText(tsv);
188
+ } catch (e: any) {
189
+ threw = true;
190
+ expect(String(e.message).includes('q-value'), true);
191
+ }
192
+ expect(threw, true);
193
+ });
194
+
195
+ test('accepts alternative column names (ProteinGroups, Log2 Ratio, Q-Value)', async () => {
196
+ const headers = ['ProteinGroups', 'Genes', 'Log2 Ratio', 'Q-Value'];
197
+ const tsv = makeTsv([['P04637', 'TP53', '2.5', '0.001']], headers);
198
+ const df = await parseSpectronautCandidatesText(tsv);
199
+ expect(df.rowCount, 1);
200
+ expect(df.col('log2FC') !== null, true);
201
+ expect(df.col('adj.p-value') !== null, true);
202
+ });
203
+
204
+ test('keeps Comparison column for multi-comparison files', async () => {
205
+ const tsv = makeTsv([
206
+ baseRow('Treatment1 / Control', 'P04637', 'TP53', '2.5', '0.001', '0.0001'),
207
+ baseRow('Treatment2 / Control', 'P04637', 'TP53', '1.8', '0.002', '0.0005'),
208
+ baseRow('Treatment1 / Control', 'P38398', 'BRCA1', '-2.3', '0.001', '0.0001'),
209
+ ], BASE_HEADERS);
210
+ const df = await parseSpectronautCandidatesText(tsv);
211
+ expect(df.rowCount, 3);
212
+ expect(df.col('Comparison (group1/group2)') !== null, true);
213
+ });
214
+
215
+ // --- R3/D-08: per-row sign normalization (13-03) ---
216
+
217
+ const QTY_HEADERS = [
218
+ 'Comparison (group1/group2)', 'PG.ProteinGroups', 'PG.Genes',
219
+ 'AVG Log2 Ratio', 'Qvalue', 'Pvalue',
220
+ 'AVG Group Quantity Numerator', 'AVG Group Quantity Denominator',
221
+ 'Condition Numerator', 'Condition Denominator',
222
+ ];
223
+
224
+ test('flips reversed-comparison rows; canonical rows untouched', async () => {
225
+ // First declared comparison ("DMD / WT") is canonical. The "WT / DMD" row
226
+ // is its exact reverse → log2FC negated, AVG Group Quantity swapped,
227
+ // Comparison + Condition relabeled. The canonical row is byte-identical.
228
+ const tsv = makeTsv([
229
+ ['DMD / WT', 'P1', 'G1', '-2.0', '0.001', '0.0001', '100', '400', 'DMD', 'WT'],
230
+ ['WT / DMD', 'P2', 'G2', '3.0', '0.001', '0.0001', '800', '100', 'WT', 'DMD'],
231
+ ], QTY_HEADERS);
232
+ const df = await parseSpectronautCandidatesText(tsv);
233
+ const cmp = df.col('Comparison (group1/group2)')!;
234
+ const fc = df.col('log2FC')!;
235
+ const num = df.col('AVG Group Quantity Numerator')!;
236
+ const den = df.col('AVG Group Quantity Denominator')!;
237
+ // Canonical row 0 — unchanged.
238
+ expect(cmp.get(0), 'DMD / WT');
239
+ expect(Math.abs((fc.get(0) as number) - (-2.0)) < 1e-6, true);
240
+ expect(Math.abs((num.get(0) as number) - 100) < 1e-4, true);
241
+ expect(Math.abs((den.get(0) as number) - 400) < 1e-4, true);
242
+ // Reversed row 1 — flipped.
243
+ expect(cmp.get(1), 'DMD / WT');
244
+ expect(Math.abs((fc.get(1) as number) - (-3.0)) < 1e-6, true);
245
+ expect(Math.abs((num.get(1) as number) - 100) < 1e-4, true); // swapped 800<->100
246
+ expect(Math.abs((den.get(1) as number) - 800) < 1e-4, true);
247
+ expect(df.col('Condition Numerator')!.get(1), 'DMD');
248
+ expect(df.col('Condition Denominator')!.get(1), 'WT');
249
+ });
250
+
251
+ test('flips log2FC without AVG Group Quantity columns (no swap, no throw)', async () => {
252
+ const tsv = makeTsv([
253
+ baseRow('DMD / WT', 'P1', 'G1', '-2.0', '0.001', '0.0001'),
254
+ baseRow('WT / DMD', 'P2', 'G2', '3.0', '0.001', '0.0001'),
255
+ ], BASE_HEADERS);
256
+ const df = await parseSpectronautCandidatesText(tsv);
257
+ const fc = df.col('log2FC')!;
258
+ expect(Math.abs((fc.get(0) as number) - (-2.0)) < 1e-6, true); // canonical untouched
259
+ expect(Math.abs((fc.get(1) as number) - (-3.0)) < 1e-6, true); // reversed flipped
260
+ expect(df.col('Comparison (group1/group2)')!.get(1), 'DMD / WT');
261
+ expect(df.col('AVG Group Quantity Numerator'), null); // never created
262
+ });
263
+
264
+ test('single-orientation file is not mirrored', async () => {
265
+ const tsv = makeTsv([
266
+ baseRow('Treatment / Control', 'P1', 'A', '2.5', '0.001', '0.0001'),
267
+ baseRow('Treatment / Control', 'P2', 'B', '-1.7', '0.002', '0.0005'),
268
+ ], BASE_HEADERS);
269
+ const df = await parseSpectronautCandidatesText(tsv);
270
+ const fc = df.col('log2FC')!;
271
+ expect(Math.abs((fc.get(0) as number) - 2.5) < 1e-6, true);
272
+ expect(Math.abs((fc.get(1) as number) - (-1.7)) < 1e-6, true);
273
+ expect(df.col('Comparison (group1/group2)')!.get(0), 'Treatment / Control');
274
+ });
275
+
276
+ test('parses CSV when delimiter is comma', async () => {
277
+ const csv = 'PG.ProteinGroups,PG.Genes,AVG Log2 Ratio,Qvalue\n' +
278
+ 'P04637,TP53,2.5,0.001\n' +
279
+ 'P38398,BRCA1,-2.3,0.001';
280
+ const df = await parseSpectronautCandidatesText(csv);
281
+ expect(df.rowCount, 2);
282
+ expect(df.col('log2FC') !== null, true);
283
+ });
284
+
285
+ test('creates Primary Protein ID for semicolon-delimited IDs', async () => {
286
+ const tsv = makeTsv([
287
+ baseRow('A / B', 'P04637;Q9Y6R7', 'TP53', '2.5', '0.001', '0.0001'),
288
+ ], BASE_HEADERS);
289
+ const df = await parseSpectronautCandidatesText(tsv);
290
+ const primary = df.col('Primary Protein ID');
291
+ expect(primary !== null, true);
292
+ expect(primary!.get(0), 'P04637');
293
+ });
294
+ });
295
+
296
+ /**
297
+ * 14-03 G4 + D-05: the unified Filters viewer must scope to Comparison +
298
+ * Display Name + Source ID, never the boolean Flags column that Phase 13
299
+ * observed the legacy columnNames allowlist auto-attaching.
300
+ */
301
+ function findFiltersViewer(tv: DG.TableView): DG.Viewer | null {
302
+ for (const v of tv.viewers) {
303
+ if (v.type === DG.VIEWER.FILTERS) return v;
304
+ }
305
+ return null;
306
+ }
307
+
308
+ function makeMultiContrastDf(opts: {
309
+ withDisplayName?: boolean;
310
+ withSourceId?: boolean;
311
+ withFlags?: boolean;
312
+ withProteinId?: boolean;
313
+ comparisons?: string[];
314
+ } = {}): DG.DataFrame {
315
+ const {
316
+ withDisplayName = true, withSourceId = true,
317
+ withFlags = true, withProteinId = true,
318
+ comparisons = ['T1 / Control', 'T2 / Control', 'T1 / Control', 'T2 / Control'],
319
+ } = opts;
320
+
321
+ const cols: DG.Column[] = [
322
+ DG.Column.fromStrings('Comparison (group1/group2)', comparisons),
323
+ DG.Column.fromList('double' as DG.ColumnType, 'log2FC',
324
+ Array.from({length: comparisons.length}, (_, i) => i - 1.5)),
325
+ ];
326
+ if (withProteinId) {
327
+ const pid = DG.Column.fromStrings('Primary Protein ID',
328
+ comparisons.map((_, i) => `P${10000 + i}`));
329
+ pid.semType = SEMTYPE.PROTEIN_ID;
330
+ cols.push(pid);
331
+ }
332
+ if (withDisplayName) {
333
+ const dn = DG.Column.fromStrings('Display Name',
334
+ comparisons.map((_, i) => `Gene${i + 1}`));
335
+ dn.semType = SEMTYPE.DISPLAY_NAME;
336
+ cols.push(dn);
337
+ }
338
+ if (withSourceId) {
339
+ const sid = DG.Column.fromStrings('Source ID',
340
+ comparisons.map((_, i) => `ENSG${10000 + i}`));
341
+ sid.semType = SEMTYPE.SOURCE_ID;
342
+ cols.push(sid);
343
+ }
344
+ if (withFlags) {
345
+ cols.push(DG.Column.fromBitSet('Flags',
346
+ DG.BitSet.create(comparisons.length, (i) => i % 2 === 0)));
347
+ }
348
+ return DG.DataFrame.fromColumns(cols);
349
+ }
350
+
351
+ category('Proteomics: 14-03', () => {
352
+ test('filtersScopingNoFlags', async () => {
353
+ // The dock contract is observable; the column-membership of the docked
354
+ // viewer is NOT observable through getOptions().look — commit e527d07ba1
355
+ // discovered the platform serializer strips both `filters[]` and
356
+ // `columnNames` subtrees regardless of which shape we feed in. Round-3
357
+ // human UAT (2026-06-04) confirmed the viewer renders the requested
358
+ // columns at runtime. This test pins the docking decision; the
359
+ // visual-membership and Flags-exclusion contracts are covered by the
360
+ // Phase 13 round-3 HUMAN-UAT record.
361
+ const df = makeMultiContrastDf();
362
+ const tv = grok.shell.addTableView(df);
363
+ try {
364
+ const docked = dockComparisonFilterIfMultiContrast(tv, df);
365
+ expect(docked, true);
366
+ expect(findFiltersViewer(tv) !== null, true);
367
+ } finally {
368
+ tv.close();
369
+ }
370
+ });
371
+
372
+ test('filtersScopingSingleContrast', async () => {
373
+ const df = makeMultiContrastDf({comparisons: ['T1 / Control', 'T1 / Control', 'T1 / Control']});
374
+ const tv = grok.shell.addTableView(df);
375
+ try {
376
+ const docked = dockComparisonFilterIfMultiContrast(tv, df);
377
+ expect(docked, false);
378
+ expect(findFiltersViewer(tv), null);
379
+ } finally {
380
+ tv.close();
381
+ }
382
+ });
383
+
384
+ test('filtersScopingFallbackToProteinIdWhenNoDisplayName', async () => {
385
+ // The DataFrame lacks Display Name + Source ID; assert the docking
386
+ // decision still fires (Protein ID fallback path). Per the note above,
387
+ // column-membership is not observable via getOptions().
388
+ const df = makeMultiContrastDf({withDisplayName: false, withSourceId: false});
389
+ const tv = grok.shell.addTableView(df);
390
+ try {
391
+ const docked = dockComparisonFilterIfMultiContrast(tv, df);
392
+ expect(docked, true);
393
+ expect(findFiltersViewer(tv) !== null, true);
394
+ } finally {
395
+ tv.close();
396
+ }
397
+ });
398
+ });