react-msaview 6.4.2 → 7.0.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.
- package/README.md +6 -5
- package/bundle/index.js +100 -101
- package/bundle/index.js.map +4 -4
- package/dist/components/Loading.js +2 -2
- package/dist/components/Loading.js.map +1 -1
- package/dist/components/MSAViewer.d.ts +10 -2
- package/dist/components/MSAViewer.js +90 -2
- package/dist/components/MSAViewer.js.map +1 -1
- package/dist/components/ResizeHandles.d.ts +3 -3
- package/dist/components/ResizeHandles.js +28 -21
- package/dist/components/ResizeHandles.js.map +1 -1
- package/dist/components/dialogs/{InterProScanDialog.d.ts → AnnotationFileDialog.d.ts} +2 -2
- package/dist/components/dialogs/AnnotationFileDialog.js +92 -0
- package/dist/components/dialogs/AnnotationFileDialog.js.map +1 -0
- package/dist/components/dialogs/FeatureDialog.js +13 -9
- package/dist/components/dialogs/FeatureDialog.js.map +1 -1
- package/dist/components/header/Header.js +4 -0
- package/dist/components/header/Header.js.map +1 -1
- package/dist/components/header/HeaderStatusArea.js +0 -2
- package/dist/components/header/HeaderStatusArea.js.map +1 -1
- package/dist/components/header/LoadWarnings.d.ts +6 -0
- package/dist/components/header/LoadWarnings.js +36 -0
- package/dist/components/header/LoadWarnings.js.map +1 -0
- package/dist/components/header/MultiAlignmentSelector.js +0 -0
- package/dist/components/header/MultiAlignmentSelector.js.map +1 -1
- package/dist/components/header/TreeSettingsMenu.js +9 -1
- package/dist/components/header/TreeSettingsMenu.js.map +1 -1
- package/dist/components/header/UnshareableDataWarning.d.ts +6 -0
- package/dist/components/header/UnshareableDataWarning.js +42 -0
- package/dist/components/header/UnshareableDataWarning.js.map +1 -0
- package/dist/components/header/getAnnotationsMenu.js +14 -14
- package/dist/components/header/getAnnotationsMenu.js.map +1 -1
- package/dist/components/import/ImportForm.js +1 -1
- package/dist/components/import/ImportForm.js.map +1 -1
- package/dist/components/import/ImportFormExamples.js +2 -6
- package/dist/components/import/ImportFormExamples.js.map +1 -1
- package/dist/components/import/util.js +3 -0
- package/dist/components/import/util.js.map +1 -1
- package/dist/components/msa/MSACanvasBlock.js +4 -5
- package/dist/components/msa/MSACanvasBlock.js.map +1 -1
- package/dist/components/msa/msaRaster.d.ts +13 -3
- package/dist/components/msa/msaRaster.js +173 -61
- package/dist/components/msa/msaRaster.js.map +1 -1
- package/dist/components/msa/renderHighlights.d.ts +39 -1
- package/dist/components/msa/renderHighlights.js +71 -6
- package/dist/components/msa/renderHighlights.js.map +1 -1
- package/dist/components/msa/renderMSABlock.d.ts +1 -2
- package/dist/components/msa/renderMSABlock.js +24 -28
- package/dist/components/msa/renderMSABlock.js.map +1 -1
- package/dist/components/msa/renderMSAMouseover.js +9 -30
- package/dist/components/msa/renderMSAMouseover.js.map +1 -1
- package/dist/components/renderCtx.d.ts +1 -1
- package/dist/components/tracks/TrackBlocks.js +10 -11
- package/dist/components/tracks/TrackBlocks.js.map +1 -1
- package/dist/components/tracks/drawTracks.d.ts +26 -8
- package/dist/components/tracks/drawTracks.js +123 -13
- package/dist/components/tracks/drawTracks.js.map +1 -1
- package/dist/components/tree/TreeCanvas.js +2 -3
- package/dist/components/tree/TreeCanvas.js.map +1 -1
- package/dist/components/tree/TreeCanvasBlock.js +4 -2
- package/dist/components/tree/TreeCanvasBlock.js.map +1 -1
- package/dist/components/tree/TreeRuler.js +13 -3
- package/dist/components/tree/TreeRuler.js.map +1 -1
- package/dist/components/tree/renderTreeCanvas.d.ts +0 -1
- package/dist/components/tree/renderTreeCanvas.js +70 -30
- package/dist/components/tree/renderTreeCanvas.js.map +1 -1
- package/dist/components/tree/scaleBar.d.ts +10 -0
- package/dist/components/tree/scaleBar.js +24 -0
- package/dist/components/tree/scaleBar.js.map +1 -0
- package/dist/constants.d.ts +3 -1
- package/dist/constants.js +18 -1
- package/dist/constants.js.map +1 -1
- package/dist/fetchUtils.d.ts +7 -7
- package/dist/fetchUtils.js +4 -32
- package/dist/fetchUtils.js.map +1 -1
- package/dist/hierarchy.d.ts +2 -1
- package/dist/hierarchy.js +16 -10
- package/dist/hierarchy.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/model/DataModel.js +5 -2
- package/dist/model/DataModel.js.map +1 -1
- package/dist/model.d.ts +454 -177
- package/dist/model.js +1180 -511
- package/dist/model.js.map +1 -1
- package/dist/renderToStaticMarkup.d.ts +11 -0
- package/dist/renderToStaticMarkup.js +30 -7
- package/dist/renderToStaticMarkup.js.map +1 -1
- package/dist/renderToSvg.js +25 -19
- package/dist/renderToSvg.js.map +1 -1
- package/dist/rowCoordinateCalculations.d.ts +25 -25
- package/dist/rowCoordinateCalculations.js +12 -36
- package/dist/rowCoordinateCalculations.js.map +1 -1
- package/dist/seqPosToGlobalCol.js +3 -1
- package/dist/seqPosToGlobalCol.js.map +1 -1
- package/dist/svgTestUtil.d.ts +144 -82
- package/dist/types.d.ts +97 -3
- package/dist/util.d.ts +11 -4
- package/dist/util.js +30 -10
- package/dist/util.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/wuss.d.ts +14 -0
- package/dist/wuss.js +55 -0
- package/dist/wuss.js.map +1 -0
- package/package.json +10 -8
- package/src/components/Loading.tsx +12 -2
- package/src/components/MSAViewer.tsx +106 -2
- package/src/components/ResizeHandles.tsx +31 -31
- package/src/components/dialogs/AnnotationFileDialog.tsx +162 -0
- package/src/components/dialogs/FeatureDialog.tsx +13 -9
- package/src/components/header/Header.tsx +4 -0
- package/src/components/header/HeaderStatusArea.tsx +0 -5
- package/src/components/header/LoadWarnings.tsx +49 -0
- package/src/components/header/MultiAlignmentSelector.tsx +0 -0
- package/src/components/header/TreeSettingsMenu.tsx +9 -1
- package/src/components/header/UnshareableDataWarning.tsx +58 -0
- package/src/components/header/getAnnotationsMenu.ts +16 -17
- package/src/components/import/ImportForm.tsx +2 -2
- package/src/components/import/ImportFormExamples.tsx +2 -6
- package/src/components/import/util.ts +3 -0
- package/src/components/msa/MSACanvasBlock.tsx +4 -5
- package/src/components/msa/msaRaster.ts +216 -59
- package/src/components/msa/renderHighlights.ts +103 -5
- package/src/components/msa/renderMSABlock.ts +25 -36
- package/src/components/msa/renderMSAMouseover.ts +9 -39
- package/src/components/renderCtx.ts +1 -0
- package/src/components/tracks/TrackBlocks.tsx +10 -13
- package/src/components/tracks/drawTracks.ts +165 -17
- package/src/components/tree/TreeCanvas.tsx +2 -3
- package/src/components/tree/TreeCanvasBlock.tsx +4 -2
- package/src/components/tree/TreeRuler.tsx +38 -3
- package/src/components/tree/renderTreeCanvas.ts +79 -35
- package/src/components/tree/scaleBar.ts +23 -0
- package/src/constants.ts +20 -1
- package/src/fetchUtils.ts +7 -40
- package/src/hierarchy.ts +20 -11
- package/src/index.ts +18 -2
- package/src/model/DataModel.ts +6 -2
- package/src/model.ts +1352 -616
- package/src/rawImports.d.ts +9 -0
- package/src/renderToStaticMarkup.ts +34 -7
- package/src/renderToSvg.tsx +33 -27
- package/src/rowCoordinateCalculations.ts +15 -59
- package/src/seqPosToGlobalCol.ts +3 -1
- package/src/types.ts +131 -4
- package/src/util.ts +31 -14
- package/src/version.ts +1 -1
- package/src/wuss.ts +62 -0
- package/dist/components/dialogs/InterProScanDialog.js +0 -137
- package/dist/components/dialogs/InterProScanDialog.js.map +0 -1
- package/dist/components/dialogs/InterProScanFileDialog.d.ts +0 -7
- package/dist/components/dialogs/InterProScanFileDialog.js +0 -64
- package/dist/components/dialogs/InterProScanFileDialog.js.map +0 -1
- package/dist/launchInterProScan.d.ts +0 -13
- package/dist/launchInterProScan.js +0 -110
- package/dist/launchInterProScan.js.map +0 -1
- package/dist/useColorContrast.d.ts +0 -10
- package/dist/useColorContrast.js +0 -13
- package/dist/useColorContrast.js.map +0 -1
- package/src/clustalX.test.ts +0 -74
- package/src/colSpace.test.ts +0 -67
- package/src/collapseLogic.test.ts +0 -115
- package/src/colorSchemes.docs.test.ts +0 -57
- package/src/columnCounts.test.ts +0 -73
- package/src/columnStats.test.ts +0 -94
- package/src/columnTracks.test.ts +0 -97
- package/src/components/dialogs/InterProScanDialog.tsx +0 -214
- package/src/components/dialogs/InterProScanFileDialog.tsx +0 -138
- package/src/components/minimap/minimapLayout.test.ts +0 -71
- package/src/components/msa/msaRaster.test.ts +0 -145
- package/src/components/msa/renderBoxFeatureCanvasBlock.test.ts +0 -51
- package/src/components/tree/renderTreeCanvas.test.ts +0 -296
- package/src/dataInitialized.test.ts +0 -42
- package/src/domainBands.test.ts +0 -122
- package/src/domainLetterColor.test.ts +0 -80
- package/src/exportFileName.test.ts +0 -40
- package/src/fetchUtils.test.ts +0 -83
- package/src/flatToTree.test.ts +0 -51
- package/src/hierarchy.test.ts +0 -228
- package/src/highlights.test.ts +0 -70
- package/src/impgRender.test.tsx +0 -68
- package/src/largeTree.test.ts +0 -67
- package/src/launchInterProScan.ts +0 -167
- package/src/loadErrorDisplay.test.tsx +0 -102
- package/src/modelFilehandleLoaders.test.ts +0 -137
- package/src/modelReset.test.ts +0 -120
- package/src/neighborJoining.test.ts +0 -164
- package/src/parseAsn1.test.ts +0 -36
- package/src/propertyConservation.test.ts +0 -36
- package/src/renderColumnTracksSvg.test.tsx +0 -108
- package/src/renderDomainsSvg.test.tsx +0 -118
- package/src/renderHighlightsSvg.test.tsx +0 -85
- package/src/renderMinimapSvg.test.tsx +0 -62
- package/src/renderRasterSvg.test.tsx +0 -144
- package/src/renderSequenceLogoSvg.test.tsx +0 -165
- package/src/renderToSvg.test.tsx +0 -65
- package/src/reset.test.ts +0 -61
- package/src/rowCoordinateCalculations.test.ts +0 -211
- package/src/rowSpace.test.ts +0 -46
- package/src/segmentOverlay.test.ts +0 -70
- package/src/seqPosToGlobalCol.test.ts +0 -59
- package/src/sequenceLogo.test.ts +0 -88
- package/src/setFontSize.test.ts +0 -33
- package/src/stripDefault.test.ts +0 -48
- package/src/stripDefaultSnapshot.test.ts +0 -87
- package/src/treeMetadata.test.ts +0 -39
- package/src/useCanvasAutorun.test.tsx +0 -98
- package/src/useColorContrast.ts +0 -18
- package/src/viewChrome.test.tsx +0 -134
- package/src/wheelZoom.test.ts +0 -46
package/src/impgRender.test.tsx
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
//
|
|
3
|
-
// End-to-end check of the impg (https://github.com/pangenome/impg) integration:
|
|
4
|
-
// impg's scripts/faln2html.py drives the viewer exactly as below --
|
|
5
|
-
// MSAModelF().create({ type:'MsaView', data:{ msa } })
|
|
6
|
-
// model.setColorSchemeName('percent_identity_dynamic')
|
|
7
|
-
// model.setBgColor(true); model.setWidth(1300)
|
|
8
|
-
// then renders <MSAView model/>. This exercises that path headlessly via the
|
|
9
|
-
// public renderToSvg export. Polyfills mirror scripts/generateFigures.tsx.
|
|
10
|
-
import { createJBrowseTheme } from '@jbrowse/core/ui/theme'
|
|
11
|
-
import { beforeAll, expect, test } from 'vitest'
|
|
12
|
-
|
|
13
|
-
import MSAModelF from './model.ts'
|
|
14
|
-
import { renderToSvg } from './renderToSvg.tsx'
|
|
15
|
-
import { installSvgTestEnv } from './svgTestUtil.ts'
|
|
16
|
-
|
|
17
|
-
beforeAll(() => {
|
|
18
|
-
installSvgTestEnv()
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
// representative `impg query -o fasta-aln` block: PanSN names with region
|
|
22
|
-
// colons, dash gaps, mixed-case (soft-masked) bases
|
|
23
|
-
const impgFastaAln = `>HG002#1#chr1:1000-1040
|
|
24
|
-
ACGTACGT--ACGTNNNNacgtACGTACGTACGTACGT
|
|
25
|
-
>HG003#1#chr1:1000-1040
|
|
26
|
-
ACGTAC-TGGACGTNNNNACGTAC--ACGTacgtACGT
|
|
27
|
-
>CHM13#0#chr1:1000-1040
|
|
28
|
-
ACGTACGTGGACGTNNNNACGT----ACGTACGTACGT`
|
|
29
|
-
|
|
30
|
-
test('renders an impg fasta-aln block with declarative impg config', async () => {
|
|
31
|
-
// everything except width is a declarative MST property, so it can be set in
|
|
32
|
-
// the create() snapshot -- no setColorSchemeName/setBgColor/setMSAFormat
|
|
33
|
-
// action calls needed. msaFormat:'fasta' forces the parser and skips the
|
|
34
|
-
// heuristic fasta-vs-a3m sniffing. width is volatile (tracks container size)
|
|
35
|
-
// so it remains an action.
|
|
36
|
-
const model = MSAModelF().create({
|
|
37
|
-
id: 'impg-test',
|
|
38
|
-
type: 'MsaView',
|
|
39
|
-
height: 400,
|
|
40
|
-
colorSchemeName: 'percent_identity_dynamic',
|
|
41
|
-
bgColor: true,
|
|
42
|
-
msaFormat: 'fasta',
|
|
43
|
-
data: { msa: impgFastaAln },
|
|
44
|
-
})
|
|
45
|
-
model.setWidth(1300)
|
|
46
|
-
|
|
47
|
-
// the declarative config took effect
|
|
48
|
-
expect(model.colorSchemeName).toBe('percent_identity_dynamic')
|
|
49
|
-
expect(model.bgColor).toBe(true)
|
|
50
|
-
expect(model.msaFormat).toBe('fasta')
|
|
51
|
-
|
|
52
|
-
// the alignment parsed and all 3 rows are present at equal width
|
|
53
|
-
expect(model.rowNames).toEqual([
|
|
54
|
-
'HG002#1#chr1:1000-1040',
|
|
55
|
-
'HG003#1#chr1:1000-1040',
|
|
56
|
-
'CHM13#0#chr1:1000-1040',
|
|
57
|
-
])
|
|
58
|
-
expect(model.numColumns).toBe(38)
|
|
59
|
-
|
|
60
|
-
const svg = await renderToSvg(model, {
|
|
61
|
-
theme: createJBrowseTheme(),
|
|
62
|
-
exportType: 'entire',
|
|
63
|
-
includeMinimap: false,
|
|
64
|
-
includeTracks: false,
|
|
65
|
-
})
|
|
66
|
-
expect(svg).toContain('<svg')
|
|
67
|
-
expect(svg).toContain('HG002#1#chr1:1000-1040')
|
|
68
|
-
})
|
package/src/largeTree.test.ts
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
import { expect, test } from 'vitest'
|
|
3
|
-
|
|
4
|
-
import MSAModelF from './model.ts'
|
|
5
|
-
|
|
6
|
-
// jsdom has no 2d context, so measureTextCanvas falls back to an estimate. Only
|
|
7
|
-
// the width matters here, so a stub keeps the test about tree size.
|
|
8
|
-
HTMLCanvasElement.prototype.getContext = (() => ({
|
|
9
|
-
font: '12px sans-serif',
|
|
10
|
-
measureText: (t: string) => ({ width: t.length * 7 }),
|
|
11
|
-
})) as unknown as typeof HTMLCanvasElement.prototype.getContext
|
|
12
|
-
|
|
13
|
-
// The import form ships a "230k COVID-19 samples (tree only)" example, so a tree
|
|
14
|
-
// this size is a supported input rather than a hypothetical. Every leaf gets a
|
|
15
|
-
// measured label, and anything that then reduces over them per row has to do it
|
|
16
|
-
// without passing one argument per row.
|
|
17
|
-
function flatNewick(n: number) {
|
|
18
|
-
return `(${Array.from({ length: n }, (_, i) => `s${i}:0.1`).join(',')});`
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
test('a tree far past the argument limit still lays out', () => {
|
|
22
|
-
const model = MSAModelF().create({
|
|
23
|
-
type: 'MsaView',
|
|
24
|
-
data: { tree: flatNewick(200_000) },
|
|
25
|
-
})
|
|
26
|
-
model.setWidth(1000)
|
|
27
|
-
|
|
28
|
-
expect(model.numRows).toBe(200_000)
|
|
29
|
-
// 's199999' is the longest name, at 7 chars * 7px, scaled from the reference
|
|
30
|
-
// font size the labels are measured at down to the current one (13/16)
|
|
31
|
-
expect(model.labelsWidth).toBeCloseTo((49 * 13) / 16)
|
|
32
|
-
expect(model.treeWidth).toBeGreaterThan(0)
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
test('re-hovering the same tree node does not re-walk the tree', () => {
|
|
36
|
-
const model = MSAModelF().create({
|
|
37
|
-
type: 'MsaView',
|
|
38
|
-
data: { tree: flatNewick(200_000) },
|
|
39
|
-
})
|
|
40
|
-
model.setWidth(1000)
|
|
41
|
-
|
|
42
|
-
const leafId = model.leaves[1000]!.data.id
|
|
43
|
-
model.setHoveredTreeNode(leafId)
|
|
44
|
-
const first = model.hoveredTreeNode
|
|
45
|
-
|
|
46
|
-
// the tree's mousemove handler fires this on every event, so an unchanged id
|
|
47
|
-
// has to leave the state object alone: rebuilding it walks all 200k nodes and
|
|
48
|
-
// invalidates every overlay that reads hoveredRowIndices
|
|
49
|
-
model.setHoveredTreeNode(leafId)
|
|
50
|
-
expect(model.hoveredTreeNode).toBe(first)
|
|
51
|
-
|
|
52
|
-
model.setHoveredTreeNode(undefined)
|
|
53
|
-
expect(model.hoveredTreeNode).toBeUndefined()
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
test('a vertical zoom does not re-measure every label', () => {
|
|
57
|
-
const model = MSAModelF().create({
|
|
58
|
-
type: 'MsaView',
|
|
59
|
-
data: { tree: flatNewick(200_000) },
|
|
60
|
-
})
|
|
61
|
-
model.setWidth(1000)
|
|
62
|
-
|
|
63
|
-
const before = model.labelWidthMap
|
|
64
|
-
model.setRowHeight(24)
|
|
65
|
-
expect(model.labelWidthMap).toBe(before)
|
|
66
|
-
expect(model.labelsWidth).toBeCloseTo((49 * 18) / 16)
|
|
67
|
-
})
|
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
import { getSession } from '@jbrowse/core/util'
|
|
2
|
-
import { interProScanResponseToAnnotations } from 'msa-parsers'
|
|
3
|
-
|
|
4
|
-
import { isAbortError, jsonfetch, textfetch, timeout } from './fetchUtils.ts'
|
|
5
|
-
|
|
6
|
-
import type { MsaViewModel } from './model.ts'
|
|
7
|
-
import type { InterProScanResponse } from 'msa-parsers'
|
|
8
|
-
|
|
9
|
-
const base = 'https://www.ebi.ac.uk/Tools/services/rest'
|
|
10
|
-
|
|
11
|
-
async function runInterProScan({
|
|
12
|
-
seq,
|
|
13
|
-
onProgress,
|
|
14
|
-
onJobId,
|
|
15
|
-
programs,
|
|
16
|
-
model,
|
|
17
|
-
signal,
|
|
18
|
-
}: {
|
|
19
|
-
seq: string
|
|
20
|
-
programs: string[]
|
|
21
|
-
onProgress: (arg?: { msg: string; url?: string }) => void
|
|
22
|
-
onJobId?: (arg: string) => void
|
|
23
|
-
model: MsaViewModel
|
|
24
|
-
signal?: AbortSignal
|
|
25
|
-
}) {
|
|
26
|
-
const jobId = await textfetch(`${base}/iprscan5/run`, {
|
|
27
|
-
method: 'POST',
|
|
28
|
-
body: new URLSearchParams({
|
|
29
|
-
email: 'colin.diesh@gmail.com',
|
|
30
|
-
sequence: seq,
|
|
31
|
-
programs: programs.join(','),
|
|
32
|
-
}),
|
|
33
|
-
signal,
|
|
34
|
-
})
|
|
35
|
-
onJobId?.(jobId)
|
|
36
|
-
await wait({
|
|
37
|
-
jobId,
|
|
38
|
-
onProgress,
|
|
39
|
-
signal,
|
|
40
|
-
})
|
|
41
|
-
await loadInterProScanResultsWithStatus({ jobId, model, onProgress, signal })
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function loadInterProScanResults(jobId: string, signal?: AbortSignal) {
|
|
45
|
-
return jsonfetch<InterProScanResponse>(
|
|
46
|
-
`${base}/iprscan5/result/${jobId}/json`,
|
|
47
|
-
{ signal },
|
|
48
|
-
)
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
async function wait({
|
|
52
|
-
onProgress,
|
|
53
|
-
jobId,
|
|
54
|
-
signal,
|
|
55
|
-
}: {
|
|
56
|
-
jobId: string
|
|
57
|
-
onProgress: (arg?: { msg: string; url?: string }) => void
|
|
58
|
-
signal?: AbortSignal
|
|
59
|
-
}) {
|
|
60
|
-
const url = `${base}/iprscan5/status/${jobId}`
|
|
61
|
-
// EBI jobs run for minutes; cap polling so an unexpected/stuck status can't
|
|
62
|
-
// spin forever (the abort signal is the normal early exit). ~10s per check.
|
|
63
|
-
const maxChecks = 360
|
|
64
|
-
try {
|
|
65
|
-
let finished = false
|
|
66
|
-
for (let check = 0; check < maxChecks && !finished; check++) {
|
|
67
|
-
for (let i = 0; i < 10; i++) {
|
|
68
|
-
await timeout(1000, signal)
|
|
69
|
-
onProgress({ msg: `Checking status ${10 - i}`, url })
|
|
70
|
-
}
|
|
71
|
-
// status endpoint returns a bare status token; match exactly rather than
|
|
72
|
-
// substring so an error page mentioning "FINISHED" can't be a false hit
|
|
73
|
-
const status = (await textfetch(url, { signal })).trim()
|
|
74
|
-
if (status === 'FINISHED') {
|
|
75
|
-
finished = true
|
|
76
|
-
} else if (
|
|
77
|
-
status === 'FAILURE' ||
|
|
78
|
-
status === 'ERROR' ||
|
|
79
|
-
status === 'NOT_FOUND'
|
|
80
|
-
) {
|
|
81
|
-
throw new Error(
|
|
82
|
-
`InterProScan job ${jobId} ended with status: ${status}`,
|
|
83
|
-
)
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
if (!finished) {
|
|
87
|
-
throw new Error(
|
|
88
|
-
`InterProScan job ${jobId} did not finish after ${maxChecks} status checks`,
|
|
89
|
-
)
|
|
90
|
-
}
|
|
91
|
-
} finally {
|
|
92
|
-
onProgress()
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export async function launchInterProScan({
|
|
97
|
-
algorithm,
|
|
98
|
-
seq,
|
|
99
|
-
programs,
|
|
100
|
-
onJobId,
|
|
101
|
-
onProgress,
|
|
102
|
-
model,
|
|
103
|
-
signal,
|
|
104
|
-
}: {
|
|
105
|
-
algorithm: string
|
|
106
|
-
seq: string
|
|
107
|
-
programs: string[]
|
|
108
|
-
onProgress: (arg?: { msg: string; url?: string }) => void
|
|
109
|
-
onJobId?: (arg: string) => void
|
|
110
|
-
model: MsaViewModel
|
|
111
|
-
signal?: AbortSignal
|
|
112
|
-
}) {
|
|
113
|
-
try {
|
|
114
|
-
onProgress({ msg: `Launching ${algorithm} MSA` })
|
|
115
|
-
if (algorithm === 'interproscan') {
|
|
116
|
-
await runInterProScan({
|
|
117
|
-
seq,
|
|
118
|
-
onJobId,
|
|
119
|
-
onProgress,
|
|
120
|
-
programs,
|
|
121
|
-
model,
|
|
122
|
-
signal,
|
|
123
|
-
})
|
|
124
|
-
} else {
|
|
125
|
-
throw new Error('unknown algorithm')
|
|
126
|
-
}
|
|
127
|
-
} catch (e) {
|
|
128
|
-
if (isAbortError(e)) {
|
|
129
|
-
getSession(model).notify('Cancelled InterProScan query', 'info')
|
|
130
|
-
} else {
|
|
131
|
-
throw e
|
|
132
|
-
}
|
|
133
|
-
} finally {
|
|
134
|
-
onProgress()
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
async function loadInterProScanResultsWithStatus({
|
|
139
|
-
jobId,
|
|
140
|
-
model,
|
|
141
|
-
onProgress,
|
|
142
|
-
signal,
|
|
143
|
-
}: {
|
|
144
|
-
jobId: string
|
|
145
|
-
model: MsaViewModel
|
|
146
|
-
onProgress: (arg?: { msg: string; url?: string }) => void
|
|
147
|
-
signal?: AbortSignal
|
|
148
|
-
}) {
|
|
149
|
-
try {
|
|
150
|
-
onProgress({
|
|
151
|
-
msg: `Downloading results of ${jobId} (for larger sequences this can be slow, click status to download and upload in the manual tab)`,
|
|
152
|
-
url: `https://www.ebi.ac.uk/Tools/services/rest/iprscan5/result/${jobId}/json`,
|
|
153
|
-
})
|
|
154
|
-
const ret = await loadInterProScanResults(jobId, signal)
|
|
155
|
-
model.setAnnotations(interProScanResponseToAnnotations(ret))
|
|
156
|
-
getSession(model).notify(`Loaded interproscan ${jobId} results`, 'success')
|
|
157
|
-
} catch (e) {
|
|
158
|
-
if (isAbortError(e)) {
|
|
159
|
-
getSession(model).notify('Cancelled InterProScan query', 'info')
|
|
160
|
-
} else {
|
|
161
|
-
console.error(e)
|
|
162
|
-
getSession(model).notifyError(`${e}`, e)
|
|
163
|
-
}
|
|
164
|
-
} finally {
|
|
165
|
-
onProgress()
|
|
166
|
-
}
|
|
167
|
-
}
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
//
|
|
3
|
-
// What a failed load LOOKS like, which is a different question from what the
|
|
4
|
-
// model recorded. modelFilehandleLoaders.test.ts already pins the model half --
|
|
5
|
-
// a rejected fetch sets `error` and clears `loadingMSA`, under the name "a
|
|
6
|
-
// failed fetch surfaces as an error" -- and it passed throughout the whole time
|
|
7
|
-
// a failed url showed the reader a spinner and nothing else.
|
|
8
|
-
//
|
|
9
|
-
// The error display was never missing. ImportForm renders `model.error`, and
|
|
10
|
-
// the reader could not get to it: a non-abort failure KEEPS its filehandle
|
|
11
|
-
// (only an abort clears it), so `hasPendingFilehandle` stayed true and
|
|
12
|
-
// Loading.tsx took its spinner branch ahead of the import form, permanently.
|
|
13
|
-
// Found on AlphaFold's `files/msa/` prefix answering 403 to every key in August
|
|
14
|
-
// 2026 -- the view opened, said "Downloading file" with a Cancel button, and
|
|
15
|
-
// was still saying it at 105s.
|
|
16
|
-
import React, { act } from 'react'
|
|
17
|
-
|
|
18
|
-
import { createRoot } from 'react-dom/client'
|
|
19
|
-
import { afterEach, beforeEach, expect, test } from 'vitest'
|
|
20
|
-
|
|
21
|
-
import MSAView from './components/Loading.tsx'
|
|
22
|
-
import MSAModelF from './model.ts'
|
|
23
|
-
|
|
24
|
-
import type { MsaViewModel } from './model.ts'
|
|
25
|
-
import type { Root } from 'react-dom/client'
|
|
26
|
-
|
|
27
|
-
Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true)
|
|
28
|
-
|
|
29
|
-
let container: HTMLDivElement
|
|
30
|
-
let root: Root
|
|
31
|
-
let model: MsaViewModel
|
|
32
|
-
|
|
33
|
-
function uri(u: string) {
|
|
34
|
-
return { locationType: 'UriLocation' as const, uri: u }
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function render() {
|
|
38
|
-
act(() => {
|
|
39
|
-
root.render(<MSAView model={model} />)
|
|
40
|
-
})
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
beforeEach(() => {
|
|
44
|
-
container = document.createElement('div')
|
|
45
|
-
document.body.append(container)
|
|
46
|
-
root = createRoot(container)
|
|
47
|
-
model = MSAModelF().create({ type: 'MsaView' })
|
|
48
|
-
model.setWidth(800)
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
afterEach(() => {
|
|
52
|
-
act(() => {
|
|
53
|
-
root.unmount()
|
|
54
|
-
})
|
|
55
|
-
container.remove()
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
test('a load that fails shows the error rather than a spinner forever', () => {
|
|
59
|
-
// the state the loader leaves behind on a 403: the filehandle it could not
|
|
60
|
-
// read is still set, nothing parsed, and the error recorded
|
|
61
|
-
model.setMSAFilehandle(uri('https://example.com/blocked.a3m'))
|
|
62
|
-
model.setLoadingMSA(false)
|
|
63
|
-
model.setError(new Error('HTTP 403 fetching blocked.a3m'))
|
|
64
|
-
render()
|
|
65
|
-
|
|
66
|
-
expect(container.textContent).toContain('403')
|
|
67
|
-
expect(container.textContent).not.toContain('Loading')
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
test('a load still in flight is a spinner, not an error', () => {
|
|
71
|
-
model.setMSAFilehandle(uri('https://example.com/slow.a3m'))
|
|
72
|
-
render()
|
|
73
|
-
|
|
74
|
-
expect(model.loadingMSA).toBe(true)
|
|
75
|
-
expect(container.textContent).not.toContain('Return to import form')
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
// A guard rather than a pin: this one passes without the fix too, because with
|
|
79
|
-
// no filehandle left set the view reaches the ImportForm and that already shows
|
|
80
|
-
// the error. It is here so the new branch cannot quietly swallow the case.
|
|
81
|
-
//
|
|
82
|
-
// It also records the surprise underneath: `dataInitialized` is itself
|
|
83
|
-
// `(msa || tree) && !error`, so ANY error hides a loaded alignment, including a
|
|
84
|
-
// tree or gff that 404s beside a good one -- setError is shared by every
|
|
85
|
-
// filehandle loader. Whether a failed OPTIONAL file should blank a good
|
|
86
|
-
// alignment is a real question and a wider one, since `dataInitialized` is read
|
|
87
|
-
// in several places; narrowing it is a design change rather than this fix.
|
|
88
|
-
test('an error over loaded data says so instead of a bare import form', () => {
|
|
89
|
-
const loaded = MSAModelF().create({
|
|
90
|
-
type: 'MsaView',
|
|
91
|
-
msaFormat: 'fasta',
|
|
92
|
-
height: 200,
|
|
93
|
-
data: { msa: '>seq1\nACDE\n>seq2\nACDF' },
|
|
94
|
-
})
|
|
95
|
-
loaded.setWidth(800)
|
|
96
|
-
loaded.setError(new Error('domains.gff 404'))
|
|
97
|
-
act(() => {
|
|
98
|
-
root.render(<MSAView model={loaded} />)
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
expect(container.textContent).toContain('domains.gff 404')
|
|
102
|
-
})
|
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
// The reactive filehandle loaders in afterCreate: each fetches when its
|
|
2
|
-
// filehandle changes, and each guards against a stale response, since a user can
|
|
3
|
-
// pick a second file while the first is still downloading.
|
|
4
|
-
import { beforeEach, expect, test, vi } from 'vitest'
|
|
5
|
-
|
|
6
|
-
import MSAModelF from './model.ts'
|
|
7
|
-
|
|
8
|
-
import type * as FetchUtils from './fetchUtils.ts'
|
|
9
|
-
|
|
10
|
-
// uri -> settle functions for the fetch that autorun kicked off
|
|
11
|
-
const inFlight = new Map<
|
|
12
|
-
string,
|
|
13
|
-
{ resolve: (text: string) => void; reject: (e: unknown) => void }
|
|
14
|
-
>()
|
|
15
|
-
|
|
16
|
-
vi.mock('@jbrowse/core/util/io', () => ({
|
|
17
|
-
openLocation: (loc: { uri: string }) => loc,
|
|
18
|
-
}))
|
|
19
|
-
|
|
20
|
-
vi.mock('./fetchUtils.ts', async importOriginal => ({
|
|
21
|
-
...(await importOriginal<typeof FetchUtils>()),
|
|
22
|
-
fetchTextWithProgress: (loc: { uri: string }) =>
|
|
23
|
-
new Promise<string>((resolve, reject) => {
|
|
24
|
-
inFlight.set(loc.uri, { resolve, reject })
|
|
25
|
-
}),
|
|
26
|
-
}))
|
|
27
|
-
|
|
28
|
-
function uri(u: string) {
|
|
29
|
-
return { locationType: 'UriLocation' as const, uri: u }
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function fasta(seq: string) {
|
|
33
|
-
return `>seq1\n${seq}`
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// let the awaited fetch and the actions that follow it run
|
|
37
|
-
function flush() {
|
|
38
|
-
return new Promise(resolve => setTimeout(resolve, 0))
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function makeModel() {
|
|
42
|
-
const model = MSAModelF().create({ type: 'MsaView' })
|
|
43
|
-
model.setWidth(800)
|
|
44
|
-
return model
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
beforeEach(() => {
|
|
48
|
-
inFlight.clear()
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
test('an msa filehandle loads into the model and clears its loading flag', async () => {
|
|
52
|
-
const model = makeModel()
|
|
53
|
-
model.setMSAFilehandle(uri('aln.fa'))
|
|
54
|
-
expect(model.loadingMSA).toBe(true)
|
|
55
|
-
|
|
56
|
-
inFlight.get('aln.fa')!.resolve(fasta('ACDE'))
|
|
57
|
-
await flush()
|
|
58
|
-
|
|
59
|
-
expect(model.data.msa).toBe(fasta('ACDE'))
|
|
60
|
-
expect(model.loadingMSA).toBe(false)
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
test('a slower earlier fetch never clobbers a newer one', async () => {
|
|
64
|
-
const model = makeModel()
|
|
65
|
-
model.setMSAFilehandle(uri('first.fa'))
|
|
66
|
-
model.setMSAFilehandle(uri('second.fa'))
|
|
67
|
-
|
|
68
|
-
// the newer fetch finishes first, then the abandoned one comes back
|
|
69
|
-
inFlight.get('second.fa')!.resolve(fasta('SECOND'))
|
|
70
|
-
await flush()
|
|
71
|
-
inFlight.get('first.fa')!.resolve(fasta('FIRST'))
|
|
72
|
-
await flush()
|
|
73
|
-
|
|
74
|
-
expect(model.data.msa).toBe(fasta('SECOND'))
|
|
75
|
-
expect(model.loadingMSA).toBe(false)
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
test('a stale failure does not raise an error over live data', async () => {
|
|
79
|
-
const model = makeModel()
|
|
80
|
-
model.setMSAFilehandle(uri('first.fa'))
|
|
81
|
-
model.setMSAFilehandle(uri('second.fa'))
|
|
82
|
-
|
|
83
|
-
inFlight.get('second.fa')!.resolve(fasta('SECOND'))
|
|
84
|
-
await flush()
|
|
85
|
-
inFlight.get('first.fa')!.reject(new Error('too slow'))
|
|
86
|
-
await flush()
|
|
87
|
-
|
|
88
|
-
expect(model.error).toBeUndefined()
|
|
89
|
-
expect(model.data.msa).toBe(fasta('SECOND'))
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
test('cancelling drops the filehandle so the view returns to the import form', async () => {
|
|
93
|
-
const model = makeModel()
|
|
94
|
-
model.setMSAFilehandle(uri('aln.fa'))
|
|
95
|
-
|
|
96
|
-
inFlight.get('aln.fa')!.reject(new DOMException('Aborted', 'AbortError'))
|
|
97
|
-
await flush()
|
|
98
|
-
|
|
99
|
-
expect(model.msaFilehandle).toBeUndefined()
|
|
100
|
-
expect(model.error).toBeUndefined()
|
|
101
|
-
expect(model.loadingMSA).toBe(false)
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
test('a failed fetch surfaces as an error', async () => {
|
|
105
|
-
const model = makeModel()
|
|
106
|
-
model.setMSAFilehandle(uri('aln.fa'))
|
|
107
|
-
|
|
108
|
-
inFlight.get('aln.fa')!.reject(new Error('404'))
|
|
109
|
-
await flush()
|
|
110
|
-
|
|
111
|
-
expect(model.error).toBeInstanceOf(Error)
|
|
112
|
-
expect(model.loadingMSA).toBe(false)
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
test('tree, treeMetadata and gff filehandles each load into their own field', async () => {
|
|
116
|
-
const model = MSAModelF().create({
|
|
117
|
-
type: 'MsaView',
|
|
118
|
-
treeFilehandle: uri('tree.nh'),
|
|
119
|
-
treeMetadataFilehandle: uri('meta.json'),
|
|
120
|
-
gffFilehandle: uri('domains.gff'),
|
|
121
|
-
})
|
|
122
|
-
model.setWidth(800)
|
|
123
|
-
|
|
124
|
-
inFlight.get('tree.nh')!.resolve('(a,b);')
|
|
125
|
-
inFlight.get('meta.json')!.resolve('{"a":{"genome":"human"}}')
|
|
126
|
-
inFlight
|
|
127
|
-
.get('domains.gff')!
|
|
128
|
-
.resolve(
|
|
129
|
-
'##gff-version 3\na\tPfam\tprotein_match\t1\t2\t.\t.\t.\tName=PF00069',
|
|
130
|
-
)
|
|
131
|
-
await flush()
|
|
132
|
-
|
|
133
|
-
expect(model.data.tree).toBe('(a,b);')
|
|
134
|
-
expect(model.treeMetadata.a?.genome).toBe('human')
|
|
135
|
-
expect(model.actuallyShowDomains).toBe(true)
|
|
136
|
-
expect(model.loadingTree).toBe(false)
|
|
137
|
-
})
|
package/src/modelReset.test.ts
DELETED
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
// reset() returns the view to the import form, and the next file loads into
|
|
2
|
-
// the same model instance. It applies a default snapshot filtered to
|
|
3
|
-
// preservedOnReset, so anything file-derived that survived would be a hole in
|
|
4
|
-
// that list — the last test enumerates the snapshot keys to keep the list and
|
|
5
|
-
// the model's properties from drifting apart as properties are added. The
|
|
6
|
-
// stakes: node ids are path-derived (node-0-0-1), so a stale collapsed or
|
|
7
|
-
// showOnly id names a real node in the next tree and folds it.
|
|
8
|
-
import { getSnapshot } from '@jbrowse/mobx-state-tree'
|
|
9
|
-
import { describe, expect, test } from 'vitest'
|
|
10
|
-
|
|
11
|
-
import MSAModelF, { preservedOnReset } from './model.ts'
|
|
12
|
-
|
|
13
|
-
const firstFile = {
|
|
14
|
-
msa: '>a\nACGT\n>b\nACGT\n>c\nACGT\n>d\nACGT',
|
|
15
|
-
tree: '((a,b),(c,d));',
|
|
16
|
-
}
|
|
17
|
-
const secondFile = {
|
|
18
|
-
msa: '>e\nTTTT\n>f\nTTTT\n>g\nTTTT\n>h\nTTTT',
|
|
19
|
-
tree: '((e,f),(g,h));',
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function makeModel() {
|
|
23
|
-
const model = MSAModelF().create({ type: 'MsaView', data: firstFile })
|
|
24
|
-
model.setWidth(800)
|
|
25
|
-
return model
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function internalNodeId(model: ReturnType<typeof makeModel>) {
|
|
29
|
-
return model.leaves[0]!.parent!.data.id
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
describe('reset', () => {
|
|
33
|
-
test('a collapsed clade does not fold the next loaded tree', () => {
|
|
34
|
-
const model = makeModel()
|
|
35
|
-
model.toggleCollapsed(internalNodeId(model))
|
|
36
|
-
expect(model.leaves.length).toBe(3)
|
|
37
|
-
|
|
38
|
-
model.reset()
|
|
39
|
-
model.setData(secondFile)
|
|
40
|
-
expect(model.collapsed.length).toBe(0)
|
|
41
|
-
expect(model.leaves.map(l => l.data.name).toSorted()).toEqual([
|
|
42
|
-
'e',
|
|
43
|
-
'f',
|
|
44
|
-
'g',
|
|
45
|
-
'h',
|
|
46
|
-
])
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
test('showOnly does not restrict the next loaded tree', () => {
|
|
50
|
-
const model = makeModel()
|
|
51
|
-
model.setShowOnly(internalNodeId(model))
|
|
52
|
-
expect(model.leaves.length).toBe(2)
|
|
53
|
-
|
|
54
|
-
model.reset()
|
|
55
|
-
model.setData(secondFile)
|
|
56
|
-
expect(model.showOnly).toBe(undefined)
|
|
57
|
-
expect(model.leaves.length).toBe(4)
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
test('clears file-derived volatiles', () => {
|
|
61
|
-
const model = makeModel()
|
|
62
|
-
model.setMousePos(2, 1)
|
|
63
|
-
model.setMouseClickPos(3, 0)
|
|
64
|
-
model.setHighlightedColumns([1, 2])
|
|
65
|
-
model.setHoveredTreeNode(internalNodeId(model))
|
|
66
|
-
|
|
67
|
-
model.reset()
|
|
68
|
-
expect(model.mouseCol).toBe(undefined)
|
|
69
|
-
expect(model.mouseClickCol).toBe(undefined)
|
|
70
|
-
expect(model.highlightedColumns).toBe(undefined)
|
|
71
|
-
expect(model.hoveredTreeNode).toBe(undefined)
|
|
72
|
-
expect(model.error).toBe(undefined)
|
|
73
|
-
expect(model.annotations).toEqual([])
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
test('display preferences survive', () => {
|
|
77
|
-
const model = makeModel()
|
|
78
|
-
model.setColorSchemeName('clustalx')
|
|
79
|
-
model.setDrawNodeBubbles(false)
|
|
80
|
-
model.toggleTrack('conservation')
|
|
81
|
-
|
|
82
|
-
model.reset()
|
|
83
|
-
expect(model.colorSchemeName).toBe('clustalx')
|
|
84
|
-
expect(model.drawNodeBubbles).toBe(false)
|
|
85
|
-
expect(model.turnedOffTracks.get('conservation')).toBe(true)
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
test('every property off preservedOnReset matches a fresh model', () => {
|
|
89
|
-
const model = makeModel()
|
|
90
|
-
model.toggleCollapsed(internalNodeId(model))
|
|
91
|
-
model.setShowOnly(internalNodeId(model))
|
|
92
|
-
model.drawRelativeTo('a')
|
|
93
|
-
model.setFilter('PF00001', true)
|
|
94
|
-
model.setScrollX(-100)
|
|
95
|
-
model.setScrollY(-50)
|
|
96
|
-
model.zoomIn()
|
|
97
|
-
model.setCurrentAlignment(1)
|
|
98
|
-
model.setMSAFilehandle({
|
|
99
|
-
uri: 'http://x/msa.fa',
|
|
100
|
-
locationType: 'UriLocation',
|
|
101
|
-
})
|
|
102
|
-
model.setTreeFilehandle({
|
|
103
|
-
uri: 'http://x/t.nh',
|
|
104
|
-
locationType: 'UriLocation',
|
|
105
|
-
})
|
|
106
|
-
model.setTreeMetadataFilehandle({
|
|
107
|
-
uri: 'http://x/m.json',
|
|
108
|
-
locationType: 'UriLocation',
|
|
109
|
-
})
|
|
110
|
-
model.setMSAFormat('fasta')
|
|
111
|
-
|
|
112
|
-
model.reset()
|
|
113
|
-
const fresh = MSAModelF().create({ type: 'MsaView' })
|
|
114
|
-
const strip = (snap: object) =>
|
|
115
|
-
Object.fromEntries(
|
|
116
|
-
Object.entries(snap).filter(([key]) => !preservedOnReset.has(key)),
|
|
117
|
-
)
|
|
118
|
-
expect(strip(getSnapshot(model))).toEqual(strip(getSnapshot(fresh)))
|
|
119
|
-
})
|
|
120
|
-
})
|