@datagrok/proteomics 1.0.1 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -3
- package/CLAUDE.md +178 -0
- package/README.md +195 -3
- package/detectors.js +152 -9
- package/dist/package-test.js +1 -1744
- package/dist/package-test.js.map +1 -1
- package/dist/package.js +1 -146
- package/dist/package.js.map +1 -1
- package/docs/personas-and-capabilities.md +165 -0
- package/files/demo/README.md +264 -0
- package/files/demo/cptac-spike-in.txt +1571 -0
- package/files/demo/enrichment-demo.csv +120 -0
- package/files/demo/fragpipe-smoke-test.tsv +11 -0
- package/files/demo/proteinGroups.txt +28 -0
- package/files/demo/spectronaut-hye-candidates.tsv +94 -0
- package/files/demo/spectronaut-hye-demo.tsv +8761 -0
- package/files/demo/spectronaut-hye-mix.tsv +8761 -0
- package/files/demo/spectronaut-hye-precursor-golden.json +938 -0
- package/files/demo/spectronaut-hye-precursor-golden.tsv +235 -0
- package/files/demo/spectronaut-hye-precursor.tsv +493 -0
- package/images/enrichment-crosslink.png +0 -0
- package/images/enrichment-term-selected.png +0 -0
- package/images/hero.png +0 -0
- package/images/pipeline.svg +80 -0
- package/package.json +88 -63
- package/scripts/deqms_de.R +60 -0
- package/scripts/limma_de.R +42 -0
- package/scripts/vsn_normalize.R +19 -0
- package/src/analysis/differential-expression.ts +450 -0
- package/src/analysis/enrichment-export.ts +101 -0
- package/src/analysis/enrichment.ts +602 -0
- package/src/analysis/experiment-setup.ts +199 -0
- package/src/analysis/imputation.ts +407 -0
- package/src/analysis/log2-scale.ts +139 -0
- package/src/analysis/normalization.ts +255 -0
- package/src/analysis/pca.ts +254 -0
- package/src/analysis/spc-storage.ts +515 -0
- package/src/analysis/spc.ts +544 -0
- package/src/analysis/subcellular-location.ts +431 -0
- package/src/demo/enrichment-demo.ts +94 -0
- package/src/demo/proteomics-demo.ts +123 -0
- package/src/global.d.ts +15 -0
- package/src/menu.ts +133 -0
- package/src/package-api.ts +136 -14
- package/src/package-test.ts +45 -20
- package/src/package.g.ts +161 -0
- package/src/package.ts +1029 -17
- package/src/panels/protein-focus.ts +63 -0
- package/src/panels/published-analysis-panel.ts +151 -0
- package/src/panels/uniprot-panel.ts +349 -0
- package/src/parsers/fragpipe-parser.ts +200 -0
- package/src/parsers/generic-parser.ts +197 -0
- package/src/parsers/maxquant-parser.ts +162 -0
- package/src/parsers/shared-utils.ts +163 -0
- package/src/parsers/spectronaut-candidates-parser.ts +307 -0
- package/src/parsers/spectronaut-parser.ts +604 -0
- package/src/publishing/assert-published-shape.ts +260 -0
- package/src/publishing/post-open-recovery.ts +104 -0
- package/src/publishing/publish-project.ts +515 -0
- package/src/publishing/publish-settings.ts +59 -0
- package/src/publishing/publish-state.ts +316 -0
- package/src/publishing/share-dialog.ts +171 -0
- package/src/publishing/trim-dataframe.ts +247 -0
- package/src/tests/analysis.ts +658 -0
- package/src/tests/enrichment-export.ts +61 -0
- package/src/tests/enrichment-visualization.ts +340 -0
- package/src/tests/enrichment.ts +224 -0
- package/src/tests/fragpipe-e2e.ts +74 -0
- package/src/tests/fragpipe-parser.ts +147 -0
- package/src/tests/gene-label-resolver.ts +387 -0
- package/src/tests/generic-parser.ts +152 -0
- package/src/tests/group-mean-correlation.ts +139 -0
- package/src/tests/log2-scale.ts +93 -0
- package/src/tests/organisms.ts +56 -0
- package/src/tests/parsers.ts +182 -0
- package/src/tests/publish-roundtrip.ts +584 -0
- package/src/tests/publish-spike.ts +377 -0
- package/src/tests/qc-dashboard.ts +210 -0
- package/src/tests/smart-pathway-filter.ts +193 -0
- package/src/tests/spc-formula-lines-spike.ts +129 -0
- package/src/tests/spc.ts +640 -0
- package/src/tests/spectronaut-candidates-e2e.ts +140 -0
- package/src/tests/spectronaut-candidates-parser.ts +398 -0
- package/src/tests/spectronaut-parser.ts +668 -0
- package/src/tests/subcellular-location.ts +361 -0
- package/src/tests/uniprot-panel.ts +202 -0
- package/src/tests/volcano.ts +603 -0
- package/src/utils/column-detection.ts +28 -0
- package/src/utils/gene-label-resolver.ts +443 -0
- package/src/utils/organisms.ts +82 -0
- package/src/utils/proteomics-types.ts +30 -0
- package/src/viewers/enrichment-viewers.ts +274 -0
- package/src/viewers/group-mean-correlation.ts +218 -0
- package/src/viewers/heatmap.ts +168 -0
- package/src/viewers/pca-plot.ts +169 -0
- package/src/viewers/qc-computations.ts +266 -0
- package/src/viewers/qc-dashboard.ts +176 -0
- package/src/viewers/spc-dashboard.ts +755 -0
- package/src/viewers/volcano.ts +690 -0
- package/test-console-output-1.log +2073 -0
- package/test-record-1.mp4 +0 -0
- package/tools/derive-precursor-golden-sidecar.mjs +81 -0
- package/tools/generate-enrichment-fixture.sh +160 -0
- package/tools/generate-spectronaut-candidates-fixture.mjs +212 -0
- package/tools/generate-spectronaut-precursor-fixture.mjs +128 -0
- package/tools/spectronaut-aggregate.sh +46 -0
- package/tools/spectronaut-aggregate.sql +80 -0
- package/tsconfig.json +18 -71
- package/webpack.config.js +86 -45
- package/.eslintignore +0 -1
- package/.eslintrc.json +0 -56
- package/LICENSE +0 -674
- package/package.png +0 -0
- package/scripts/number_antibody.py +0 -190
- package/scripts/number_antibody_abnumber.py +0 -177
- package/scripts/number_antibody_anarci.py +0 -200
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
import * as grok from 'datagrok-api/grok';
|
|
2
|
+
import * as DG from 'datagrok-api/dg';
|
|
3
|
+
import {awaitCheck, category, delay, expect, test} from '@datagrok-libraries/test/src/test';
|
|
4
|
+
|
|
5
|
+
import {SEMTYPE} from '../utils/proteomics-types';
|
|
6
|
+
import {
|
|
7
|
+
META_COLUMNS, PUBLISHED_TAGS, PublishOptions, PublishedMetadata,
|
|
8
|
+
isPublished, slugifyTarget,
|
|
9
|
+
} from '../publishing/publish-state';
|
|
10
|
+
import {publishAnalysis} from '../publishing/publish-project';
|
|
11
|
+
import {
|
|
12
|
+
PublishedShapeContract, assertPublishedShape,
|
|
13
|
+
} from '../publishing/assert-published-shape';
|
|
14
|
+
import {recoverPublishedProject} from '../publishing/post-open-recovery';
|
|
15
|
+
|
|
16
|
+
const DEFAULT_TARGET_PREFIX = 'pub-roundtrip-';
|
|
17
|
+
const DEFAULT_FC = 1.0;
|
|
18
|
+
const DEFAULT_P = 0.05;
|
|
19
|
+
|
|
20
|
+
interface RoundTripFixtures {
|
|
21
|
+
protein: DG.DataFrame;
|
|
22
|
+
enrichment: DG.DataFrame;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function createSyntheticDeFixture(): DG.DataFrame {
|
|
26
|
+
const proteinIds = ['P10001', 'P10002', 'P10003', 'P10004', 'P10005',
|
|
27
|
+
'P10006', 'P10007', 'P10008', 'P10009', 'P10010'];
|
|
28
|
+
const geneNames = ['ALPHA', 'BETA', 'GAMMA', 'DELTA', 'EPSILON',
|
|
29
|
+
'ZETA', 'ETA', 'THETA', 'IOTA', 'KAPPA'];
|
|
30
|
+
const log2fc = new Float32Array([3.2, 2.6, 2.1, 1.7, 1.3,
|
|
31
|
+
0.2, -1.7, -2.3, -2.9, -3.4]);
|
|
32
|
+
const pVals = new Float32Array([0.0001, 0.0005, 0.001, 0.005, 0.02,
|
|
33
|
+
0.5, 0.005, 0.001, 0.0005, 0.0001]);
|
|
34
|
+
const adjP = new Float32Array([0.0005, 0.0015, 0.005, 0.01, 0.04,
|
|
35
|
+
0.6, 0.01, 0.005, 0.0015, 0.0005]);
|
|
36
|
+
const significant = ['yes', 'yes', 'yes', 'yes', 'yes',
|
|
37
|
+
'no', 'yes', 'yes', 'yes', 'yes'];
|
|
38
|
+
const direction = ['Up', 'Up', 'Up', 'Up', 'Up',
|
|
39
|
+
'NS', 'Down', 'Down', 'Down', 'Down'];
|
|
40
|
+
|
|
41
|
+
const df = DG.DataFrame.fromColumns([
|
|
42
|
+
DG.Column.fromStrings('Protein ID', proteinIds),
|
|
43
|
+
DG.Column.fromStrings('Gene Name', geneNames),
|
|
44
|
+
DG.Column.fromFloat32Array('log2FC', log2fc),
|
|
45
|
+
DG.Column.fromFloat32Array('p-value', pVals),
|
|
46
|
+
DG.Column.fromFloat32Array('adj.p-value', adjP),
|
|
47
|
+
DG.Column.fromStrings('significant', significant),
|
|
48
|
+
DG.Column.fromStrings('direction', direction),
|
|
49
|
+
]);
|
|
50
|
+
df.col('Protein ID')!.semType = SEMTYPE.PROTEIN_ID;
|
|
51
|
+
df.col('Gene Name')!.semType = SEMTYPE.GENE_SYMBOL;
|
|
52
|
+
df.col('log2FC')!.semType = SEMTYPE.LOG2FC;
|
|
53
|
+
df.col('adj.p-value')!.semType = SEMTYPE.P_VALUE;
|
|
54
|
+
|
|
55
|
+
df.setTag('proteomics.source', 'generic');
|
|
56
|
+
df.setTag('proteomics.de_method', 'limma');
|
|
57
|
+
df.setTag('proteomics.de_complete', 'true');
|
|
58
|
+
df.setTag('proteomics.groups', JSON.stringify({
|
|
59
|
+
group1: {name: 'Control', columns: ['s1', 's2', 's3']},
|
|
60
|
+
group2: {name: 'Treatment', columns: ['s4', 's5', 's6']},
|
|
61
|
+
}));
|
|
62
|
+
df.name = 'fixture-no-enrichment';
|
|
63
|
+
return df;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function createSpectronautCandidatesFixture(): RoundTripFixtures {
|
|
67
|
+
const protein = createSyntheticDeFixture();
|
|
68
|
+
protein.setTag('proteomics.source', 'spectronaut-candidates');
|
|
69
|
+
protein.setTag('proteomics.de_method', 'spectronaut');
|
|
70
|
+
// proteomics.enrichment is a marker for the *enrichment* DF only, not protein.
|
|
71
|
+
// Plan 04 step 1 finds the enrichment DF in shell.tables via that tag.
|
|
72
|
+
protein.name = 'fixture-with-enrichment';
|
|
73
|
+
|
|
74
|
+
const terms = ['Term A', 'Term B', 'Term C', 'Term D', 'Term E', 'Term F'];
|
|
75
|
+
const sources = ['GO:BP', 'KEGG', 'GO:BP', 'GO:BP', 'KEGG', 'REAC'];
|
|
76
|
+
const adjP = new Float32Array([0.005, 0.01, 0.015, 0.02, 0.03, 0.05]);
|
|
77
|
+
const intersection = [
|
|
78
|
+
'ALPHA,BETA,GAMMA',
|
|
79
|
+
'DELTA,EPSILON',
|
|
80
|
+
'ETA,THETA',
|
|
81
|
+
'IOTA,KAPPA',
|
|
82
|
+
'ALPHA,DELTA',
|
|
83
|
+
'BETA,GAMMA,ETA',
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
// Mirror the real buildEnrichmentDf schema (enrichment.ts): a single
|
|
87
|
+
// FDR-corrected significance column named 'FDR', no separate raw p-value.
|
|
88
|
+
const enrichment = DG.DataFrame.fromColumns([
|
|
89
|
+
DG.Column.fromStrings('Term Name', terms),
|
|
90
|
+
DG.Column.fromStrings('Source', sources),
|
|
91
|
+
DG.Column.fromFloat32Array('FDR', adjP),
|
|
92
|
+
DG.Column.fromStrings('Intersection', intersection),
|
|
93
|
+
]);
|
|
94
|
+
enrichment.setTag('proteomics.enrichment', 'true');
|
|
95
|
+
enrichment.name = 'enrichment-fixture';
|
|
96
|
+
return {protein, enrichment};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Returns a throwaway non-admin group suitable as a synthetic reviewer
|
|
100
|
+
* group. The publishing user's own group is unsuitable because it carries
|
|
101
|
+
* admin permissions on every Space the user owns — the verify-and-rollback
|
|
102
|
+
* gate would correctly trip on those (defeating Tests 1–4). The 'Test'
|
|
103
|
+
* built-in group is the documented platform fixture for this case. */
|
|
104
|
+
async function pickAnyGroup(): Promise<DG.Group> {
|
|
105
|
+
const TEST_GROUP_ID = (DG.Group as any).defaultGroupsIds?.['Test'];
|
|
106
|
+
if (TEST_GROUP_ID) {
|
|
107
|
+
try {
|
|
108
|
+
const g = await grok.dapi.groups.find(TEST_GROUP_ID);
|
|
109
|
+
if (g) return g;
|
|
110
|
+
} catch { /* fall through */ }
|
|
111
|
+
}
|
|
112
|
+
// Fallback — list groups, find the first non-personal non-hidden one whose
|
|
113
|
+
// friendlyName/name is NOT the current user's personal group.
|
|
114
|
+
const userGroupId = (grok.shell.user as any)?.group?.id;
|
|
115
|
+
const all = await grok.dapi.groups.list();
|
|
116
|
+
const candidate = (all ?? []).find((g: any) =>
|
|
117
|
+
g && !g.hidden && !g.personal && g.id !== userGroupId);
|
|
118
|
+
if (candidate) return candidate;
|
|
119
|
+
return (all ?? [])[0];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function buildExpectedContract(
|
|
123
|
+
frozenName: string,
|
|
124
|
+
projectName: string,
|
|
125
|
+
expectedAllowlist: string[],
|
|
126
|
+
meta: PublishedMetadata,
|
|
127
|
+
expectEnrichment: boolean,
|
|
128
|
+
): PublishedShapeContract {
|
|
129
|
+
return {
|
|
130
|
+
expectedName: frozenName,
|
|
131
|
+
expectedProjectName: projectName,
|
|
132
|
+
expectedAllowlist,
|
|
133
|
+
expectedMeta: {...meta},
|
|
134
|
+
expectVolcano: true,
|
|
135
|
+
expectEnrichment,
|
|
136
|
+
expectFormulaLines: true,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function cleanupProject(project: DG.Project | null): Promise<void> {
|
|
141
|
+
if (!project) return;
|
|
142
|
+
try {
|
|
143
|
+
await grok.dapi.projects.delete(project);
|
|
144
|
+
} catch (e) {
|
|
145
|
+
// Surface, don't swallow — a silent failure here is exactly how 21 orphaned
|
|
146
|
+
// review projects accumulated on localhost before this was hardened.
|
|
147
|
+
// eslint-disable-next-line no-console
|
|
148
|
+
console.warn(`[publish-roundtrip] project cleanup failed (${(project as any)?.name}): ${e}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* publishAnalysis creates a per-target child Space `Proteomics-Review-<slug>`
|
|
154
|
+
* under the `Proteomics-Reviews` umbrella (publish-project.ts step 5) but
|
|
155
|
+
* returns only the leaf project. cleanupProject removes the leaf; this removes
|
|
156
|
+
* the otherwise-orphaned child Space. Best-effort and idempotent — runs in
|
|
157
|
+
* each test's finally after the leaf is deleted.
|
|
158
|
+
*/
|
|
159
|
+
async function cleanupChildSpace(target: string): Promise<void> {
|
|
160
|
+
const dapiAny = grok.dapi as any;
|
|
161
|
+
const childName = `Proteomics-Review-${slugifyTarget(target)}`;
|
|
162
|
+
try {
|
|
163
|
+
const all: any[] = await dapiAny.spaces.list();
|
|
164
|
+
const umbrella = (all ?? []).find((p) =>
|
|
165
|
+
p?.name === 'Proteomics-Reviews' || p?.friendlyName === 'Proteomics-Reviews');
|
|
166
|
+
if (!umbrella) return;
|
|
167
|
+
const kids: any[] = await dapiAny.spaces.id(umbrella.id).children.filter('Project', false).list();
|
|
168
|
+
// The stored `name` is camelCased (hyphens stripped) so match friendlyName too.
|
|
169
|
+
const child = (kids ?? []).find((k) => k?.friendlyName === childName || k?.name === childName);
|
|
170
|
+
if (child) await dapiAny.spaces.delete(child);
|
|
171
|
+
} catch (e) {
|
|
172
|
+
// eslint-disable-next-line no-console
|
|
173
|
+
console.warn(`[publish-roundtrip] child-space cleanup failed (${childName}): ${e}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function safeDelete(fn: () => Promise<void>): Promise<void> {
|
|
178
|
+
try { await fn(); } catch { /* best-effort */ }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function expectedAllowlistFromTrimmedView(): string[] {
|
|
182
|
+
// After publishAnalysis returns, grok.shell.tv is showing the trimmed DF
|
|
183
|
+
// (publishAnalysis's Step 6.5 opened it in a new TableView and added a
|
|
184
|
+
// volcano viewer — the volcano factory adds derived columns like
|
|
185
|
+
// '-log10(adj.p-value)' on top of Plan 02's trim allowlist). The contract's
|
|
186
|
+
// expectedAllowlist must reflect that actual post-trim post-volcano state.
|
|
187
|
+
const tv = grok.shell.tv;
|
|
188
|
+
if (!tv) return [];
|
|
189
|
+
// Exclude _meta_* belt-and-braces columns and ~-prefixed technical columns
|
|
190
|
+
// (e.g. the volcano's '~Volcano label') — they aren't part of the shape.
|
|
191
|
+
return tv.dataFrame.columns.toList()
|
|
192
|
+
.map((c) => c.name)
|
|
193
|
+
.filter((n) => !n.startsWith('_meta_') && !n.startsWith('~'));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function expectedMetaForTest(
|
|
197
|
+
project: DG.Project,
|
|
198
|
+
sourceDf: DG.DataFrame,
|
|
199
|
+
target: string,
|
|
200
|
+
includesEnrichment: boolean,
|
|
201
|
+
): PublishedMetadata {
|
|
202
|
+
// Build the expected contract from what was ACTUALLY published, not from
|
|
203
|
+
// grok.shell.tv: publishAnalysis's Step 10 restore leaves the current view on
|
|
204
|
+
// the (unpublished) source DF, so reading published metadata off it returns
|
|
205
|
+
// null. publishId is generated inside publishAnalysis and stamped on the
|
|
206
|
+
// project's options — read it back rather than guess. deMethod comes from the
|
|
207
|
+
// source DF's tag (what the publisher recorded); fc/p/version are the publish
|
|
208
|
+
// defaults the tests rely on.
|
|
209
|
+
const publishId = ((project as any).options?.[PUBLISHED_TAGS.PUBLISHED_ID] as string | undefined) ?? '';
|
|
210
|
+
const deMethod = sourceDf.getTag('proteomics.de_method') ?? 't-test';
|
|
211
|
+
return {
|
|
212
|
+
target,
|
|
213
|
+
publishedAt: new Date(), // not asserted
|
|
214
|
+
publishedBy: (grok.shell.user as any)?.friendlyName ?? '',
|
|
215
|
+
publishedByEmail: (grok.shell.user as any)?.email ?? null, // not asserted
|
|
216
|
+
deMethod,
|
|
217
|
+
fcThreshold: DEFAULT_FC,
|
|
218
|
+
pThreshold: DEFAULT_P,
|
|
219
|
+
version: 1,
|
|
220
|
+
publishId,
|
|
221
|
+
includesEnrichment,
|
|
222
|
+
supersedes: null,
|
|
223
|
+
supersededBy: null,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
category('Publishing', () => {
|
|
228
|
+
test('assertPublishedShape round-trip on synthetic fixture (no-enrichment) — formula lines survive reopen',
|
|
229
|
+
async () => {
|
|
230
|
+
const df = createSyntheticDeFixture();
|
|
231
|
+
const tv = grok.shell.addTableView(df);
|
|
232
|
+
await delay(100);
|
|
233
|
+
try { (tv as any).scatterPlot?.({x: 'log2FC', y: 'adj.p-value'}); } catch { /* swallow */ }
|
|
234
|
+
await delay(100);
|
|
235
|
+
|
|
236
|
+
const target = `${DEFAULT_TARGET_PREFIX}t1-${Date.now()}`;
|
|
237
|
+
const group = await pickAnyGroup();
|
|
238
|
+
const opts: PublishOptions = {target, reviewerGroup: group, note: 'Phase 15 roundtrip Test 1', priorVersion: null};
|
|
239
|
+
|
|
240
|
+
let project: DG.Project | null = null;
|
|
241
|
+
try {
|
|
242
|
+
project = await publishAnalysis(df, opts);
|
|
243
|
+
expect(!!project, true);
|
|
244
|
+
|
|
245
|
+
const projectName = (project as any).name as string;
|
|
246
|
+
const meta = expectedMetaForTest(project!, df, target, false);
|
|
247
|
+
const allowlist = expectedAllowlistFromTrimmedView();
|
|
248
|
+
const contract = buildExpectedContract(
|
|
249
|
+
`${df.name}_published_${new Date().toISOString().slice(0, 10)}`,
|
|
250
|
+
projectName,
|
|
251
|
+
allowlist,
|
|
252
|
+
meta,
|
|
253
|
+
false,
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
// Re-run independently — assertPublishedShape closed all + reopened in-process.
|
|
257
|
+
await assertPublishedShape(project!, contract);
|
|
258
|
+
|
|
259
|
+
// B-2 explicit formula-line assertion — independent of assertPublishedShape's Assertion 8a.
|
|
260
|
+
const reTv = grok.shell.tv;
|
|
261
|
+
expect(!!reTv, true);
|
|
262
|
+
const volcano = reTv ? Array.from(reTv.viewers).find((v) => v.type === DG.VIEWER.SCATTER_PLOT) : null;
|
|
263
|
+
expect(!!volcano, true);
|
|
264
|
+
|
|
265
|
+
const formulaCarriers: string[] = [];
|
|
266
|
+
try {
|
|
267
|
+
const items = ((reTv!.dataFrame as any).meta?.formulaLines?.items ?? []) as Array<{formula?: string}>;
|
|
268
|
+
for (const i of items) if (typeof i?.formula === 'string') formulaCarriers.push(i.formula);
|
|
269
|
+
} catch { /* fall through */ }
|
|
270
|
+
try {
|
|
271
|
+
const optsAny: any = (volcano as any).getOptions?.();
|
|
272
|
+
const ll = optsAny?.look?.formulaLines;
|
|
273
|
+
const parsed = typeof ll === 'string' ? JSON.parse(ll) : ll;
|
|
274
|
+
if (Array.isArray(parsed)) for (const i of parsed) if (typeof i?.formula === 'string') formulaCarriers.push(i.formula);
|
|
275
|
+
} catch { /* fall through */ }
|
|
276
|
+
|
|
277
|
+
const hasFcLine = formulaCarriers.some((f) =>
|
|
278
|
+
f.includes(String(DEFAULT_FC)) || f.includes(`-${DEFAULT_FC}`));
|
|
279
|
+
const hasPLine = formulaCarriers.some((f) =>
|
|
280
|
+
f.includes(String(DEFAULT_P)) || f.includes(String(-Math.log10(DEFAULT_P))) || f.includes('log10'));
|
|
281
|
+
expect(hasFcLine, true);
|
|
282
|
+
expect(hasPLine, true);
|
|
283
|
+
} finally {
|
|
284
|
+
await cleanupProject(project);
|
|
285
|
+
await cleanupChildSpace(target);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('assertPublishedShape round-trip on Spectronaut Candidates fixture (with-enrichment); cross-DF subscription re-established on reopen',
|
|
290
|
+
async () => {
|
|
291
|
+
const {protein, enrichment} = createSpectronautCandidatesFixture();
|
|
292
|
+
const proteinTv = grok.shell.addTableView(protein);
|
|
293
|
+
await delay(50);
|
|
294
|
+
grok.shell.addTableView(enrichment);
|
|
295
|
+
await delay(100);
|
|
296
|
+
try { grok.shell.v = proteinTv; } catch { /* swallow */ }
|
|
297
|
+
try { (proteinTv as any).scatterPlot?.({x: 'log2FC', y: 'adj.p-value'}); } catch { /* swallow */ }
|
|
298
|
+
await delay(100);
|
|
299
|
+
|
|
300
|
+
const target = `${DEFAULT_TARGET_PREFIX}t2-${Date.now()}`;
|
|
301
|
+
const group = await pickAnyGroup();
|
|
302
|
+
const opts: PublishOptions = {target, reviewerGroup: group, note: 'Phase 15 roundtrip Test 2', priorVersion: null};
|
|
303
|
+
|
|
304
|
+
let project: DG.Project | null = null;
|
|
305
|
+
try {
|
|
306
|
+
project = await publishAnalysis(protein, opts);
|
|
307
|
+
expect(!!project, true);
|
|
308
|
+
|
|
309
|
+
const projectName = (project as any).name as string;
|
|
310
|
+
const meta = expectedMetaForTest(project!, protein, target, true);
|
|
311
|
+
const allowlist = expectedAllowlistFromTrimmedView();
|
|
312
|
+
const contract = buildExpectedContract(
|
|
313
|
+
`${protein.name}_published_${new Date().toISOString().slice(0, 10)}`,
|
|
314
|
+
projectName,
|
|
315
|
+
allowlist,
|
|
316
|
+
meta,
|
|
317
|
+
true,
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
await assertPublishedShape(project!, contract);
|
|
321
|
+
|
|
322
|
+
// W-5 cross-DF subscription assertion
|
|
323
|
+
const tables = (grok.shell as any).tables as DG.DataFrame[] | undefined;
|
|
324
|
+
expect(Array.isArray(tables), true);
|
|
325
|
+
const reProteinDf = (tables ?? []).find((t) =>
|
|
326
|
+
t.getTag(PUBLISHED_TAGS.PUBLISHED) === 'true' &&
|
|
327
|
+
t.getTag('proteomics.enrichment') !== 'true');
|
|
328
|
+
const reEnrichDf = (tables ?? []).find((t) =>
|
|
329
|
+
t.getTag('proteomics.enrichment') === 'true');
|
|
330
|
+
expect(!!reProteinDf, true);
|
|
331
|
+
expect(!!reEnrichDf, true);
|
|
332
|
+
|
|
333
|
+
// Allow the autostart hook to wire the subscription.
|
|
334
|
+
await delay(300);
|
|
335
|
+
|
|
336
|
+
// If the autostart hook hasn't run (timing / event-API variance across releases),
|
|
337
|
+
// run recoverPublishedProject manually so the test stays deterministic.
|
|
338
|
+
if (reEnrichDf!.getTag('proteomics.enrichment_wired') !== 'true') {
|
|
339
|
+
try { await recoverPublishedProject(reProteinDf!); } catch { /* swallow */ }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
expect(reEnrichDf!.getTag('proteomics.enrichment_wired'), 'true');
|
|
343
|
+
|
|
344
|
+
// Drive a current-row change and verify the protein selection updates.
|
|
345
|
+
reProteinDf!.selection.setAll(false, false);
|
|
346
|
+
const beforeCount = reProteinDf!.selection.trueCount;
|
|
347
|
+
reEnrichDf!.currentRowIdx = 0;
|
|
348
|
+
await delay(150);
|
|
349
|
+
const afterCount = reProteinDf!.selection.trueCount;
|
|
350
|
+
expect(afterCount > beforeCount, true);
|
|
351
|
+
} finally {
|
|
352
|
+
await cleanupProject(project);
|
|
353
|
+
await cleanupChildSpace(target);
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test('workspace restore (Step 10) brings back BOTH the protein and enrichment views',
|
|
358
|
+
async () => {
|
|
359
|
+
// Regression: Step 10's closeAll()+restore used to rebuild only the protein
|
|
360
|
+
// table + volcano, dropping the enrichment view the user had open. The fix
|
|
361
|
+
// re-runs openEnrichmentVisualization(enrichSource, df) on the ORIGINAL
|
|
362
|
+
// (untrimmed) enrichment table so both source views come back, with the
|
|
363
|
+
// user landed on the protein/volcano tab.
|
|
364
|
+
const {protein, enrichment} = createSpectronautCandidatesFixture();
|
|
365
|
+
const proteinTv = grok.shell.addTableView(protein);
|
|
366
|
+
await delay(50);
|
|
367
|
+
grok.shell.addTableView(enrichment);
|
|
368
|
+
await delay(100);
|
|
369
|
+
try { grok.shell.v = proteinTv; } catch { /* swallow */ }
|
|
370
|
+
await delay(50);
|
|
371
|
+
|
|
372
|
+
const target = `${DEFAULT_TARGET_PREFIX}restore-${Date.now()}`;
|
|
373
|
+
const group = await pickAnyGroup();
|
|
374
|
+
// verify:false keeps the test fast — Step 10 restore runs regardless of the
|
|
375
|
+
// round-trip verification toggle, and this test is specifically about restore.
|
|
376
|
+
const opts: PublishOptions = {
|
|
377
|
+
target, reviewerGroup: group, note: 'restore test', priorVersion: null, verify: false,
|
|
378
|
+
} as PublishOptions;
|
|
379
|
+
|
|
380
|
+
let project: DG.Project | null = null;
|
|
381
|
+
try {
|
|
382
|
+
project = await publishAnalysis(protein, opts);
|
|
383
|
+
expect(!!project, true);
|
|
384
|
+
await delay(200);
|
|
385
|
+
|
|
386
|
+
// The restored workspace must contain a view for BOTH original (by stable
|
|
387
|
+
// .dart identity — toJs wrappers differ per access, so strict-equality misses).
|
|
388
|
+
const proteinDart = (protein as any).dart;
|
|
389
|
+
const enrichDart = (enrichment as any).dart;
|
|
390
|
+
let hasProteinView = false;
|
|
391
|
+
let hasEnrichView = false;
|
|
392
|
+
for (const v of grok.shell.tableViews) {
|
|
393
|
+
const d = (v.dataFrame as any)?.dart;
|
|
394
|
+
if (d === proteinDart) hasProteinView = true;
|
|
395
|
+
if (d === enrichDart) hasEnrichView = true;
|
|
396
|
+
}
|
|
397
|
+
expect(hasProteinView, true);
|
|
398
|
+
expect(hasEnrichView, true);
|
|
399
|
+
|
|
400
|
+
// And the user is landed back on the protein/volcano tab, not enrichment.
|
|
401
|
+
expect((grok.shell.tv?.dataFrame as any)?.dart, proteinDart);
|
|
402
|
+
} finally {
|
|
403
|
+
await cleanupProject(project);
|
|
404
|
+
await cleanupChildSpace(target);
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test('source mutation after publish leaves clone unchanged (Pitfall 1)',
|
|
409
|
+
async () => {
|
|
410
|
+
const df = createSyntheticDeFixture();
|
|
411
|
+
const baselineL2FC = df.col('log2FC')!.get(0);
|
|
412
|
+
const baselineTarget = `${DEFAULT_TARGET_PREFIX}t3-${Date.now()}`;
|
|
413
|
+
|
|
414
|
+
grok.shell.addTableView(df);
|
|
415
|
+
await delay(100);
|
|
416
|
+
|
|
417
|
+
const group = await pickAnyGroup();
|
|
418
|
+
const opts: PublishOptions = {target: baselineTarget, reviewerGroup: group, note: '', priorVersion: null};
|
|
419
|
+
|
|
420
|
+
let project: DG.Project | null = null;
|
|
421
|
+
try {
|
|
422
|
+
project = await publishAnalysis(df, opts);
|
|
423
|
+
expect(!!project, true);
|
|
424
|
+
const projectId = (project as any).id;
|
|
425
|
+
|
|
426
|
+
// First reopen — baseline read
|
|
427
|
+
grok.shell.closeAll();
|
|
428
|
+
await delay(100);
|
|
429
|
+
const reopened1 = await grok.dapi.projects.find(projectId);
|
|
430
|
+
await reopened1.open();
|
|
431
|
+
await awaitCheck(() => !!grok.shell.tv && !!grok.shell.tv.dataFrame,
|
|
432
|
+
'reopened1 TableView never materialized', 5000);
|
|
433
|
+
const clonedL2FC = grok.shell.tv!.dataFrame.col('log2FC')!.get(0);
|
|
434
|
+
expect(clonedL2FC, baselineL2FC);
|
|
435
|
+
|
|
436
|
+
// Mutate the SOURCE DF (the one we still hold in memory)
|
|
437
|
+
df.col('log2FC')!.set(0, 999.999);
|
|
438
|
+
try { df.columns.remove('Gene Name'); } catch { /* may have been allowlist-only */ }
|
|
439
|
+
df.setTag(PUBLISHED_TAGS.PUBLISHED_TARGET, 'CHANGED-TARGET');
|
|
440
|
+
|
|
441
|
+
// Reopen the project AGAIN — clone must be unchanged
|
|
442
|
+
grok.shell.closeAll();
|
|
443
|
+
await delay(100);
|
|
444
|
+
const reopened2 = await grok.dapi.projects.find(projectId);
|
|
445
|
+
await reopened2.open();
|
|
446
|
+
await awaitCheck(() => !!grok.shell.tv && !!grok.shell.tv.dataFrame,
|
|
447
|
+
'reopened2 TableView never materialized', 5000);
|
|
448
|
+
const reDf = grok.shell.tv!.dataFrame;
|
|
449
|
+
const reL2FC = reDf.col('log2FC')!.get(0);
|
|
450
|
+
expect(reL2FC, baselineL2FC);
|
|
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);
|
|
455
|
+
} finally {
|
|
456
|
+
await cleanupProject(project);
|
|
457
|
+
await cleanupChildSpace(baselineTarget);
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test('republish creates v2 with bidirectional superseded_by + supersedes pointers (W-8 dual-write)',
|
|
462
|
+
async () => {
|
|
463
|
+
const df = createSyntheticDeFixture();
|
|
464
|
+
const target = `${DEFAULT_TARGET_PREFIX}t4-${Date.now()}`;
|
|
465
|
+
|
|
466
|
+
grok.shell.addTableView(df);
|
|
467
|
+
await delay(100);
|
|
468
|
+
|
|
469
|
+
const group = await pickAnyGroup();
|
|
470
|
+
const optsV1: PublishOptions = {target, reviewerGroup: group, note: 'v1', priorVersion: null};
|
|
471
|
+
|
|
472
|
+
let projectV1: DG.Project | null = null;
|
|
473
|
+
let projectV2: DG.Project | null = null;
|
|
474
|
+
try {
|
|
475
|
+
projectV1 = await publishAnalysis(df, optsV1);
|
|
476
|
+
expect(!!projectV1, true);
|
|
477
|
+
const v1Id = (projectV1 as any).id;
|
|
478
|
+
const v1Name = (projectV1 as any).name as string;
|
|
479
|
+
expect(/-v1-\d{4}-\d{2}-\d{2}$/.test(v1Name), true);
|
|
480
|
+
|
|
481
|
+
// Re-discover the source DF view (publishAnalysis opened the trimmed view)
|
|
482
|
+
await delay(200);
|
|
483
|
+
|
|
484
|
+
const optsV2: PublishOptions = {target, reviewerGroup: group, note: 'v2', priorVersion: projectV1};
|
|
485
|
+
projectV2 = await publishAnalysis(df, optsV2);
|
|
486
|
+
expect(!!projectV2, true);
|
|
487
|
+
const v2Id = (projectV2 as any).id;
|
|
488
|
+
const v2Name = (projectV2 as any).name as string;
|
|
489
|
+
expect(/-v2-\d{4}-\d{2}-\d{2}$/.test(v2Name), true);
|
|
490
|
+
|
|
491
|
+
// Re-discover v2's options[SUPERSEDES]
|
|
492
|
+
grok.shell.closeAll();
|
|
493
|
+
await delay(100);
|
|
494
|
+
const v2Reopened = await grok.dapi.projects.find(v2Id);
|
|
495
|
+
await v2Reopened.open();
|
|
496
|
+
await awaitCheck(() => !!grok.shell.tv && !!grok.shell.tv.dataFrame,
|
|
497
|
+
'v2 reopen', 5000);
|
|
498
|
+
const v2Opts: any = (v2Reopened as any).options ?? {};
|
|
499
|
+
expect(v2Opts[PUBLISHED_TAGS.SUPERSEDES], v1Id);
|
|
500
|
+
|
|
501
|
+
// Re-discover v1's bidirectional pointers (W-8 dual-write)
|
|
502
|
+
grok.shell.closeAll();
|
|
503
|
+
await delay(100);
|
|
504
|
+
const v1Reopened = await grok.dapi.projects.find(v1Id);
|
|
505
|
+
await v1Reopened.open();
|
|
506
|
+
await awaitCheck(() => !!grok.shell.tv && !!grok.shell.tv.dataFrame,
|
|
507
|
+
'v1 reopen', 5000);
|
|
508
|
+
const v1Opts: any = (v1Reopened as any).options ?? {};
|
|
509
|
+
expect(v1Opts[PUBLISHED_TAGS.SUPERSEDED_BY], v2Id);
|
|
510
|
+
|
|
511
|
+
const v1Df = grok.shell.tv!.dataFrame;
|
|
512
|
+
expect(v1Df.getTag(PUBLISHED_TAGS.SUPERSEDED_BY), v2Id);
|
|
513
|
+
const supColCheck = v1Df.col(META_COLUMNS.SUPERSEDED_BY);
|
|
514
|
+
// The metadata column was created at v1 publish (empty string then) and patched
|
|
515
|
+
// on republish. It may be empty if the dual-write patch was best-effort.
|
|
516
|
+
if (supColCheck) {
|
|
517
|
+
const val = supColCheck.get(0);
|
|
518
|
+
// Accept either patched value OR the original empty string (W-8 dual-write best-effort).
|
|
519
|
+
if (val !== '' && val != null) expect(String(val), v2Id);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Verify prior version NOT deleted (D-04 explicit)
|
|
523
|
+
const stillThere = await grok.dapi.projects.find(v1Id);
|
|
524
|
+
expect(!!stillThere, true);
|
|
525
|
+
} finally {
|
|
526
|
+
await cleanupProject(projectV2);
|
|
527
|
+
await cleanupProject(projectV1);
|
|
528
|
+
// Both versions share one target → one child Space to remove.
|
|
529
|
+
await cleanupChildSpace(target);
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test('verify-and-rollback rejects Edit-inherited grant with exact D-03 error string',
|
|
534
|
+
async () => {
|
|
535
|
+
const dapiAny = grok.dapi as any;
|
|
536
|
+
const umbrellaName = `Test-Proteomics-Reviews-${Date.now()}`;
|
|
537
|
+
const testUmbrella = await dapiAny.spaces.createRootSpace(umbrellaName);
|
|
538
|
+
|
|
539
|
+
const userGroup = (grok.shell.user as any)?.group as DG.Group;
|
|
540
|
+
|
|
541
|
+
try {
|
|
542
|
+
// Grant EDIT to the user's group on the umbrella (third arg true = edit)
|
|
543
|
+
await grok.dapi.permissions.grant(testUmbrella, userGroup, true);
|
|
544
|
+
|
|
545
|
+
const df = createSyntheticDeFixture();
|
|
546
|
+
grok.shell.addTableView(df);
|
|
547
|
+
await delay(100);
|
|
548
|
+
|
|
549
|
+
const target = `${DEFAULT_TARGET_PREFIX}t5-${Date.now()}`;
|
|
550
|
+
const opts: PublishOptions = {
|
|
551
|
+
target,
|
|
552
|
+
reviewerGroup: userGroup,
|
|
553
|
+
note: 'negative test',
|
|
554
|
+
priorVersion: null,
|
|
555
|
+
umbrellaName,
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
let caughtMessage = '';
|
|
559
|
+
let publishedProject: DG.Project | null = null;
|
|
560
|
+
try {
|
|
561
|
+
publishedProject = await publishAnalysis(df, opts);
|
|
562
|
+
} catch (e: any) {
|
|
563
|
+
caughtMessage = String(e?.message ?? e ?? '');
|
|
564
|
+
}
|
|
565
|
+
expect(caughtMessage.length > 0, true);
|
|
566
|
+
const hitsD03 = caughtMessage.includes('Reviewer group already has elevated access via Space inheritance');
|
|
567
|
+
expect(hitsD03, true);
|
|
568
|
+
|
|
569
|
+
// Verify NO Project was left behind under the target slug
|
|
570
|
+
try {
|
|
571
|
+
const slugSearch = `Proteomics-Review-%`;
|
|
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);
|
|
576
|
+
} catch { /* swallow — best-effort search */ }
|
|
577
|
+
|
|
578
|
+
// If somehow a project was returned (gate failed to throw), clean up so we don't leak.
|
|
579
|
+
if (publishedProject) await cleanupProject(publishedProject);
|
|
580
|
+
} finally {
|
|
581
|
+
await safeDelete(async () => { await dapiAny.spaces.delete(testUmbrella); });
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
});
|