jbrowse-plugin-msaview 3.4.0 → 3.5.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 (171) hide show
  1. package/README.md +7 -0
  2. package/dist/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.js +23 -17
  3. package/dist/LaunchMsaView/components/BlastQuery/BlastManualPanel.js +1 -0
  4. package/dist/LaunchMsaView/components/BlastQuery/MsaAlgorithmSelect.js +5 -3
  5. package/dist/LaunchMsaView/components/BlastQuery/consts.d.ts +24 -4
  6. package/dist/LaunchMsaView/components/BlastQuery/consts.js +44 -2
  7. package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.d.ts +7 -0
  8. package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.js +42 -0
  9. package/dist/LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.js +32 -0
  10. package/dist/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.js +1 -0
  11. package/dist/LaunchMsaView/components/ManualMSALoader/launchView.d.ts +3 -1
  12. package/dist/LaunchMsaView/components/ManualMSALoader/launchView.js +2 -1
  13. package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +10 -4
  14. package/dist/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.js +4 -1
  15. package/dist/LaunchMsaView/components/QueryRowSelector.js +21 -5
  16. package/dist/LaunchMsaView/detectQueryRow.d.ts +11 -0
  17. package/dist/LaunchMsaView/detectQueryRow.js +11 -4
  18. package/dist/LaunchMsaView/detectQueryRow.test.js +30 -0
  19. package/dist/LaunchMsaView/useQueryRowName.d.ts +1 -0
  20. package/dist/LaunchMsaView/useQueryRowName.js +5 -1
  21. package/dist/LaunchMsaView/useQueryRowName.test.d.ts +1 -0
  22. package/dist/LaunchMsaView/useQueryRowName.test.js +23 -0
  23. package/dist/LaunchMsaViewExtensionPoint/index.js +19 -12
  24. package/dist/LaunchMsaViewExtensionPoint/index.test.d.ts +1 -0
  25. package/dist/LaunchMsaViewExtensionPoint/index.test.js +64 -0
  26. package/dist/MsaViewPanel/afterCreateAutoruns.d.ts +16 -0
  27. package/dist/MsaViewPanel/afterCreateAutoruns.js +138 -86
  28. package/dist/MsaViewPanel/components/LaunchProgress.js +23 -5
  29. package/dist/MsaViewPanel/components/MsaViewPanel.js +6 -2
  30. package/dist/MsaViewPanel/components/MsaViewPanel.test.js +40 -0
  31. package/dist/MsaViewPanel/doLaunchBlast.d.ts +11 -1
  32. package/dist/MsaViewPanel/doLaunchBlast.js +79 -96
  33. package/dist/MsaViewPanel/doLaunchBlast.test.d.ts +1 -0
  34. package/dist/MsaViewPanel/doLaunchBlast.test.js +158 -0
  35. package/dist/MsaViewPanel/doLaunchOrthologs.d.ts +3 -1
  36. package/dist/MsaViewPanel/doLaunchOrthologs.js +46 -10
  37. package/dist/MsaViewPanel/doLaunchOrthologs.test.js +48 -24
  38. package/dist/MsaViewPanel/genomeToMSA.js +4 -4
  39. package/dist/MsaViewPanel/genomeToMSA.test.js +67 -0
  40. package/dist/MsaViewPanel/loadProteinDomains.d.ts +8 -2
  41. package/dist/MsaViewPanel/loadProteinDomains.js +18 -9
  42. package/dist/MsaViewPanel/loadProteinDomains.test.d.ts +1 -0
  43. package/dist/MsaViewPanel/loadProteinDomains.test.js +41 -0
  44. package/dist/MsaViewPanel/model.d.ts +306 -85
  45. package/dist/MsaViewPanel/model.js +169 -42
  46. package/dist/MsaViewPanel/model.test.d.ts +1 -0
  47. package/dist/MsaViewPanel/model.test.js +100 -0
  48. package/dist/MsaViewPanel/msaCoordToGenomeCoord.d.ts +12 -1
  49. package/dist/MsaViewPanel/msaCoordToGenomeCoord.js +8 -8
  50. package/dist/MsaViewPanel/msaCoordToGenomeCoord.test.js +133 -32
  51. package/dist/MsaViewPanel/msaDataStore.d.ts +7 -3
  52. package/dist/MsaViewPanel/msaDataStore.js +26 -7
  53. package/dist/MsaViewPanel/msaDataStore.test.d.ts +1 -0
  54. package/dist/MsaViewPanel/msaDataStore.test.js +44 -0
  55. package/dist/MsaViewPanel/observeProteinHighlights.test.js +20 -3
  56. package/dist/MsaViewPanel/processInit.test.d.ts +1 -0
  57. package/dist/MsaViewPanel/processInit.test.js +72 -0
  58. package/dist/MsaViewPanel/resolveConnectedTranscript.d.ts +9 -0
  59. package/dist/MsaViewPanel/resolveConnectedTranscript.js +122 -0
  60. package/dist/MsaViewPanel/runLaunch.d.ts +39 -0
  61. package/dist/MsaViewPanel/runLaunch.js +100 -0
  62. package/dist/MsaViewPanel/runLaunch.test.d.ts +1 -0
  63. package/dist/MsaViewPanel/runLaunch.test.js +175 -0
  64. package/dist/MsaViewPanel/storedData.test.d.ts +1 -0
  65. package/dist/MsaViewPanel/storedData.test.js +160 -0
  66. package/dist/MsaViewPanel/structureConnection.d.ts +0 -4
  67. package/dist/MsaViewPanel/structureConnection.js +0 -19
  68. package/dist/MsaViewPanel/util.d.ts +39 -0
  69. package/dist/MsaViewPanel/util.js +44 -0
  70. package/dist/jbrowse-plugin-msaview.umd.production.min.js +46 -45
  71. package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
  72. package/dist/utils/blastCache.js +32 -0
  73. package/dist/utils/blastCache.test.d.ts +1 -0
  74. package/dist/utils/blastCache.test.js +72 -0
  75. package/dist/utils/browserAlign.d.ts +42 -0
  76. package/dist/utils/browserAlign.js +297 -0
  77. package/dist/utils/browserAlign.test.d.ts +1 -0
  78. package/dist/utils/browserAlign.test.js +85 -0
  79. package/dist/utils/ebiBlast.d.ts +13 -2
  80. package/dist/utils/ebiBlast.js +32 -4
  81. package/dist/utils/ebiJobDispatcher.d.ts +6 -3
  82. package/dist/utils/ebiJobDispatcher.js +13 -6
  83. package/dist/utils/fetch.d.ts +8 -1
  84. package/dist/utils/fetch.js +28 -2
  85. package/dist/utils/homologSearch.d.ts +31 -0
  86. package/dist/utils/homologSearch.js +6 -0
  87. package/dist/utils/msa.d.ts +5 -11
  88. package/dist/utils/msa.js +19 -30
  89. package/dist/utils/msaRows.d.ts +11 -8
  90. package/dist/utils/msaRows.js +14 -12
  91. package/dist/utils/ncbiDomains.d.ts +2 -2
  92. package/dist/utils/ncbiOrthologs.d.ts +11 -1
  93. package/dist/utils/ncbiOrthologs.js +26 -7
  94. package/dist/utils/ncbiOrthologs.test.js +23 -1
  95. package/dist/utils/pantherOrthologs.js +2 -10
  96. package/dist/utils/phmmer.d.ts +12 -1
  97. package/dist/utils/phmmer.js +52 -13
  98. package/dist/utils/poll.d.ts +6 -1
  99. package/dist/utils/poll.js +7 -2
  100. package/dist/utils/unirefHomologs.d.ts +94 -0
  101. package/dist/utils/unirefHomologs.js +193 -0
  102. package/dist/utils/unirefHomologs.test.d.ts +1 -0
  103. package/dist/utils/unirefHomologs.test.js +118 -0
  104. package/dist/version.d.ts +1 -1
  105. package/dist/version.js +1 -1
  106. package/package.json +17 -16
  107. package/src/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.tsx +40 -19
  108. package/src/LaunchMsaView/components/BlastQuery/BlastManualPanel.tsx +1 -0
  109. package/src/LaunchMsaView/components/BlastQuery/MsaAlgorithmSelect.tsx +7 -2
  110. package/src/LaunchMsaView/components/BlastQuery/consts.ts +50 -2
  111. package/src/LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.ts +43 -0
  112. package/src/LaunchMsaView/components/BlastQuery/searchChoiceStorage.ts +64 -0
  113. package/src/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.tsx +1 -0
  114. package/src/LaunchMsaView/components/ManualMSALoader/launchView.ts +4 -0
  115. package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +12 -5
  116. package/src/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.tsx +4 -1
  117. package/src/LaunchMsaView/components/QueryRowSelector.tsx +35 -7
  118. package/src/LaunchMsaView/detectQueryRow.test.ts +34 -0
  119. package/src/LaunchMsaView/detectQueryRow.ts +22 -4
  120. package/src/LaunchMsaView/useQueryRowName.test.ts +27 -0
  121. package/src/LaunchMsaView/useQueryRowName.ts +5 -1
  122. package/src/LaunchMsaViewExtensionPoint/index.test.ts +74 -0
  123. package/src/LaunchMsaViewExtensionPoint/index.ts +53 -12
  124. package/src/MsaViewPanel/afterCreateAutoruns.ts +150 -81
  125. package/src/MsaViewPanel/components/LaunchProgress.tsx +46 -4
  126. package/src/MsaViewPanel/components/MsaViewPanel.test.tsx +43 -0
  127. package/src/MsaViewPanel/components/MsaViewPanel.tsx +8 -2
  128. package/src/MsaViewPanel/doLaunchBlast.test.ts +207 -0
  129. package/src/MsaViewPanel/doLaunchBlast.ts +94 -140
  130. package/src/MsaViewPanel/doLaunchOrthologs.test.ts +54 -26
  131. package/src/MsaViewPanel/doLaunchOrthologs.ts +63 -9
  132. package/src/MsaViewPanel/genomeToMSA.test.ts +76 -0
  133. package/src/MsaViewPanel/genomeToMSA.ts +8 -4
  134. package/src/MsaViewPanel/loadProteinDomains.test.ts +52 -0
  135. package/src/MsaViewPanel/loadProteinDomains.ts +24 -11
  136. package/src/MsaViewPanel/model.test.ts +121 -0
  137. package/src/MsaViewPanel/model.ts +200 -49
  138. package/src/MsaViewPanel/msaCoordToGenomeCoord.test.ts +152 -32
  139. package/src/MsaViewPanel/msaCoordToGenomeCoord.ts +21 -11
  140. package/src/MsaViewPanel/msaDataStore.test.ts +54 -0
  141. package/src/MsaViewPanel/msaDataStore.ts +32 -13
  142. package/src/MsaViewPanel/observeProteinHighlights.test.ts +27 -3
  143. package/src/MsaViewPanel/processInit.test.ts +84 -0
  144. package/src/MsaViewPanel/resolveConnectedTranscript.ts +150 -0
  145. package/src/MsaViewPanel/runLaunch.test.ts +208 -0
  146. package/src/MsaViewPanel/runLaunch.ts +141 -0
  147. package/src/MsaViewPanel/storedData.test.ts +196 -0
  148. package/src/MsaViewPanel/structureConnection.ts +0 -26
  149. package/src/MsaViewPanel/util.ts +75 -0
  150. package/src/utils/blastCache.test.ts +86 -0
  151. package/src/utils/blastCache.ts +34 -1
  152. package/src/utils/browserAlign.test.ts +102 -0
  153. package/src/utils/browserAlign.ts +352 -0
  154. package/src/utils/ebiBlast.ts +42 -2
  155. package/src/utils/ebiJobDispatcher.ts +18 -3
  156. package/src/utils/fetch.ts +29 -2
  157. package/src/utils/homologSearch.ts +51 -0
  158. package/src/utils/msa.ts +25 -39
  159. package/src/utils/msaRows.ts +20 -16
  160. package/src/utils/ncbiDomains.ts +4 -2
  161. package/src/utils/ncbiOrthologs.test.ts +25 -0
  162. package/src/utils/ncbiOrthologs.ts +27 -7
  163. package/src/utils/pantherOrthologs.ts +6 -11
  164. package/src/utils/phmmer.ts +62 -12
  165. package/src/utils/poll.ts +8 -1
  166. package/src/utils/unirefHomologs.test.ts +149 -0
  167. package/src/utils/unirefHomologs.ts +323 -0
  168. package/src/version.ts +1 -1
  169. package/dist/MsaViewPanel/structureConnection.test.js +0 -53
  170. package/src/MsaViewPanel/structureConnection.test.ts +0 -62
  171. /package/dist/{MsaViewPanel/structureConnection.test.d.ts → LaunchMsaView/components/BlastQuery/searchChoiceStorage.test.d.ts} +0 -0
@@ -0,0 +1,122 @@
1
+ import { getConf } from '@jbrowse/core/configuration';
2
+ import { getSession } from '@jbrowse/core/util';
3
+ import { getProteinSequenceFromFeature } from '../LaunchMsaView/components/calculateProteinSequence';
4
+ import { fetchSeq } from '../LaunchMsaView/components/fetchSeq';
5
+ import { getTranscriptFeatures } from '../LaunchMsaView/util';
6
+ // The short form of a connected launch. A spec that says
7
+ //
8
+ // {"type":"MsaView","connectedViewId":"lgv1","connectedTranscript":"NM_000546.6", ...}
9
+ //
10
+ // gets its `connectedFeature` -- the exon model every genome<->column mapping
11
+ // runs through -- looked up here, from the gene tracks the connected genome
12
+ // view has open, and its query row translated from that transcript. Before
13
+ // this the spec had to carry the transcript's CDS list itself, ~1.5kB of
14
+ // coordinates a person cannot type and a tutorial cannot show.
15
+ //
16
+ // jbrowse-plugin-protein3d does the same for its `transcriptId` shorthand,
17
+ // against a `connectedView` spec of its own; here the genome view already
18
+ // exists (the spec pinned its id), so its tracks and regions are read off the
19
+ // live model instead.
20
+ function stripVersion(id) {
21
+ return id.replace(/\.\d+$/, '');
22
+ }
23
+ function transcriptMatches(transcript, id) {
24
+ const target = stripVersion(id);
25
+ return [
26
+ transcript.get('name'),
27
+ transcript.get('id'),
28
+ transcript.get('transcript_id'),
29
+ transcript.id(),
30
+ ].some(c => typeof c === 'string' && (c === id || stripVersion(c) === target));
31
+ }
32
+ async function findTranscript(self, transcriptId) {
33
+ const view = self.connectedView;
34
+ const session = getSession(self);
35
+ const sessionId = 'msaview-connectedTranscript';
36
+ const regions = view.displayedRegions.map(r => ({
37
+ assemblyName: r.assemblyName,
38
+ refName: r.refName,
39
+ start: r.start,
40
+ end: r.end,
41
+ }));
42
+ for (const track of view.tracks) {
43
+ const feats = (await session.rpcManager.call(sessionId, 'CoreGetFeatures', {
44
+ adapterConfig: getConf(track, 'adapter'),
45
+ sessionId,
46
+ regions,
47
+ }));
48
+ for (const feat of feats) {
49
+ const hit = getTranscriptFeatures(feat).find(t => transcriptMatches(t, transcriptId));
50
+ if (hit) {
51
+ return hit;
52
+ }
53
+ }
54
+ }
55
+ return undefined;
56
+ }
57
+ async function translate(self, transcript) {
58
+ const { start, end, refName } = transcript.toJSON();
59
+ const assemblyName = self.connectedView.assemblyNames[0];
60
+ const seq = await fetchSeq({
61
+ start,
62
+ end,
63
+ refName,
64
+ assemblyName,
65
+ session: getSession(self),
66
+ });
67
+ return getProteinSequenceFromFeature({ seq, feature: transcript });
68
+ }
69
+ /**
70
+ * Resolve `connectedTranscript` into `connectedFeature` once the connected
71
+ * genome view has its assembly, regions and tracks, then hand the translation
72
+ * to whichever launch is waiting for a query. Left in place on failure so the
73
+ * error stays attributable; the autorun's tracked reads are the view's
74
+ * readiness, so nothing refires until that changes.
75
+ */
76
+ export function resolveConnectedTranscriptIfNeeded(self) {
77
+ const { connectedTranscript, connectedFeature, connectedView } = self;
78
+ if (!connectedTranscript ||
79
+ connectedFeature ||
80
+ !connectedView?.initialized ||
81
+ connectedView.tracks.length === 0) {
82
+ return;
83
+ }
84
+ const trackCount = connectedView.tracks.length;
85
+ void (async () => {
86
+ try {
87
+ self.setProgress(`Looking up ${connectedTranscript} in the genome view...`);
88
+ const transcript = await findTranscript(self, connectedTranscript);
89
+ if (!transcript) {
90
+ throw new Error(`Transcript "${connectedTranscript}" was not found in the ${trackCount} track(s) open in the connected genome view. Open the gene track that carries it, and make sure the view is on its locus.`);
91
+ }
92
+ const proteinSequence = await translate(self, transcript);
93
+ if (!proteinSequence) {
94
+ throw new Error(`Transcript "${connectedTranscript}" has no CDS to translate`);
95
+ }
96
+ // toJSON, not the Feature: the params are frozen snapshot properties, so
97
+ // an instance in one is gone the moment the session reloads
98
+ const transcriptJson = transcript.toJSON();
99
+ const { blastParams, orthologParams } = self;
100
+ if (blastParams && !blastParams.proteinSequence) {
101
+ self.setBlastParams({
102
+ ...blastParams,
103
+ selectedTranscript: transcriptJson,
104
+ proteinSequence,
105
+ });
106
+ }
107
+ if (orthologParams && !orthologParams.proteinSequence) {
108
+ self.setOrthologParams({
109
+ ...orthologParams,
110
+ selectedTranscript: transcriptJson,
111
+ proteinSequence,
112
+ });
113
+ }
114
+ // last, because the launchers wake on it
115
+ self.setConnectedFeature(transcriptJson);
116
+ }
117
+ catch (e) {
118
+ console.error(e);
119
+ self.setError(e);
120
+ }
121
+ })();
122
+ }
@@ -0,0 +1,39 @@
1
+ import type { JBrowsePluginMsaViewModel } from './model';
2
+ /**
3
+ * What a launch is allowed to do to the model and to the network while it runs.
4
+ *
5
+ * Every write a launch makes goes through `act`, so "nothing touches the model
6
+ * once the launch has been abandoned" is one rule in one place. Spreading that
7
+ * guard across the call sites only takes one omission to undo: an abandoned
8
+ * launch keeps running until its next network hop rejects, and an action on a
9
+ * destroyed mobx-state-tree node throws where nothing is catching.
10
+ */
11
+ export interface LaunchScope {
12
+ /** aborts when the user cancels, and when the view is destroyed */
13
+ signal: AbortSignal;
14
+ act: (fn: () => void) => void;
15
+ onProgress: (arg: string) => void;
16
+ /** the EBI job id, published before the first poll so the view can link out */
17
+ onRid: (arg: string) => void;
18
+ }
19
+ interface LaunchedData {
20
+ msa: string;
21
+ /** empty when the aligner built no tree, and one is then built in the browser */
22
+ tree: string;
23
+ treeMetadata: string;
24
+ }
25
+ /**
26
+ * Run one launch attempt, owning the AbortController that ties it to the view.
27
+ *
28
+ * The controller lives on the model because two things end a launch and neither
29
+ * is here: the Cancel button, and the disposer `afterCreate` registers. Before
30
+ * it existed, closing a view mid-BLAST left the poller checking EBI for the
31
+ * job's lifetime and then writing to a node that was gone.
32
+ */
33
+ export declare function runLaunch({ self, message, launch, onLaunched, }: {
34
+ self: JBrowsePluginMsaViewModel;
35
+ message: string;
36
+ launch: (scope: LaunchScope) => Promise<LaunchedData>;
37
+ onLaunched: () => void;
38
+ }): void;
39
+ export {};
@@ -0,0 +1,100 @@
1
+ import { getSession } from '@jbrowse/core/util';
2
+ import { isAbortError } from '../utils/fetch';
3
+ /**
4
+ * The in-browser aligner and a search hand back rows and no tree;
5
+ * react-msaview's neighbour joining over the finished alignment is what EBI's
6
+ * simple_phylogeny would have computed, without the job.
7
+ *
8
+ * It runs after the alignment is applied, and its refusals are reported rather
9
+ * than thrown: the library caps neighbour joining by row count and throws above
10
+ * it, and inside the success path that throw discarded an alignment that had
11
+ * just cost fifteen minutes of EBI queue behind "Running EBI BLAST failed".
12
+ * A search asking for 1000 hits clears that cap on its own.
13
+ */
14
+ function buildTree(self) {
15
+ if (self.rows.length < 2) {
16
+ return;
17
+ }
18
+ try {
19
+ self.calculateNeighborJoiningTreeFromMSA();
20
+ }
21
+ catch (e) {
22
+ console.error(e);
23
+ // a notice, not the view's error: the alignment is fine and on screen
24
+ try {
25
+ getSession(self).notify(`The alignment loaded, but no tree was built. ${e instanceof Error ? e.message : String(e)}`, 'warning');
26
+ }
27
+ catch (e2) {
28
+ console.error(e2);
29
+ }
30
+ }
31
+ }
32
+ /**
33
+ * Run one launch attempt, owning the AbortController that ties it to the view.
34
+ *
35
+ * The controller lives on the model because two things end a launch and neither
36
+ * is here: the Cancel button, and the disposer `afterCreate` registers. Before
37
+ * it existed, closing a view mid-BLAST left the poller checking EBI for the
38
+ * job's lifetime and then writing to a node that was gone.
39
+ */
40
+ export function runLaunch({ self, message, launch, onLaunched, }) {
41
+ const controller = new AbortController();
42
+ const { signal } = controller;
43
+ self.setLaunchController(controller);
44
+ const act = (fn) => {
45
+ if (!signal.aborted) {
46
+ fn();
47
+ }
48
+ };
49
+ const scope = {
50
+ signal,
51
+ act,
52
+ onProgress: arg => {
53
+ act(() => {
54
+ self.setProgress(arg);
55
+ });
56
+ },
57
+ onRid: arg => {
58
+ act(() => {
59
+ self.setRid(arg);
60
+ });
61
+ },
62
+ };
63
+ void (async () => {
64
+ try {
65
+ act(() => {
66
+ self.setProgress(message);
67
+ self.setError(undefined);
68
+ });
69
+ const data = await launch(scope);
70
+ act(() => {
71
+ self.setData(data);
72
+ onLaunched();
73
+ });
74
+ if (!data.tree) {
75
+ act(() => {
76
+ buildTree(self);
77
+ });
78
+ }
79
+ }
80
+ catch (e) {
81
+ // a cancel is not a failure: drawing the error panel for one would tell
82
+ // the user their launch broke when they are the one who stopped it
83
+ if (!isAbortError(e)) {
84
+ act(() => {
85
+ self.setError(e);
86
+ });
87
+ console.error(e);
88
+ }
89
+ }
90
+ finally {
91
+ act(() => {
92
+ self.setProgress('');
93
+ // only if a later launch has not already claimed the slot
94
+ if (self.launchController === controller) {
95
+ self.setLaunchController(undefined);
96
+ }
97
+ });
98
+ }
99
+ })();
100
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,175 @@
1
+ import { describe, expect, test, vi } from 'vitest';
2
+ import { runLaunch } from './runLaunch';
3
+ /**
4
+ * A model that records every write, so "no model actions after abort" is
5
+ * assertable rather than inferred. The real thing throws on a destroyed node;
6
+ * here a write that should not have happened simply shows up in the log.
7
+ */
8
+ function makeModel() {
9
+ const writes = [];
10
+ const model = {
11
+ launchController: undefined,
12
+ setLaunchController(arg) {
13
+ model.launchController = arg;
14
+ writes.push(`setLaunchController:${arg ? 'set' : 'cleared'}`);
15
+ },
16
+ setProgress: (arg) => writes.push(`setProgress:${arg}`),
17
+ setError: (e) => writes.push(`setError:${e instanceof Error ? e.message : String(e)}`),
18
+ setRid: (arg) => writes.push(`setRid:${arg}`),
19
+ setData: () => writes.push('setData'),
20
+ rows: [],
21
+ calculateNeighborJoiningTreeFromMSA: () => writes.push('neighborJoining'),
22
+ };
23
+ return { model, writes };
24
+ }
25
+ const DATA = { msa: '>a\nMK', tree: '(a);', treeMetadata: '{}' };
26
+ /** run the microtask queue out so the launch's async body has finished */
27
+ const settle = () => new Promise(res => setTimeout(res, 0));
28
+ describe('a launch that finishes', () => {
29
+ test('applies its data, clears its params and releases the controller', async () => {
30
+ const { model, writes } = makeModel();
31
+ const onLaunched = vi.fn();
32
+ runLaunch({
33
+ self: model,
34
+ message: 'Submitting query',
35
+ onLaunched,
36
+ launch: async (scope) => {
37
+ scope.onRid('ncbiblast-1');
38
+ scope.onProgress('Re-checking BLAST status in... 7');
39
+ return DATA;
40
+ },
41
+ });
42
+ await settle();
43
+ expect(writes).toEqual([
44
+ 'setLaunchController:set',
45
+ 'setProgress:Submitting query',
46
+ 'setError:undefined',
47
+ 'setRid:ncbiblast-1',
48
+ 'setProgress:Re-checking BLAST status in... 7',
49
+ 'setData',
50
+ 'setProgress:',
51
+ 'setLaunchController:cleared',
52
+ ]);
53
+ expect(onLaunched).toHaveBeenCalled();
54
+ });
55
+ });
56
+ // The failure this file exists for: closing a view mid-BLAST left the poller
57
+ // running for the job's lifetime and then writing to a destroyed
58
+ // mobx-state-tree node, whose throw surfaced as an unhandled rejection.
59
+ describe('a launch that is abandoned', () => {
60
+ async function abandonMidFlight(reject) {
61
+ const { model, writes } = makeModel();
62
+ let released;
63
+ const held = new Promise(res => {
64
+ released = res;
65
+ });
66
+ let captured;
67
+ runLaunch({
68
+ self: model,
69
+ message: 'Submitting query',
70
+ onLaunched: () => {
71
+ throw new Error('a cancelled launch must not report success');
72
+ },
73
+ launch: async (scope) => {
74
+ captured = scope;
75
+ await held;
76
+ // what the network hop does once its signal is aborted: either it
77
+ // rejects with an AbortError, or it had already resolved and returns
78
+ if (reject) {
79
+ throw new DOMException('Aborted', 'AbortError');
80
+ }
81
+ return DATA;
82
+ },
83
+ });
84
+ await settle();
85
+ const before = writes.length;
86
+ model.launchController.abort();
87
+ // everything the launch would still try to do after the abort
88
+ captured.onProgress('Re-checking BLAST status in... 3');
89
+ captured.onRid('ncbiblast-2');
90
+ captured.act(() => {
91
+ throw new Error('act must not run its callback after an abort');
92
+ });
93
+ released();
94
+ await settle();
95
+ return writes.slice(before);
96
+ }
97
+ test('nothing is written to the model once the abort lands', async () => {
98
+ expect(await abandonMidFlight(true)).toEqual([]);
99
+ });
100
+ test('a launch that resolved anyway still applies nothing', async () => {
101
+ expect(await abandonMidFlight(false)).toEqual([]);
102
+ });
103
+ test('an abort renders no error, so a cancel does not read as a failure', async () => {
104
+ const { model, writes } = makeModel();
105
+ runLaunch({
106
+ self: model,
107
+ message: 'Submitting query',
108
+ onLaunched: () => { },
109
+ launch: () => Promise.reject(new DOMException('Aborted', 'AbortError')),
110
+ });
111
+ await settle();
112
+ expect(writes.some(w => w.startsWith('setError:Aborted'))).toBe(false);
113
+ });
114
+ });
115
+ describe('a launch that fails', () => {
116
+ test('records the error and stops the spinner', async () => {
117
+ vi.spyOn(console, 'error').mockImplementation(() => { });
118
+ const { model, writes } = makeModel();
119
+ runLaunch({
120
+ self: model,
121
+ message: 'Resolving orthologs',
122
+ onLaunched: () => {
123
+ throw new Error('a failed launch must not report success');
124
+ },
125
+ launch: () => Promise.reject(new Error('Only 1 ortholog(s) found')),
126
+ });
127
+ await settle();
128
+ expect(writes).toContain('setError:Only 1 ortholog(s) found');
129
+ expect(writes.at(-2)).toBe('setProgress:');
130
+ });
131
+ });
132
+ // react-msaview caps neighbor joining by row count and throws above it, and a
133
+ // search can ask for 1000 hits. Thrown from inside the success path, that
134
+ // discarded a finished alignment behind "Running EBI BLAST failed".
135
+ describe('a launch whose alignment is too big to build a tree from', () => {
136
+ test('keeps the alignment and reports the refusal', async () => {
137
+ const { model, writes } = makeModel();
138
+ vi.spyOn(console, 'error').mockImplementation(() => { });
139
+ model.rows = [
140
+ ['a', 'MK'],
141
+ ['b', 'MK'],
142
+ ];
143
+ model.calculateNeighborJoiningTreeFromMSA = () => {
144
+ throw new Error('Neighbor joining here is capped at 500 sequences');
145
+ };
146
+ const onLaunched = vi.fn();
147
+ runLaunch({
148
+ self: model,
149
+ message: 'Submitting query',
150
+ onLaunched,
151
+ launch: async () => ({ ...DATA, tree: '' }),
152
+ });
153
+ await settle();
154
+ expect(writes).toContain('setData');
155
+ expect(onLaunched).toHaveBeenCalled();
156
+ expect(writes.filter(w => w.startsWith('setError:'))).toEqual([
157
+ 'setError:undefined',
158
+ ]);
159
+ });
160
+ test('builds the tree when the alignment came back without one', async () => {
161
+ const { model, writes } = makeModel();
162
+ model.rows = [
163
+ ['a', 'MK'],
164
+ ['b', 'MK'],
165
+ ];
166
+ runLaunch({
167
+ self: model,
168
+ message: 'Submitting query',
169
+ onLaunched: vi.fn(),
170
+ launch: async () => ({ ...DATA, tree: '' }),
171
+ });
172
+ await settle();
173
+ expect(writes).toContain('neighborJoining');
174
+ });
175
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,160 @@
1
+ import { beforeEach, describe, expect, test, vi } from 'vitest';
2
+ import { loadStoredData, storeDataToIndexedDB } from './afterCreateAutoruns';
3
+ import { generateDataStoreId, retrieveMsaData, storeMsaData, } from './msaDataStore';
4
+ // IndexedDB itself is msaDataStore.test.ts's subject; what is under test here is
5
+ // which writes these two autoruns decide to make.
6
+ vi.mock('./msaDataStore', () => ({
7
+ cleanupOldData: vi.fn(),
8
+ generateDataStoreId: vi.fn(),
9
+ retrieveMsaData: vi.fn(),
10
+ storeMsaData: vi.fn(),
11
+ }));
12
+ const mockRetrieve = vi.mocked(retrieveMsaData);
13
+ const mockStore = vi.mocked(storeMsaData);
14
+ const mockGenerateId = vi.mocked(generateDataStoreId);
15
+ const MSA = '>a\nMK';
16
+ const NEW_ID = 'msa-generated';
17
+ function makeModel(over = {}) {
18
+ const model = {
19
+ dataStoreId: undefined,
20
+ rows: [],
21
+ data: {},
22
+ msaFilehandle: undefined,
23
+ treeFilehandle: undefined,
24
+ isStoringData: false,
25
+ lastStoredData: undefined,
26
+ loadingStoredData: false,
27
+ error: undefined,
28
+ setDataStoreId(arg) {
29
+ model.dataStoreId = arg;
30
+ },
31
+ setIsStoringData(arg) {
32
+ model.isStoringData = arg;
33
+ },
34
+ setLastStoredData(arg) {
35
+ model.lastStoredData = arg;
36
+ },
37
+ setLoadingStoredData(arg) {
38
+ model.loadingStoredData = arg;
39
+ },
40
+ setError(arg) {
41
+ model.error = arg;
42
+ },
43
+ setMSA(arg) {
44
+ model.data.msa = arg;
45
+ },
46
+ setTree(arg) {
47
+ model.data.tree = arg;
48
+ },
49
+ setTreeMetadata(arg) {
50
+ model.data.treeMetadata = arg;
51
+ },
52
+ ...over,
53
+ };
54
+ return model;
55
+ }
56
+ const settle = () => new Promise(res => setTimeout(res, 0));
57
+ beforeEach(() => {
58
+ vi.clearAllMocks();
59
+ mockGenerateId.mockReturnValue(NEW_ID);
60
+ mockStore.mockResolvedValue(true);
61
+ });
62
+ describe('restoring a view from IndexedDB', () => {
63
+ test('a row that is still there is applied and recorded as stored', async () => {
64
+ mockRetrieve.mockResolvedValue({
65
+ msa: MSA,
66
+ tree: '(a);',
67
+ treeMetadata: '{}',
68
+ });
69
+ const model = makeModel({ dataStoreId: 'msa-1' });
70
+ loadStoredData(model);
71
+ await settle();
72
+ expect(model.data.msa).toBe(MSA);
73
+ expect(model.error).toBeUndefined();
74
+ // recorded, so the store autorun does not write the restored data straight
75
+ // back out again
76
+ expect(model.lastStoredData).toEqual({
77
+ msa: MSA,
78
+ tree: '(a);',
79
+ treeMetadata: '{}',
80
+ });
81
+ model.rows = [['a', 'MK']];
82
+ storeDataToIndexedDB(model);
83
+ expect(mockStore).not.toHaveBeenCalled();
84
+ });
85
+ // the row expires or the user clears site data, and the view used to reopen as
86
+ // a blank import form with no hint that anything had been lost
87
+ test('a row that is gone says so, in words that name the policy', async () => {
88
+ mockRetrieve.mockResolvedValue(undefined);
89
+ const model = makeModel({ dataStoreId: 'msa-1' });
90
+ loadStoredData(model);
91
+ await settle();
92
+ expect(model.error).toBeInstanceOf(Error);
93
+ expect(model.error.message).toMatch(/7 days/);
94
+ // the id names nothing now, so it goes -- otherwise react-msaview's "Return
95
+ // to import form" lands straight back on this error
96
+ expect(model.dataStoreId).toBeUndefined();
97
+ });
98
+ });
99
+ describe('keeping IndexedDB up to date', () => {
100
+ test('the first alignment is written under a fresh id', async () => {
101
+ const model = makeModel({ rows: [['a', 'MK']], data: { msa: MSA } });
102
+ storeDataToIndexedDB(model);
103
+ await settle();
104
+ expect(mockStore).toHaveBeenCalledWith(NEW_ID, { msa: MSA });
105
+ expect(model.dataStoreId).toBe(NEW_ID);
106
+ expect(model.isStoringData).toBe(false);
107
+ });
108
+ // react-msaview's "calculate neighbor-joining tree" replaces data.tree long
109
+ // after the first write; the row used to keep the alignment's original tree
110
+ // forever, so a reopened session restored a picture the user had replaced
111
+ test('an edit after the first write updates the existing row', async () => {
112
+ const model = makeModel({
113
+ dataStoreId: 'msa-1',
114
+ rows: [['a', 'MK']],
115
+ data: { msa: MSA, tree: '(a);' },
116
+ lastStoredData: { msa: MSA, tree: '(a);' },
117
+ });
118
+ storeDataToIndexedDB(model);
119
+ expect(mockStore).not.toHaveBeenCalled();
120
+ model.data.tree = '(a:0.1);';
121
+ storeDataToIndexedDB(model);
122
+ await settle();
123
+ expect(mockStore).toHaveBeenCalledWith('msa-1', {
124
+ msa: MSA,
125
+ tree: '(a:0.1);',
126
+ treeMetadata: undefined,
127
+ });
128
+ expect(model.dataStoreId).toBe('msa-1');
129
+ });
130
+ // a browser that refuses IndexedDB (private mode) answers every write the same
131
+ // way, and the autorun reruns on its own isStoringData flag -- so a write that
132
+ // is not recorded as attempted is a write that retries forever
133
+ test('a write that fails is not retried on the same data', async () => {
134
+ vi.spyOn(console, 'error').mockImplementation(() => { });
135
+ mockStore.mockResolvedValue(false);
136
+ const model = makeModel({ rows: [['a', 'MK']], data: { msa: MSA } });
137
+ storeDataToIndexedDB(model);
138
+ await settle();
139
+ expect(model.dataStoreId).toBeUndefined();
140
+ storeDataToIndexedDB(model);
141
+ await settle();
142
+ expect(mockStore).toHaveBeenCalledTimes(1);
143
+ });
144
+ test('a view reading from a filehandle stores nothing, since the file is the source', () => {
145
+ storeDataToIndexedDB(makeModel({
146
+ rows: [['a', 'MK']],
147
+ data: { msa: MSA },
148
+ msaFilehandle: { uri: 'msa.fa', locationType: 'UriLocation' },
149
+ }));
150
+ expect(mockStore).not.toHaveBeenCalled();
151
+ });
152
+ test('a write already in flight is not duplicated', () => {
153
+ storeDataToIndexedDB(makeModel({
154
+ rows: [['a', 'MK']],
155
+ data: { msa: MSA },
156
+ isStoringData: true,
157
+ }));
158
+ expect(mockStore).not.toHaveBeenCalled();
159
+ });
160
+ });
@@ -27,7 +27,3 @@ export declare function isProteinView(view: unknown): view is ProteinView;
27
27
  export declare function getProteinViews(views: {
28
28
  type: string;
29
29
  }[]): ProteinView[];
30
- /**
31
- * Helper to convert gapped MSA column to ungapped position for a specific row
32
- */
33
- export declare function gappedToUngappedPosition(sequence: string, gappedPosition: number): number | undefined;
@@ -8,22 +8,3 @@ export function isProteinView(view) {
8
8
  export function getProteinViews(views) {
9
9
  return views.filter(isProteinView);
10
10
  }
11
- /**
12
- * Helper to convert gapped MSA column to ungapped position for a specific row
13
- */
14
- export function gappedToUngappedPosition(sequence, gappedPosition) {
15
- if (gappedPosition < 0 || gappedPosition >= sequence.length) {
16
- return undefined;
17
- }
18
- let ungapped = 0;
19
- for (let i = 0; i < gappedPosition; i++) {
20
- if (sequence[i] !== '-') {
21
- ungapped++;
22
- }
23
- }
24
- // If the position itself is a gap, return undefined
25
- if (sequence[gappedPosition] === '-') {
26
- return undefined;
27
- }
28
- return ungapped;
29
- }
@@ -16,6 +16,44 @@ export declare function hasQueryRow(model: {
16
16
  rows: string[][];
17
17
  querySeqName: string;
18
18
  }): boolean;
19
+ /**
20
+ * The transcript a launch was started from, as it is stored: plain JSON.
21
+ *
22
+ * blastParams and orthologParams are frozen snapshot properties, so a Feature
23
+ * instance put in one lives only as long as the tab. A reloaded session hands
24
+ * back the JSON the instance serialized to, and `.get()` on that threw --
25
+ * minutes after the EBI job the reload resubmitted had finally come back.
26
+ */
27
+ export type TranscriptRef = Record<string, unknown> | LiveFeature;
28
+ interface LiveFeature {
29
+ toJSON: () => Record<string, unknown>;
30
+ }
31
+ /**
32
+ * Read the transcript's fields whichever shape it arrived in: a caller inside
33
+ * the session may still hand over a live Feature.
34
+ */
35
+ export declare function transcriptFields(transcript?: TranscriptRef): Record<string, unknown>;
36
+ /** what a transcript is called, preferring its name over its id */
37
+ export declare function transcriptName(transcript?: TranscriptRef): string | undefined;
38
+ export interface QueryRowModel {
39
+ querySeqName: string;
40
+ querySeqOffset: number;
41
+ seqPosToVisibleCol: (rowName: string, seqPos: number) => number | undefined;
42
+ visibleColToSeqPos: (rowName: string, visibleCol: number) => number | undefined;
43
+ }
44
+ /**
45
+ * The visible column showing residue `proteinPos` (0-based) of the transcript,
46
+ * or undefined when the query row does not carry that residue or react-msaview
47
+ * is hiding its column.
48
+ *
49
+ * Two things separate the transcript from the row. `querySeqOffset` is the
50
+ * trimming — a pasted BLAST alignment carries the aligned region, not the whole
51
+ * protein. And the row can simply stop short: react-msaview answers one column
52
+ * past the end for a position it does not have, which would light the last
53
+ * column for every residue beyond the row, so the round trip back through
54
+ * visibleColToSeqPos is what rejects those.
55
+ */
56
+ export declare function transcriptPosToVisibleCol(model: QueryRowModel, proteinPos: number): number | undefined;
19
57
  export declare function hasHoverPosition(hovered: unknown): hovered is {
20
58
  hoverPosition: {
21
59
  coord: number;
@@ -29,3 +67,4 @@ export declare function hasHoverPosition(hovered: unknown): hovered is {
29
67
  * - https://alphafold.ebi.ac.uk/files/msa/AF-P12345-F1-msa_v6.a3m -> P12345
30
68
  */
31
69
  export declare function getUniprotIdFromAlphaFoldUrl(url: string): string | undefined;
70
+ export {};