diff --git a/scripts/browser-ui.js b/scripts/browser-ui.js index e1042bd..4742cd6 100644 --- a/scripts/browser-ui.js +++ b/scripts/browser-ui.js @@ -143,8 +143,8 @@ async function main() { await page.locator('#exampleDropdownTrigger').click(); await page.locator(`[data-example="${id}"]`).click(); await ready(page); - assert.equal(await page.locator('#sequence').inputValue(), example.vaRRIParams.sequence); - assert.equal(await page.locator('#structure').inputValue(), example.vaRRIParams.structure); + assert.equal(await page.locator('#sequence').inputValue(), example.vaRRIParams.fields.sequence); + assert.equal(await page.locator('#structure').inputValue(), example.vaRRIParams.fields.structure); assert.ok(await page.locator('#rendering-canvas circle[node_type="nucleotide"]').count() > 0); if (id === 'crossing-rri') await checkCrossingRriExample(page, output); } diff --git a/scripts/visual-baseline.js b/scripts/visual-baseline.js index 0d86ddc..971e423 100644 --- a/scripts/visual-baseline.js +++ b/scripts/visual-baseline.js @@ -38,6 +38,21 @@ function serve() { }); } +async function exampleParameters(example) { + if (example.vaRRIParams.format !== 'vaRRI') { + return new URLSearchParams({ ...example.vaRRIParams, + forceLayout: '0', forceLayoutLinearRRI: '0', forceLayoutLinearStructure: '0', + freeTrailingEnds: '0', pullPseudoknotBasepairs: '0', textAnnotations: '[]' }); + } + const codec = await import(pathToFileURL(path.join(root, 'src/core/model/index.js')).href); + const decoded = codec.decodeJsonState(example.vaRRIParams); + return codec.encodeUrlState({ ...decoded, + fields: { ...decoded.fields, forceLayout: false, forceLayoutLinearRRI: false, + forceLayoutLinearStructure: false, forceLayoutFreeTails: false, forceLayoutPullCrossing: false }, + annotations: { ...decoded.annotations, textAnnotations: [] }, + }); +} + async function pixels(file) { const image = await loadImage(file); const canvas = createCanvas(image.width, image.height); @@ -125,9 +140,7 @@ async function main() { let seed = 83; Math.random = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296); }); - const parameters = new URLSearchParams({ ...examples[name].vaRRIParams, - forceLayout: '0', forceLayoutLinearRRI: '0', forceLayoutLinearStructure: '0', - freeTrailingEnds: '0', pullPseudoknotBasepairs: '0', textAnnotations: '[]' }); + const parameters = await exampleParameters(examples[name]); await page.goto(`${origin}/index.html?${parameters}`, { waitUntil: 'networkidle' }); await page.waitForFunction(() => document.querySelector('#rendering-canvas circle[node_num]') && document.getElementById('rendering-canvas')?.style.visibility !== 'hidden'); diff --git a/src/ui/examples-data.js b/src/ui/examples-data.js index e5ef308..bd46584 100644 --- a/src/ui/examples-data.js +++ b/src/ui/examples-data.js @@ -1,13 +1,4 @@ -const featureOverviewProfile = [ - '# unpaired probabilities', - '1 0.9', - '2 0.7', - '3 0.3', - '4 0.1', - '7 0.3', - '8 0.7', - '9 0.6', -].join('\n'); +import featureOverview from './examples/feature-overview.js'; const coronelTellezProfile1 = [ '61 0.6', @@ -106,32 +97,15 @@ const coronelTellezProfile2 = [ '107 0.6', ].join('\n'); +// vaRRIParams uses the Store/Load JSON session shape so a stored visualization +// can replace an example payload without conversion. const examples = { '2mol': { name: 'RNA–RNA interaction feature overview', nameShort: 'All feature showcase', description: 'Demonstrates cropping, probability profiles, region and subsequence highlights, and point mutations in one interaction.', descriptionShort: 'Showcases all features of vaRRI.', - vaRRIParams: { - sequence: 'ACGAUCAUGUGGUUUAGAGCAUUUUCGACAGCAG&ACGAAAAAAAGAGCAGACAGUAG', - structure: '..<<..<<...>>>>...((....(((...((..&............)))))..))..', - startIndex1: -6, - startIndex2: 100, - coloring: 'strand', - highlighting: 'region', - backgroundhighlighting: 'basepairs', - forceLayoutLinearRRI: 1, - distinctBpTypes: 1, - cropping: 2, - forceLayout: 0, - regionHighlights: '21-25&113-114:0d00ff:0.2', - subseqHighlights: '1:15-18:338a29:1,2:117-118:1e1ee4:0.7', - mutations: '1:20G:338a29,2:115C:1e1ee4', - profileData1: featureOverviewProfile, - profileIdxRef1: 1, - profileColorRepresentsOne1: 1, - profileColorRepresentsOne2: 0, - }, + vaRRIParams: featureOverview, }, 'coronel-tellez-2022': { name: 'sRNA-controlled iron sparing response in Staphylococci', @@ -141,23 +115,22 @@ const examples = { description: 'Reproduction of *Figure 6A-bottom* [(Coronel-Tellez, et al., 2022)](http://dx.doi.org/10.1093/nar/gkac648) showing an interaction with position-specific SHAPE probing data annotation and subsequence highlighting. The long input sequences are cropped to the region of interaction +3 nucleotides.', descriptionShort: 'Position-specific SHAPE probing data annotation and cropping of long sequences to RRI region.', vaRRIParams: { - sequence: 'AAUUCUAUCUGAAAGAUGUGUGGGGCAUCGUUAUUUUAGGUGGAUAUGAGCAAUUUAUUAAAAGUCAUUUACGGAAAAUAUAUAUAGACGGGGUGAGUAAUAUGCAAGAACAUUUGGUGGUUACACUUGAUAGCAAAGGAGAAGAACUU&UUGAAAAUGAUUAUCAAUACCACAUAGAACAUCCCCCCCACAACGUUUCGUUCUUGUUGGAUUGGUCAUUUUCAAAUAUUCCCCUUUUAUAUGCCCGUAAAAGACAAUAUACGUUAUAACAACGUUUUAUAAAAGCAGUAAACCCUUACGACACUUUAGGUUUACUGCUUUUGU', - structure: '...............................................................(((.(((((((......((((((((.((((.((((((.(((.............................................&..................................................................))))......))))))))))))))))).))))))).))).....................................................................', - colorSeq1: 'c9c1c9', - colorSeq2: 'c9c1c9', - startIndex1: 1, - startIndex2: 1, - cropping: 3, - forceLayout: 'off', - backgroundhighlighting: 'nothing', - highlighting: 'basepairs', - subseqHighlights: '1:88-93:0f50b8:1,1:102-104:0e7a06:1,2:81-84:e012dd:1,2:94-96:e012dd:1', - profileData1: coronelTellezProfile1, - profileColorRepresentsOne1: 1, - profileColor1: 'ea373c', - profileData2: coronelTellezProfile2, - profileColorRepresentsOne2: 1, - profileColor2: 'ea373c', + format: 'vaRRI', version: 1, + fields: { + sequence: 'AAUUCUAUCUGAAAGAUGUGUGGGGCAUCGUUAUUUUAGGUGGAUAUGAGCAAUUUAUUAAAAGUCAUUUACGGAAAAUAUAUAUAGACGGGGUGAGUAAUAUGCAAGAACAUUUGGUGGUUACACUUGAUAGCAAAGGAGAAGAACUU&UUGAAAAUGAUUAUCAAUACCACAUAGAACAUCCCCCCCACAACGUUUCGUUCUUGUUGGAUUGGUCAUUUUCAAAUAUUCCCCUUUUAUAUGCCCGUAAAAGACAAUAUACGUUAUAACAACGUUUUAUAAAAGCAGUAAACCCUUACGACACUUUAGGUUUACUGCUUUUGU', + structure: '...............................................................(((.(((((((......((((((((.((((.((((((.(((.............................................&..................................................................))))......))))))))))))))))).))))))).))).....................................................................', + colorSeq1: '#c9c1c9', colorSeq2: '#c9c1c9', startIndex1: '1', startIndex2: '1', cropping: '3', + forceLayout: false, backgroundhighlighting: 'nothing', highlighting: 'basepairs', + profileData1: coronelTellezProfile1, profileColorRepresentsOne1: true, profileColor1: '#ea373c', + profileData2: coronelTellezProfile2, profileColorRepresentsOne2: true, profileColor2: '#ea373c', + }, + annotations: { subsequenceHighlights: [ + { sequence: '1', range: [[88, 93]], color: '#0f50b8', alpha: 1 }, + { sequence: '1', range: [[102, 104]], color: '#0e7a06', alpha: 1 }, + { sequence: '2', range: [[81, 84]], color: '#e012dd', alpha: 1 }, + { sequence: '2', range: [[94, 96]], color: '#e012dd', alpha: 1 }, + ] }, + rotation: 0, showRenderingOnly: false, }, }, 'wu-2024': { @@ -168,13 +141,22 @@ const examples = { description: 'Reproduction of Figure 4C [(Wu et al., 2024)](https://doi.org/10.1101/2024.06.23.600155) showing an interaction with 4 mutations and a subsequence highlighting within a sequence context upstream of the start codon.', descriptionShort: 'Subsequence highlighting and mutations within a sequence context.', vaRRIParams: { - sequence: 'AACUCGCGAAAGCCAUAAAAACCAGGGAGACA&UUCCCUGGUGUUGGCGCAGUAUUCGCGCA', - structure: '....((((((.((((.....((((((((....&.))))))))..))))......))))))..', - startIndex1: -35, - startIndex2: 2, - subseqHighlights: '1:-12--6:0dec3f:0.9', - mutations: '1:-14G:fb0bcb,1:-13G:fb0bcb,2:8C:fb0bcb,2:9C:fb0bcb', - forceLayout: 'off', + format: 'vaRRI', version: 1, + fields: { + sequence: 'AACUCGCGAAAGCCAUAAAAACCAGGGAGACA&UUCCCUGGUGUUGGCGCAGUAUUCGCGCA', + structure: '....((((((.((((.....((((((((....&.))))))))..))))......))))))..', + startIndex1: '-35', startIndex2: '2', forceLayout: false, + }, + annotations: { + subsequenceHighlights: [{ sequence: '1', range: [[-12, -6]], color: '#0dec3f', alpha: 0.9 }], + pointMutations: [ + { sequence: '1', position: -14, replacement: 'G', color: '#fb0bcb' }, + { sequence: '1', position: -13, replacement: 'G', color: '#fb0bcb' }, + { sequence: '2', position: 8, replacement: 'C', color: '#fb0bcb' }, + { sequence: '2', position: 9, replacement: 'C', color: '#fb0bcb' }, + ], + }, + rotation: 0, showRenderingOnly: false, }, }, 'IntaRNA-seeds': { @@ -183,20 +165,19 @@ const examples = { description: 'Highlighting of the two potential seed interactions considered by IntaRNA to predict the shown interaction between the two sequences. The highlighting is done using a semi-transparent purple color, which allows to show overlapping regions.', descriptionShort: 'Opaque highlighting of multiple potential IntaRNA seed regions.', vaRRIParams: { - sequence:'CUUAGCCGUAAUUGGAUUAGCUGAUGAACAAACUUCUCGUGAGUCUGCUGUUGACCCUGGGUCUGAC&GAGAGACCCACGCAGUCGGACUCUUCAGAUUAUCUCCUCAUCAGGCUAAUCACGGUUUUU', - colorSeq1:'#add8e6', - startIndex1:-23, - colorSeq2:'#f4bb44', - startIndex2:64, - structure:'...((((((.....(((((((((((((.......(((...((((((((((.......(((((((...&...)))))))..))).)))))))...)))........)))))).)))))))))))))...', - colorBasepair:'#ff0000', - coloring:'strand', - highlighting:'region', - colorRriNodes:'#ff0000', - forceLayoutLinearRRI: 1, - distinctBpTypes:1, - regionHighlights:'35-41&67-73:0D00FF:0.4,-9--3&108-114:0D00FF:0.4', - forceLayout: 'off', + format: 'vaRRI', version: 1, + fields: { + sequence: 'CUUAGCCGUAAUUGGAUUAGCUGAUGAACAAACUUCUCGUGAGUCUGCUGUUGACCCUGGGUCUGAC&GAGAGACCCACGCAGUCGGACUCUUCAGAUUAUCUCCUCAUCAGGCUAAUCACGGUUUUU', + structure: '...((((((.....(((((((((((((.......(((...((((((((((.......(((((((...&...)))))))..))).)))))))...)))........)))))).)))))))))))))...', + colorSeq1: '#add8e6', startIndex1: '-23', colorSeq2: '#f4bb44', startIndex2: '64', + colorBasepair: '#ff0000', coloring: 'strand', highlighting: 'region', colorRriNodes: '#ff0000', + forceLayoutLinearRRI: true, distinctBpTypes: true, forceLayout: false, + }, + annotations: { regionHighlights: [ + { sequence1Range: [35, 41], sequence2Range: [67, 73], color: '#0D00FF', alpha: 0.4 }, + { sequence1Range: [-9, -3], sequence2Range: [108, 114], color: '#0D00FF', alpha: 0.4 }, + ] }, + rotation: 0, showRenderingOnly: false, }, }, 'crossing-rri': { @@ -205,13 +186,14 @@ const examples = { descriptionShort: 'Crossing RNA–RNA interaction resolved via relaxed force-field layout.', description: 'Crossing RNA–RNA interactions (w.r.t. the underlying joint-structure layout algorithm of fornac) are typically problematic since their base pairing forms a crossing pseudoknot structure. Some can be layouted when relaxing the force field (see Visualization Settings).', vaRRIParams: { - sequence: 'NNNNNNNNNNNNNNNNN&NNNNNNNNNNNNN', - structure:'((..((...<<<..<<<&))))...>>>>>>', - distinctBpTypes: 0, - forceLayout: 1, - forceLayoutFreeTails: 1, - forceLayoutPullCrossing: 1, - }, + format: 'vaRRI', version: 1, + fields: { + sequence: 'NNNNNNNNNNNNNNNNN&NNNNNNNNNNNNN', + structure: '((..((...<<<..<<<&))))...>>>>>>', + distinctBpTypes: false, forceLayout: true, forceLayoutFreeTails: true, forceLayoutPullCrossing: true, + }, + annotations: {}, rotation: 0, showRenderingOnly: false, + }, }, }; diff --git a/src/ui/examples/feature-overview.js b/src/ui/examples/feature-overview.js new file mode 100644 index 0000000..39b4af5 --- /dev/null +++ b/src/ui/examples/feature-overview.js @@ -0,0 +1,113 @@ +export default { + format: 'vaRRI', + version: 1, + fields: { + sequence: 'ACGAUCAUGUGGUUUAGAGCAUUUUCGACAGCAG&ACGAAAAAAAGAGCAGACAGUAG', + structure: '..<<..<<...>>>>...((....(((...((..&............)))))..))..', + startIndex1: '-6', + startIndex2: '100', + seqName1: 'blue RNA', + seqName2: 'the target', + cropping: '2', + colorSeq1: '#add8e6', + colorSeq2: '#f4bb44', + coloring: 'strand', + highlighting: 'region', + backgroundhighlighting: 'basepairs', + distinctBpTypes: true, + profileData1: '# unpaired probabilities\n1 0.9\n2 0.7\n3 0.3\n4 0.1\n7 0.3\n8 0.7\n9 0.6', + profileIdxRef1: '1', + profileColor1: '#800080', + profileColorRepresentsOne1: true, + profileData2: '', + profileIdxRef2: '1', + profileColor2: '#ff0000', + profileColorRepresentsOne2: false, + forceLayout: false, + forceLayoutFreeTails: false, + forceLayoutPullCrossing: false, + forceLayoutLinearStructure: false, + forceLayoutLinearRRI: true, + hideFooterAndHeader: false, + colorRriNodes: '#ff0000', + colorRriRegion: '#ff0000', + colorBasepair: '#ff0000', + }, + annotations: { + pointMutations: [ + { sequence: '1', position: 20, replacement: 'G', color: '#338a29' }, + { sequence: '2', position: 115, replacement: 'C', color: '#1E1EE4' }, + ], + subsequenceHighlights: [ + { sequence: '1', range: [[15, 18]], color: '#338A29', alpha: 1 }, + { sequence: '2', range: [[117, 118]], color: '#1E1EE4', alpha: 0.7 }, + ], + regionHighlights: [ + { sequence1Range: [21, 25], sequence2Range: [113, 114], color: '#0D00FF', alpha: 0.2 }, + ], + textAnnotations: [ + { + text: 'blue RNA', sequenceNameFor: '1', bold: true, italic: false, size: 8, color: '#2891c8', + position: { x: -200.7008741529051, y: 13.469304295038569 }, anchor: null, + }, + { + text: 'the target', sequenceNameFor: '2', bold: true, italic: false, size: 8, color: '#e8be17', + position: { x: -200.57799194603135, y: -33.758161226681665 }, anchor: null, + }, + { + text: 'subsequence', sequenceNameFor: null, bold: true, italic: true, size: 8, color: '#56af3d', + position: { x: -21.39257544906482, y: 47.81899107367843 }, anchor: null, + }, + { + text: 'annotation', sequenceNameFor: null, bold: true, italic: true, size: 8, color: '#56af3d', + position: { x: -21.963319946538917, y: 62.08760351052973 }, anchor: null, + }, + { + text: 'region', sequenceNameFor: null, bold: false, italic: false, size: 8, color: '#8049ab', + position: { x: -94.44787112574372, y: 36.40410112419755 }, anchor: null, + }, + { + text: 'annotation', sequenceNameFor: null, bold: false, italic: false, size: 8, color: '#8049ab', + position: { x: -92.73563763332153, y: 47.81899107367843 }, anchor: null, + }, + { + text: 'point', sequenceNameFor: null, bold: true, italic: false, size: 8, color: '#0737de', + position: { x: -61.66608138819855, y: -52.52624900153063 }, anchor: null, + }, + { + text: 'mutation', sequenceNameFor: null, bold: true, italic: false, size: 8, color: '#0737de', + position: { x: -60.36931650324601, y: -41.50374747943411 }, anchor: null, + }, + { + text: 'indexing', sequenceNameFor: null, bold: false, italic: true, size: 8, color: '#ff0000', + position: { x: -126.09563916228302, y: -45.965174958955686 }, anchor: null, + }, + { + text: 'probability', sequenceNameFor: null, bold: true, italic: false, size: 8, color: '#a8488b', + position: { x: 100.16407087669964, y: -55.85866037891114 }, anchor: null, + }, + { + text: 'profile', sequenceNameFor: null, bold: true, italic: false, size: 8, color: '#a8488b', + position: { x: 104.37348693340908, y: -40.64601818636274 }, anchor: null, + }, + { + text: 'sequence names', sequenceNameFor: null, bold: false, italic: false, size: 8, color: '#000000', + position: { x: -200.6407749205137, y: -53.51437846108769 }, anchor: null, + }, + { + text: 'base-pair', sequenceNameFor: null, bold: false, italic: false, size: 8, color: '#ff0000', + position: { x: 90.7051408510386, y: 25.763421748858548 }, anchor: null, + }, + { + text: 'types', sequenceNameFor: null, bold: false, italic: false, size: 8, color: '#ff0000', + position: { x: 90.20965459972643, y: 38.15057803166269 }, anchor: null, + }, + { + text: 'linear RRI helices', sequenceNameFor: null, bold: false, italic: false, size: 8, color: '#ff8080', + position: { x: -168.9296548365352, y: -8.425129591680786 }, anchor: null, + }, + ], + }, + rotation: 0, + showRenderingOnly: false, +}; diff --git a/src/ui/navigation-actions.js b/src/ui/navigation-actions.js index bad6b41..7aa77e2 100644 --- a/src/ui/navigation-actions.js +++ b/src/ui/navigation-actions.js @@ -1,5 +1,6 @@ import { nextTick } from 'vue'; import { decodeUrlState, encodeUrlState } from '../core/model/url-state.js'; +import { decodeJsonState } from '../core/model/json-state.js'; import { marked } from './vendor/marked.js'; export function createNavigationActions({ api, state, defaults, initialColors, actions, document, examples }) { @@ -81,14 +82,13 @@ export function createNavigationActions({ api, state, defaults, initialColors, a async function loadExample(key) { const example = examples[key]; if (!example?.vaRRIParams) return false; + const decoded = decodeJsonState(example.vaRRIParams); api.setColors(initialColors); clearAll(); const selectionGeneration = generation; - const params = new URLSearchParams(); - Object.entries(example.vaRRIParams).forEach(([key, value]) => { - if (key !== 'showRenderingOnly' && value !== undefined && value !== null) params.set(key, value); - }); - loadAllUrlParameters(params, { revealProfilePanel: false }); + const fields = { ...defaults, ...decoded.fields }; + state.renderingOnly = state.showFullPage = decoded.showRenderingOnly; + applySessionState({ ...decoded, fields }, { revealProfilePanel: false }); actions.resetFastaForm(); actions.resetSubseqForm(); actions.resetRegionForm(); actions.resetMutationForm(); actions.resetTextAnnotationForm(); state.selectedExample = key; diff --git a/tests/examples-data.test.js b/tests/examples-data.test.js index 466ca18..c591e6f 100644 --- a/tests/examples-data.test.js +++ b/tests/examples-data.test.js @@ -3,6 +3,8 @@ import vaRRI from '../src/vaRRI.js'; import examples from '../example-data.js'; +const decodeExample = example => vaRRI.decodeJsonState(example.vaRRIParams); + describe('shared example catalog', () => { test('contains the feature overview and the two existing literature examples', () => { expect(Object.keys(examples)).toEqual([ @@ -26,9 +28,13 @@ describe('shared example catalog', () => { test.each(Object.entries(examples))('%s is a valid two-molecule RNA-RNA interaction', (_id, example) => { expect(example.name.trim()).not.toBe(''); expect(example.description.trim()).not.toBe(''); - expect(example.vaRRIParams).toBeDefined(); + expect(example.vaRRIParams).toMatchObject({ + format: 'vaRRI', version: 1, fields: expect.any(Object), annotations: expect.any(Object), + rotation: expect.any(Number), showRenderingOnly: expect.any(Boolean), + }); - const { sequence, structure } = example.vaRRIParams; + const { fields, annotations } = decodeExample(example); + const { sequence, structure } = fields; expect(sequence.split('&')).toHaveLength(2); expect(structure.split('&')).toHaveLength(2); expect(sequence.split('&').every(Boolean)).toBe(true); @@ -37,29 +43,45 @@ describe('shared example catalog', () => { const validated = vaRRI.validate({ sequence, structure, - startIndex1: String(example.vaRRIParams.startIndex1 ?? 1), - startIndex2: String(example.vaRRIParams.startIndex2 ?? 1), - cropping: String(example.vaRRIParams.cropping ?? -1), - coloring: example.vaRRIParams.coloring ?? 'strand', - highlighting: example.vaRRIParams.highlighting ?? 'region', - backgroundhighlighting: example.vaRRIParams.backgroundhighlighting ?? 'basepairs', - distinctBpTypes: example.vaRRIParams.distinctBpTypes !== 0, + ...fields, ...annotations, + startIndex1: fields.startIndex1 ?? '1', + startIndex2: fields.startIndex2 ?? '1', + cropping: fields.cropping ?? '-1', + coloring: fields.coloring ?? 'strand', + highlighting: fields.highlighting ?? 'region', + backgroundhighlighting: fields.backgroundhighlighting ?? 'basepairs', + distinctBpTypes: fields.distinctBpTypes ?? true, }); expect(validated.molecules).toBe('2'); }); - test('literature URL parameters retain their showcased annotations', () => { - expect(examples['coronel-tellez-2022'].vaRRIParams).toMatchObject({ - cropping: 3, + test('stored JSON sessions retain the showcased annotations', () => { + const coronel = decodeExample(examples['coronel-tellez-2022']); + expect(coronel.fields).toMatchObject({ + cropping: '3', highlighting: 'basepairs', backgroundhighlighting: 'nothing', }); - expect(examples['coronel-tellez-2022'].vaRRIParams.subseqHighlights.split(',')).toHaveLength(4); - expect(examples['coronel-tellez-2022'].vaRRIParams.profileData1).not.toBe(''); - expect(examples['coronel-tellez-2022'].vaRRIParams.profileData2).not.toBe(''); + expect(coronel.annotations.subsequenceHighlights).toHaveLength(4); + expect(coronel.fields.profileData1).not.toBe(''); + expect(coronel.fields.profileData2).not.toBe(''); + + const wu = decodeExample(examples['wu-2024']); + expect(wu.annotations.subsequenceHighlights).toHaveLength(1); + expect(wu.annotations.pointMutations).toHaveLength(4); + }); - expect(examples['wu-2024'].vaRRIParams.subseqHighlights.split(',')).toHaveLength(1); - expect(examples['wu-2024'].vaRRIParams.mutations.split(',')).toHaveLength(4); + test('the feature showcase matches the replacement session', () => { + const showcase = decodeExample(examples['2mol']); + expect(showcase.fields).toMatchObject({ + seqName1: 'blue RNA', seqName2: 'the target', forceLayout: false, + forceLayoutLinearRRI: true, profileColorRepresentsOne2: false, + }); + expect(showcase.annotations).toMatchObject({ + pointMutations: expect.arrayContaining([expect.objectContaining({ position: 20, replacement: 'G' })]), + subsequenceHighlights: expect.any(Array), regionHighlights: expect.any(Array), + }); + expect(showcase.annotations.textAnnotations).toHaveLength(15); }); }); diff --git a/tests/force-geometry.test.js b/tests/force-geometry.test.js index 9a52986..634e3d3 100644 --- a/tests/force-geometry.test.js +++ b/tests/force-geometry.test.js @@ -10,12 +10,17 @@ const radius = nodes => { const y = nodes.reduce((sum, node) => sum + node.y, 0) / nodes.length; return nodes.reduce((sum, node) => sum + Math.hypot(node.x - x, node.y - y), 0) / nodes.length; }; +const exampleInput = (api, name) => { + const session = api.decodeJsonState(examples[name].vaRRIParams); + return { ...session.fields, ...session.annotations }; +}; test('all-in-one retains both exterior loop radii with ordinary force layout', () => { const dom = new JSDOM('
'); const container = createGraphCanvas(dom.window.document.getElementById('rna')); try { - const v = createVaRRI().validate(examples['2mol'].vaRRIParams); + const api = createVaRRI(); + const v = api.validate(exampleInput(api, '2mol')); const graph = container.addRNA(v.structure, { sequence: v.sequence }); const loops = graph.nodes.filter(node => node.num === -1 && node.scaffoldType === 'exterior') .map(hub => hub.nucs.map(index => graph.nodes[index - 1]).filter(node => node.nodeType === 'nucleotide')); @@ -44,7 +49,7 @@ test.each(Object.keys(examples).flatMap(name => ['ordinary', 'linear', 'free-cro const api = createVaRRI({ root }); const container = createGraphCanvas(root); try { - const v = api.validate(examples[name].vaRRIParams); + const v = api.validate(exampleInput(api, name)); const graph = container.addRNA(v.structure, { sequence: v.sequence }); if (mode === 'linear') api.applyLinearHelixSprings(container, v, { rri: true, structure: true }); if (mode === 'free-crossing') { diff --git a/tests/pseudoknot-example.test.js b/tests/pseudoknot-example.test.js index c652d79..d20f277 100644 --- a/tests/pseudoknot-example.test.js +++ b/tests/pseudoknot-example.test.js @@ -5,7 +5,11 @@ import { createVaRRI } from '../src/core/index.js'; import { applyPseudoknotLinkStrength, relaxForceGraphScaffold } from '../src/core/canvas/layout/scaffold.js'; import examples from '../example-data.js'; -const input = () => createVaRRI().validate(examples['crossing-rri'].vaRRIParams); +const input = () => { + const api = createVaRRI(); + const session = api.decodeJsonState(examples['crossing-rri'].vaRRIParams); + return api.validate({ ...session.fields, ...session.annotations }); +}; const makeGraph = structure => createRnaGraph(structure, { sequence: structure.replaceAll(/[^&]/g, 'A'), labelInterval: 0, }); diff --git a/tests/ui-bootstrap.test.js b/tests/ui-bootstrap.test.js index c0ee43d..997f6e2 100644 --- a/tests/ui-bootstrap.test.js +++ b/tests/ui-bootstrap.test.js @@ -65,8 +65,8 @@ test('boots through bound UI actions without inline handlers', async () => { expect(trigger.textContent.trim()).toBe(profileExample.nameShort); expect(trigger.textContent).not.toContain(profileExample.descriptionShort); expect(profileButton.getAttribute('aria-current')).toBe('true'); - expect(document.getElementById('sequence').value).toBe(profileExample.vaRRIParams.sequence); - expect(document.getElementById('structure').value).toBe(profileExample.vaRRIParams.structure); + expect(document.getElementById('sequence').value).toBe(profileExample.vaRRIParams.fields.sequence); + expect(document.getElementById('structure').value).toBe(profileExample.vaRRIParams.fields.structure); expect(document.getElementById('cropping').value).toBe('3'); expect(document.getElementById('highlighting').value).toBe('basepairs'); expect(document.getElementById('backgroundhighlighting').value).toBe('nothing'); @@ -91,8 +91,8 @@ test('boots through bound UI actions without inline handlers', async () => { expect(trigger.textContent.trim()).toBe(mutationExample.nameShort); expect(mutationButton.getAttribute('aria-current')).toBe('true'); - expect(document.getElementById('sequence').value).toBe(mutationExample.vaRRIParams.sequence); - expect(document.getElementById('structure').value).toBe(mutationExample.vaRRIParams.structure); + expect(document.getElementById('sequence').value).toBe(mutationExample.vaRRIParams.fields.sequence); + expect(document.getElementById('structure').value).toBe(mutationExample.vaRRIParams.fields.structure); expect(document.getElementById('startIndex1').value).toBe('-35'); expect(document.getElementById('startIndex2').value).toBe('2'); expect(document.getElementById('cropping').value).toBe('-1'); @@ -144,7 +144,8 @@ test('linear controls preserve the force choice through examples, toggles, and s modifyExamples(examples) { examples['linear-test'] = { ...examples['2mol'], name: 'Linear layout test', nameShort: 'Linear layout test', descriptionShort: 'Static linear RRI.', - vaRRIParams: { ...examples['2mol'].vaRRIParams, forceLayout: '0', forceLayoutLinearRRI: '1' } }; + vaRRIParams: { ...examples['2mol'].vaRRIParams, + fields: { ...examples['2mol'].vaRRIParams.fields, forceLayout: false, forceLayoutLinearRRI: true } } }; }, }); const openSpy = jest.spyOn(window, 'open').mockImplementation(() => null); diff --git a/tests/ui-sequence-names.test.js b/tests/ui-sequence-names.test.js index 0bcadad..06e5680 100644 --- a/tests/ui-sequence-names.test.js +++ b/tests/ui-sequence-names.test.js @@ -1,6 +1,15 @@ import { jest } from '@jest/globals'; import { mountViewer } from './helpers/vue-viewer.js'; +const mountSequenceNameViewer = () => mountViewer({ + modifyExamples(examples) { + const example = examples['2mol']; + examples['2mol'] = { ...example, vaRRIParams: { ...example.vaRRIParams, + fields: { ...example.vaRRIParams.fields, seqName1: 'Seq. 1', seqName2: 'Seq. 2' }, + annotations: { ...example.vaRRIParams.annotations, textAnnotations: [] } } }; + }, +}); + function changeField(id, value) { const input = document.getElementById(id); input.value = value; @@ -9,7 +18,7 @@ function changeField(id, value) { } test('sequence name fields and named annotation dialogs synchronize without rerendering RNA', async () => { - const viewer = await mountViewer(); + const viewer = await mountSequenceNameViewer(); try { const names = document.querySelector('fieldset.sequence-names'); expect(names.querySelector('legend').textContent).toBe('Sequence Names'); @@ -62,7 +71,7 @@ test('sequence name fields and named annotation dialogs synchronize without rere }); test('deleting or clearing sequence-name labels only unpositions them', async () => { - const viewer = await mountViewer(); + const viewer = await mountSequenceNameViewer(); try { const annotation = viewer.api.registerTextAnnotation({ text: 'RNA alpha', sequenceNameFor: '1', position: { x: 12, y: 34 } }); @@ -88,7 +97,7 @@ test('deleting or clearing sequence-name labels only unpositions them', async () test.each([ { seqName1: 'Explicit α & β', seqName2: 'Other RNA' }, ])('sequence name URL fields override label text and survive shared links: %j', async nameFields => { - const viewer = await mountViewer(); + const viewer = await mountSequenceNameViewer(); try { const params = new URLSearchParams({ sequence: 'ACGU&UGCA', structure: '((..&..))', ...nameFields, textAnnotations: JSON.stringify([ @@ -111,7 +120,7 @@ test.each([ }); test('a retained name for an absent strand is identified and cannot start a list drag', async () => { - const viewer = await mountViewer(); + const viewer = await mountSequenceNameViewer(); try { const annotation = viewer.api.registerTextAnnotation({ text: 'Second RNA', sequenceNameFor: '2' }); viewer.view.state.fields.sequence = 'ACGU'; diff --git a/tests/ui-text-annotations.test.js b/tests/ui-text-annotations.test.js index 3b74aef..57a4121 100644 --- a/tests/ui-text-annotations.test.js +++ b/tests/ui-text-annotations.test.js @@ -1,6 +1,15 @@ import { jest } from '@jest/globals'; import { mountViewer } from './helpers/vue-viewer.js'; +const mountAnnotationViewer = () => mountViewer({ + modifyExamples(examples) { + const example = examples['2mol']; + examples['2mol'] = { ...example, vaRRIParams: { ...example.vaRRIParams, + fields: { ...example.vaRRIParams.fields, seqName1: 'Seq. 1', seqName2: 'Seq. 2' }, + annotations: { ...example.vaRRIParams.annotations, textAnnotations: [] } } }; + }, +}); + function editField(id, value) { const input = document.getElementById(id); if (input.type === 'checkbox') input.checked = value; @@ -32,7 +41,7 @@ async function saveDialog(viewer) { } test('text annotation controls add, style, edit and remove plain text without rerendering the graph', async () => { - const viewer = await mountViewer(); + const viewer = await mountAnnotationViewer(); try { const refresh = jest.spyOn(viewer.api, 'refreshTextAnnotations'); viewer.renderSpy.mockClear(); @@ -87,7 +96,7 @@ test('text annotation controls add, style, edit and remove plain text without re }); test('free text can be added before sequence input, validates style, and clears drafts independently', async () => { - const viewer = await mountViewer(); + const viewer = await mountAnnotationViewer(); try { viewer.view.actions.clearAll(); await addDialog(viewer); @@ -119,7 +128,7 @@ test('free text can be added before sequence input, validates style, and clears }); test('text dialogs cancel draft changes and the text panel follows visualization settings', async () => { - const viewer = await mountViewer(); + const viewer = await mountAnnotationViewer(); try { const panels = [...document.querySelectorAll('.controls-column > aside')]; expect(panels.slice(0, 4).map(panel => panel.getAttribute('aria-label'))) @@ -146,7 +155,7 @@ test('text dialogs cancel draft changes and the text panel follows visualization }); test('small canvas labels keep readable list previews without changing their stored or edited size', async () => { - const viewer = await mountViewer(); + const viewer = await mountAnnotationViewer(); try { const annotation = viewer.api.registerTextAnnotation({ text: 'Seq. 1', size: 6.4 }); viewer.view.actions.syncAnnotations(); @@ -162,7 +171,7 @@ test('small canvas labels keep readable list previews without changing their sto }); test('text styles and placement survive shared URLs while editor drafts are omitted', async () => { - const viewer = await mountViewer(); + const viewer = await mountAnnotationViewer(); try { viewer.api.registerTextAnnotation({ text: 'Label α: &, + ', bold: true, italic: true, size: 21, color: '#123456', position: { x: -24.5, y: 3.25 } }); @@ -184,7 +193,7 @@ test('text styles and placement survive shared URLs while editor drafts are omit }); test('list drops accept only this viewer drag and delegate client coordinates to the core', async () => { - const viewer = await mountViewer(); + const viewer = await mountAnnotationViewer(); try { const annotation = viewer.api.registerTextAnnotation({ text: 'Drag me' }); viewer.view.actions.syncAnnotations(); @@ -213,7 +222,7 @@ test('list drops accept only this viewer drag and delegate client coordinates to }); test('clearing the viewer cancels pending text placement', async () => { - const viewer = await mountViewer(); + const viewer = await mountAnnotationViewer(); try { viewer.api.registerTextAnnotation({ text: 'Cancel this drag' }); viewer.view.actions.syncAnnotations(); diff --git a/tests/vue-state.test.js b/tests/vue-state.test.js index 10f9f55..e5172d8 100644 --- a/tests/vue-state.test.js +++ b/tests/vue-state.test.js @@ -7,7 +7,7 @@ test('Vue state remains serializable and does not proxy the visualization API', expect(isReactive(viewer.view.state)).toBe(true); expect(isProxy(viewer.api)).toBe(false); const state = JSON.parse(JSON.stringify(viewer.view.state)); - expect(state.fields.sequence).toBe(viewer.examples['2mol'].vaRRIParams.sequence); + expect(state.fields.sequence).toBe(viewer.examples['2mol'].vaRRIParams.fields.sequence); expect(state.annotations.subsequences).toHaveLength(2); expect(state.annotations.mutations).toHaveLength(2); expect(state).not.toHaveProperty('force');